mizdra's blog

ぽよぐらみんぐ

React Server Components で時限式コンポーネントを作る

特定の時間になったらコンテンツをページに出したい、ということがあると思う。漫画サービスなら「ゴールデンウィーク限定!全話無料キャンペーン!」みたいなのとか。

普段の業務ではこうしたことを実現するために、時限式コンポーネントや、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/serverrenderToString を使えばできるとは思う。まあでも CSS 送りたい時はどうするのとか、ユーザイベントに反応するコンポーネントを作るにはどうするの *3 とか色々面倒な検討事項があるから、やりたくはないけど...。

とはいえこうして振り返ってみると、Server Components が導入されたことで Client Components だけあった時代よりも時限式コンポーネントを作るのがずっと簡単になった、ということが分かると思う。

検証に使ったコード

github.com

*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 であり、イベントハンドラが一切設定されていない。そのためユーザのクリックイベントなどには何も反応できない。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.