mcpField
S
ssr

ssr

github.com/tanstack開発15.3k192 回インストール4 日前に更新
リポジトリ

ssr について

TanStack Router のストリーミング/非ストリーミング SSR、水和、ドキュメントメタデータを解説するスキルです。SSR を統合する開発者向けで、本番利用には TanStack Start が推奨されています。

主な機能

  1. 01ストリーミングと非ストリーミング SSR
  2. 02サーバーレンダリングとクライアント水和
  3. 03Loader データのデハイドレーション
  4. 04ルート単位の head 管理
  5. 05Express 連携例

活用シーン

  1. 01TanStack Router アプリに SSR を追加
  2. 02遅延 Loader コンテンツをストリーミング
  3. 03ルートごとにドキュメントメタデータを設定
  4. 04Express にレンダリングを統合

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/tanstack/router --skill ssr

インストール後、Claude Code で/ssrを入力して読み込まれたか確認します。

詳細

SSR(サーバーサイドレンダリング)

WARNING: SSR API は実験的なものです。TanStack Start と内部実装を共有しており、変更される可能性があります。本番環境での SSR には TanStack Start を使用することをおすすめします。既存のサーバーと統合する場合に限り、手動で SSR を設定してください。

CRITICAL: TanStack Router はクライアントファーストです。ローダーはデフォルトでクライアント上で実行されます。SSR を有効にすると、ローダーはクライアントとサーバーの両方で実行されます。Remix や Next.js のローダーのように、サーバー上でのみ実行されるわけではありません。router-core/data-loading を参照してください。

CRITICAL: Next.js のパターン(getServerSideProps、App Router、サーバーコンポーネント)や、Remix のパターン(サーバー専用のローダーエクスポート)を生成しないでください。TanStack Router には独自の SSR API があります。

概念

SSR には次の 2 つの形式があります。

  • 非ストリーミング:サーバーでページ全体をレンダリングし、単一の HTML レスポンスとして送信した後、クライアントでハイドレーションします。
  • ストリーミング:最初に表示する重要な部分をすぐに送信し、残りのコンテンツは準備でき次第、段階的にストリーミングします。

主な動作:

  • サーバー上ではメモリ履歴が自動的に使用されます(window はありません)。
  • ローダーデータはサーバー上で自動的にデハイドレーションされ、クライアント上でハイドレーションされます。
  • データのシリアライズは、Date、Error、FormData、undefined を標準でサポートしています。

セットアップ:共有ルーターファクトリー

ルーターはサーバーとクライアントで同じように作成する必要があります。共有ファイルからファクトリー関数をエクスポートしてください。

// src/router.tsx
import { createRouter as createTanstackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export function createRouter() {
  return createTanstackRouter({ routeTree })
}

declare module '@tanstack/react-router' {
  interface Register {
    router: ReturnType<typeof createRouter>
  }
}

非ストリーミング SSR

サーバーエントリー(defaultRenderHandler を使用)
// src/entry-server.tsx
import {
  createRequestHandler,
  defaultRenderHandler,
} from '@tanstack/react-router/ssr/server'
import { createRouter } from './router'

export async function render({ request }: { request: Request }) {
  const handler = createRequestHandler({ request, createRouter })
  return await handler(defaultRenderHandler)
}
サーバーエントリー(カスタムラッパー用に renderRouterToString を使用)
// src/entry-server.tsx
import {
  createRequestHandler,
  renderRouterToString,
  RouterServer,
} from '@tanstack/react-router/ssr/server'
import { createRouter } from './router'

export function render({ request }: { request: Request }) {
  const handler = createRequestHandler({ request, createRouter })

  return handler(({ responseHeaders, router }) =>
    renderRouterToString({
      responseHeaders,
      router,
      children: <RouterServer router={router} />,
    }),
  )
}
クライアントエントリー
// src/entry-client.tsx
import { hydrateRoot } from 'react-dom/client'
import { RouterClient } from '@tanstack/react-router/ssr/client'
import { createRouter } from './router'

const router = createRouter()

hydrateRoot(document, <RouterClient router={router} />)

ストリーミング SSR

サーバーエントリー(defaultStreamHandler を使用)
// src/entry-server.tsx
import {
  createRequestHandler,
  defaultStreamHandler,
} from '@tanstack/react-router/ssr/server'
import { createRouter } from './router'

export async function render({ request }: { request: Request }) {
  const handler = createRequestHandler({ request, createRouter })
  return await handler(defaultStreamHandler)
}
サーバーエントリー(カスタムラッパー用に renderRouterToStream を使用)
// src/entry-server.tsx
import {
  createRequestHandler,
  renderRouterToStream,
  RouterServer,
} from '@tanstack/react-router/ssr/server'
import { createRouter } from './router'

export function render({ request }: { request: Request }) {
  const handler = createRequestHandler({ request, createRouter })

  return handler(({ request, responseHeaders, router }) =>
    renderRouterToStream({
      request,
      responseHeaders,
      router,
      children: <RouterServer router={router} />,
    }),
  )
}

ストリーミングは自動的に行われます。defaultStreamHandler または renderRouterToStream を使用すると、遅延データ(ローダーから返され、await されていない Promise)とストリーミングされるマークアップがそのまま機能します。

ドキュメントのヘッド管理

head ルートオプションを使って、<title>、<meta>、<link>、<style> タグを管理します。<head> に <HeadContent /> を、<body> に <Scripts /> をレンダリングしてください。

head を設定したルート
// src/routes/__root.tsx
import {
  createRootRoute,
  HeadContent,
  Outlet,
  Scripts,
} from '@tanstack/react-router'

export const Route = createRootRoute({
  head: () => ({
    meta: [
      { charSet: 'UTF-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
      { title: 'My App' },
    ],
    links: [{ rel: 'icon', href: '/favicon.ico' }],
  }),
  component: RootComponent,
})

function RootComponent() {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        <Outlet />
        <Scripts />
      </body>
    </html>
  )
}
ルートごとの head(ネスト時の重複排除)

子ルートの title タグと meta タグは、同じ name/property を持つ親タグを上書きします。

// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    return { post }
  },
  head: ({ loaderData }) => ({
    meta: [
      { title: loaderData.post.title },
      { name: 'description', content: loaderData.post.excerpt },
    ],
  }),
  component: PostPage,
})

