🌴

App RouterでLIFF(LINEミニアプリ)を構築する

に公開

公式のCreate LIFF Appは更新されていない

LINEミニアプリ(LINE Mini app)や、LIFFを構築する場合、Next.jsを利用するケースが多いのではないかと思います。

が、公式テンプレートのGitHubのコミット履歴を見るとNext.jsのベース部分は3年くらい前のものなので、当然ながらApp Routerには対応していません。

https://github.com/line/create-liff-app/tree/d12f0ec22254320b36976b5a90b07c484c8b5bc4/templates/nextjs-ts

手が空いたらコントリビュートしてみたいと思いますが、いったん記事にします。

構築手順

  1. App RouterのNext.jsアプリケーションを作成
  2. liffライブラリをインストール
  3. liffのコンテキストを保持するProviderを作成
  4. rootのlayoutでProviderを利用

App RouterのNext.jsアプリケーションを作成

まずは、なんらかの手段でApp RouterのNext.jsアプリケーションを作成します。
私はよくt3-appを使用していますが、それ以外でもかまいません。

pnpm create t3-app@latest

liffライブラリをインストール

次に、liffライブラリをインストールします。
必要なライブラリは1つだけです。

pnpm add @line/liff

liffのコンテキストを保持するProviderを作成

liffのコンテキストを保持するProviderを作成します。
Create LIFF Appのテンプレートの_app.tsxに相当する処理を行う部分です。

環境変数にNEXT_PUBLIC_LIFF_IDが設定されている前提のコードとなります。

liffライブラリはwindowオブジェクトに依存するため、サーバーサイドでは利用できません。
同様に、非同期処理をPromiseとして記述しuse()Suspenseを利用する書き方もできません。

そのため、use clientディレクティブを利用し、useEffect内で初期化を行います。

LiffProvider.tsx
"use client";

import type { Liff } from "@line/liff";
import { createContext, useContext, useEffect, useState } from "react";
import { LiffError } from "./LiffError";
import { LiffFallback } from "./LiffFallback";

type ILiffContext = { liff: Liff };
const LiffContext = createContext<ILiffContext | null>(null);

export const LiffProvider = ({ children }: { children: React.ReactNode }) => {
  const [liffObject, setLiffObject] = useState<Liff | null>(null);
  const [liffError, setLiffError] = useState<Error | null>(null);

  useEffect(() => {
    void import("@line/liff").then(({ default: liff }) => {
      liff
        .init({ liffId: process.env.NEXT_PUBLIC_LIFF_ID! })
        .then(() => setLiffObject(liff))
        .catch((error) => setLiffError(error as Error));
    });
  }, []);

  if (liffError) return <LiffError liffError={liffError} />;
  if (!liffObject) return <LiffFallback />;

  return <LiffContext.Provider value={{ liff: liffObject }}>{children}</LiffContext.Provider>;
};

export function useLiff() {
  const context = useContext(LiffContext);
  if (!context) throw new Error("useLiff must be used within a LiffProvider");
  return context;
}

実際にいろんな企業が提供しているLIFFを見るとliff.init()が完了するまでローディング画面を表示するアプリが多い印象です。
というのもLIFFには2次リダイレクトがあるため、ルートページ以外にアクセスした場合には、対象パスが開く前にルートページが一瞬表示されてしまうためです。

そのため、上記実装ではLiffErrorLiffFallbackをそれぞれ作成して表示させるようにしています。

rootのlayoutでProviderを利用

最後に、rootのlayout.tsxで先ほど作成したLiffProviderを利用します。

また、このときtemplate.tsxではなくlayout.tsxを利用するべきです。
なぜならliffの初期化はアプリ全体に渡って1度のみ行えばよく、ページごとの初期化は不要であるためです。

このようにProviderとして分離すればlayout.tsxにuse clientは不要になり、root配下のページにServer Componentを利用できます。

/src/app/layout.tsx
import { LiffProvider } from "./LiffProvider";

export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="ja" className="font-sans">
      <body className="overflow-hidden">
        <LiffProvider>{children}</LiffProvider>
      </body>
    </html>
  );
}

おまけ

use()Suspenseが使えない理由

参考までに、useを使用した場合どうエラーになるのかミニマムケースを記しておきます。

WorldProvider.tsx
const worldPromise = new Promise<string>((resolve) => {
  // エラー:"alert is not defined"
  alert("worldPromise");
  // エラー:"Hydration failed because the server rendered text didn't match the client."
  resolve(typeof window == "undefined" ? "server world" : "client world");
});

const WorldContext = createContext<string | null>(null);

export const WorldProvider = ({ children }: { children: React.ReactNode }) => {
  const world = use(worldPromise);
  return (
    <WorldContext.Provider value={world}>{children}</WorldContext.Provider>
  );
};

Viewportの設定

/src/app/layout.tsx
import { type Viewport } from "next";

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  viewportFit: "cover",
  userScalable: false,
};

背景に白色以外を設定している場合、rootのlayoutでは忘れずにViewportの設定もしておくことを推奨します。

これはLIFFをiPhoneで開いたときに、ホームインジケータ部分まで背景色を広げたりするのにviewportFit: "cover"が必要だからです。

image

ViewportはLIFFに限った設定ではないですが、特にLIFFだと他のブラウザと違いボトム領域に操作系エリアがないため、有色背景でホームインジケータ部分が白く目立つという現象に遭遇しやすいと思います。

まだ、Viewportをcoverに設定した場合は、あわせてenv(safe-area-inset-bottom)を用いるとよいでしょう。
env(safe-area-inset-bottom)はiOS Safariがホームインジケータのためのセーフエリアサイズをpx単位で返してくれる環境変数です。

一応、このあたりをよしなにやってくれそうなtailwindcss-safe-areaというライブラリがあるようですが、私は使ったことがないので、ご参考程度にしてください。

bodyのスクロール設定

画面縦幅ぴったりにコンテンツを表示するようにしていても、iPhoneではスクロールがオーバーランする挙動があります。

bodyタグにoverflow-hiddenを設定することで、この挙動を回避できます。

ボトムナビなどを配置することが多いLIFFでは設定しておくほうがよいでしょう。

キリフダ株式会社

Discussion