App RouterでLIFF(LINEミニアプリ)を構築する
公式のCreate LIFF Appは更新されていない
LINEミニアプリ(LINE Mini app)や、LIFFを構築する場合、Next.jsを利用するケースが多いのではないかと思います。
が、公式テンプレートのGitHubのコミット履歴を見るとNext.jsのベース部分は3年くらい前のものなので、当然ながらApp Routerには対応していません。
手が空いたらコントリビュートしてみたいと思いますが、いったん記事にします。
構築手順
- App RouterのNext.jsアプリケーションを作成
- liffライブラリをインストール
- liffのコンテキストを保持するProviderを作成
- 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
内で初期化を行います。
"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次リダイレクトがあるため、ルートページ以外にアクセスした場合には、対象パスが開く前にルートページが一瞬表示されてしまうためです。
そのため、上記実装ではLiffError
やLiffFallback
をそれぞれ作成して表示させるようにしています。
rootのlayoutでProviderを利用
最後に、rootのlayout.tsxで先ほど作成したLiffProviderを利用します。
また、このときtemplate.tsxではなくlayout.tsxを利用するべきです。
なぜならliffの初期化はアプリ全体に渡って1度のみ行えばよく、ページごとの初期化は不要であるためです。
このようにProviderとして分離すればlayout.tsxにuse client
は不要になり、root配下のページにServer Componentを利用できます。
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
を使用した場合どうエラーになるのかミニマムケースを記しておきます。
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の設定
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"
が必要だからです。
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