function PostPage() {
  const { post } = Route.useLoaderData()
  return <article>{post.content}</article>
}
SPA の head(HTML 全体を制御しない場合)

サーバーレンダリングされた HTML がない SPA では、コンポーネントツリーの先頭に <HeadContent /> をレンダリングしてください。

import { createRootRoute, HeadContent, Outlet } from '@tanstack/react-router'

const rootRoute = createRootRoute({
  head: () => ({
    meta: [{ title: 'My SPA' }],
  }),
  component: () => (
    <>
      <HeadContent />
      <Outlet />
    </>
  ),
})

body のスクリプト

head.scripts とは別の scripts を使って、アプリのエントリーポイントより前に <body> へスクリプトを挿入します。

export const Route = createRootRoute({
  scripts: () => [{ children: 'console.log("runs before hydration")' }],
})

<Scripts /> コンポーネントがこれらをレンダリングします。<body> の末尾に配置してください。

ハイドレーション前のスクリプトに使う ScriptOnce

ScriptOnce は SSR 中に <script> をレンダリングします。このスクリプトはすぐに実行され、自身を削除します。クライアントナビゲーション時には何も行いません(重複して実行されません)。

import { ScriptOnce } from '@tanstack/react-router'

const themeScript = `(function() {
  try {
    const theme = localStorage.getItem('theme') || 'auto';
    const resolved = theme === 'auto'
      ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
      : theme;
    document.documentElement.classList.add(resolved);
  } catch (e) {}
})();`

function ThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    <>
      <ScriptOnce children={themeScript} />
      {children}
    </>
  )
}

スクリプトが DOM を変更する場合(例:<html> にクラスを追加する場合)、その要素で suppressHydrationWarning を使用してください。

<html lang="en" suppressHydrationWarning>

Express 統合の例

createRequestHandler は Web API の Request を受け取り、Web API の Response を返します。Express では、形式を相互に変換してください。

// src/entry-server.tsx
import { pipeline } from 'node:stream/promises'
import {
  RouterServer,
  createRequestHandler,
  renderRouterToString,
} from '@tanstack/react-router/ssr/server'
import { createRouter } from './router'
import type express from 'express'

export async function render({
  req,
  res,
}: {
  req: express.Request
  res: express.Response
}) {
  const protocol = req.get('x-forwarded-proto') ?? req.protocol
  const host = req.get('x-forwarded-host') ?? req.get('host')
  const url = new URL(req.originalUrl || req.url, `${protocol}://${host}`).href

  const request = new Request(url, {
    method: req.method,
    headers: (() => {
      const headers = new Headers()
      for (const [key, value] of Object.entries(req.headers)) {
        headers.set(key, value as any)
      }
      return headers
    })(),
  })

  const handler = createRequestHandler({ request, createRouter })

  const response = await handler(({ responseHeaders, router }) =>
    renderRouterToString({
      responseHeaders,
      router,
      children: <RouterServer router={router} />,
    }),
  )

  res.status(response.status)
  response.headers.forEach((value, name) => {
    res.setHeader(name, value)
  })

  return pipeline(response.body as any, res)
}

