特定の時間になったらコンテンツをページに出したい、ということがあると思う。漫画サービスなら「ゴールデンウィーク限定!全話無料キャンペーン!」みたいなのとか。
普段の業務ではこうしたことを実現するために、時限式コンポーネントや、ScheduledComponent などと呼ばれるものを作ってる *1。
// components/ScheduledComponent.tsx export function ScheduledComponent({showAt, children}: { showAt: Date; children: React.ReactNode; }) { if (new Date() < showAt) { return null; } else { return children; } }
これを Next.js Pages Router などから、以下のようにして使う。
// pages/index.page.tsx import { ScheduledComponent } from "@/components/ScheduledComponent"; export default function Home() { return ( <main> <ScheduledComponent showAt={new Date("2024-01-01T00:00:00.000Z")}> <h1>Happy New Year Campaign!</h1> </ScheduledComponent> <ScheduledComponent showAt={new Date("2024-12-25T00:00:00.000Z")}> <h1>Christmas Campaign!</h1> </ScheduledComponent> </main> ); }
Client Components では秘密のキャンペーン情報が漏洩する
ところで Next.js Pages Router では、コンポーネントはすべて Client Components となる。Client Components はその全てのソースコードがクライアント向けの bundle に含まれる。そのため、ユーザは Client Components のソースコードを (勿論 minify はされているが) 読める。
その結果、まだキャンペーン開始日になっていないにも関わらず、クライアントの bundle からキャンペーン情報が漏れてしまう。実際に Chrome devtools の Network パネルの検索機能を使ってみると、クライアントの bundle に非公開のキャンペーン情報が漏れていることがわかる。
解決策: Server Components を使う
Server Components を使うと、この問題を解決できる。Server Components はクライアントの bundle には一切含まれない。そのため、キャンペーン開始日まではユーザにその情報を隠しつつ、時間になったら公開できる。
Next.js App Router なら以下のように書けば良い。うっかり <ScheduledComponent>
が Client Components にならないよう、import 'server-only';
を付けておくのがポイント *2。
import 'server-only'; export function ScheduledComponent({showAt, children}: { showAt: Date; children: React.ReactNode; }) { if (new Date() < showAt) { return null; } else { return children; } }
// app/page.tsx import { ScheduledComponent } from "@/components/ScheduledComponent"; export default function Home() { return ( <main> <ScheduledComponent showAt={new Date("2024-01-01T00:00:00.000Z")}> <h1>Happy New Year Campaign!</h1> </ScheduledComponent> <ScheduledComponent showAt={new Date("2024-12-25T00:00:00.000Z")}> <h1>Christmas Campaign!</h1> </ScheduledComponent> </main> ); }
Chrome devtools の Network パネルの検索機能 で見てみても、クリスマスキャンペーン情報が無事漏洩しなかったことが確認できる。
参考情報
Client Components がクライアントの bundle に含まれることや、Server Components が含まれないことを知らなかった方は、以前 id:mizdra が発表した以下の資料を読んでみることをオススメします。Server Components の仕組みや漏洩の仕組みの話について詳しく書いてあります。
どうしても Next.js Pages Router で時限式コンポーネントを作りたい場合
とにかくクライアントの bundle にキャンペーン情報を含めてしまうとアウトなので、React Components に一切キャンペーン情報を書かない、というのが鉄則となる。となるとじゃあどこにキャンペーン情報を書いたらええねんという話になるのだけど... API Routes を使えば良いと思う。どういうことかと言うと、/api/get-secret-campaign
みたいな API を API Routes で生やして、そこからキャンペーン情報を返したら良い。
// pages/api/get-secret-campaign.tsx import type { NextApiRequest, NextApiResponse } from 'next'; export default function handler( req: NextApiRequest, res: NextApiResponse, ) { if (new Date() < new Date("2024-12-25T00:00:00.000Z")) { res.status(200).send(''); } else { res.status(200).send('<h1>Christmas Campaign!</h1>'); } }
// pages/index.page.tsx import { ScheduledComponent } from "@/components/ScheduledComponent"; export const getServerSideProps = (async () => { const res = await fetch('https://example.com/api/get-secret-campaign'); const secretCampaignHTML = await res.text(); return { props: { secretCampaignHTML } }; }) satisfies GetServerSideProps; export default function Home({ secretCampaignHTML, }: InferGetServerSidePropsType<typeof getServerSideProps>) { return ( <main> {secretCampaignHTML && <div dangerouslySetInnerHTML={secretCampaignHTML} />} </main> ); }
ちなみに /api/get-secret-campaign
という API のパスはクライアントの bundle に含まれることになるので、このパスに秘密の文字列を書かないよう注意が必要。
plain な HTML じゃなくてコンポーネントを返したいなら場合は、react-dom/server
の renderToString
を使えばできるとは思う。まあでも CSS 送りたい時はどうするのとか、ユーザイベントに反応するコンポーネントを作るにはどうするの *3 とか色々面倒な検討事項があるから、やりたくはないけど...。
とはいえこうして振り返ってみると、Server Components が導入されたことで Client Components だけあった時代よりも時限式コンポーネントを作るのがずっと簡単になった、ということが分かると思う。
検証に使ったコード
*1:TimeWall コンポーネントと呼ぶこともある。こっちのほうが格好良いとは思う。
*2:'use client' ディレクティブを付けない場合は、Client Components にも Server Components にもなりうる。Server Components にだけなるよう強制したければ、server-only モジュールを import する必要がある。詳しくは https://speakerdeck.com/mizdra/react-server-components-noyi-wen-wojie-kiming-kasu?slide=70 を参照。
*3:dangerouslySetInnerHTML で挿入されるのは plain な HTML であり、イベントハンドラが一切設定されていない。そのためユーザのクリックイベントなどには何も反応できない。