よくある間違い

1. 高:環境を確認せずにローダーでブラウザー API を使用する

SSR ではローダーがクライアントとサーバーの両方で実行されます。ブラウザー専用 API(window、document、localStorage)を使うと、サーバー上でエラーになります。

// WRONG — crashes on server
loader: async () => {
  const token = localStorage.getItem('token')
  return fetchData(token)
}

// CORRECT — guard with environment check
loader: async () => {
  const token =
    typeof window !== 'undefined' ? localStorage.getItem('token') : null
  return fetchData(token)
}
2. 中:サーバーレンダリングされるコンテンツにハッシュフラグメントを使用する

ハッシュフラグメント(#section)はサーバーに送信されません。ハッシュに基づく条件付きレンダリングは、ハイドレーションの不一致を引き起こします。

// WRONG — server has no hash, client does → mismatch
component: () => {
  const hash = window.location.hash
  return hash === '#admin' ? <AdminPanel /> : <UserPanel />
}

// CORRECT — use search params for server-visible state
validateSearch: z.object({ view: fallback(z.enum(['admin', 'user']), 'user') }),
component: () => {
  const { view } = Route.useSearch()
  return view === 'admin' ? <AdminPanel /> : <UserPanel />
}
3. 重大:Next.js、Remix、または React Router DOM のパターンを生成する

TanStack Router は getServerSideProps、getStaticProps、App Router の page.tsx、Remix 形式のサーバー専用 loader エクスポート、または react-router-dom の機能を使用しません。

誤ったファイル構成
WRONG (Next.js Pages Router):
  src/pages/index.tsx
  src/pages/_app.tsx
  src/pages/posts/[id].tsx

WRONG (Next.js App Router):
  app/layout.tsx
  app/page.tsx
  app/posts/[id]/page.tsx

WRONG (Next.js custom App):
  _app/index.tsx
  pages/_app.tsx, pages/_document.tsx

CORRECT (TanStack Router file-based routing):
  src/routes/__root.tsx
  src/routes/index.tsx
  src/routes/posts/$postId.tsx
誤ったインポート
// WRONG — react-router-dom is a different library
import {
  Link,
  useNavigate,
  BrowserRouter,
  Route,
  Routes,
} from 'react-router-dom'

// WRONG — Next.js Link/router
import Link from 'next/link'
import { useRouter } from 'next/router' // Pages Router
import { useRouter } from 'next/navigation' // App Router

// CORRECT — everything routing-related lives in @tanstack/react-router
import {
  Link,
  useNavigate,
  useRouter,
  useLocation,
  redirect,
} from '@tanstack/react-router'
誤ったローダー/データ取得パターン
// WRONG — Next.js Pages Router
export async function getServerSideProps() {
  return { props: { data: await fetchData() } }
}

// WRONG — Remix
export async function loader({ request }: LoaderFunctionArgs) {
  return json({ data: await fetchData() })
}

// CORRECT — TanStack Router
export const Route = createFileRoute('/data')({
  loader: async () => {
    const data = await fetchData()
    return { data }
  },
  component: DataPage,
})

function DataPage() {
  const { data } = Route.useLoaderData()
  return <div>{data}</div>
}

エージェントの出力に src/pages/、app/layout.tsx、react-router-dom、または上記のいずれかが含まれている場合、エージェントは誤ったフレームワーク向けに生成しています。ビルドに失敗するか、実行時に競合する重複した / ルートが生成されます。

課題:クライアント優先のローダーと SSR

TanStack Router のローダーは、設計上クライアント優先です。SSR が有効な場合、両方の環境で実行されます。つまり、次のようになります。

  • ブラウザー API はデフォルトで使用できます(クライアントのみ)が、SSR では動作しません
  • データベースへのアクセスはローダーに含めるべきではありません(Remix/Next とは異なります)。API ルートを使用してください
  • SSR でサーバー専用のデータロジックを使う場合は、TanStack Start のサーバー関数を使用してください

ローダーの基本については、router-core/data-loading を参照してください。

関連項目

  • router-core/data-loading — SSR によってローダーの実行場所が変わります
  • @tanstack/react-router#compositions/router-query — TanStack Query を使った SSR のデハイドレーション/ハイドレーション

コメント