ブリトーのイラスト

フルスタックフレームワーク TanStack Start を試してみた

TanStack Start は TanStack Router と Vite をベースにしたフルスタック React フレームワークです。型安全なルーティング、サーバーサイドレンダリング、ストリーミング、サーバー関数、API ルートなどの機能を提供します。この記事では TanStack Start の概要と基本的な使い方を紹介します。

TanStack Start は TanStack RouterVite をベースにしたフルスタック React フレームワークです。TanStack Router が提供する型安全なルーティング機能に加えて、サーバーサイドレンダリング(SSR), ストリーミング, サーバー関数, API ルートなどの機能を備えています。この記事では TanStack Start の概要と基本的な使い方を紹介します。TanStack Start は Next.js や Remix と同じ立ち位置のフレームワークと言えるでしょう。

この記事では TanStack Start の概要と基本的な使い方を紹介します。

TanStack Start プロジェクトを作成する

それでは TanStack Start プロジェクトをゼロから作成してみましょう。Tanstack Start プロジェクトを作成するには TanStack CLI を使用するのが簡単です。以下のコマンドを実行します。

npm create @tanstack/start@latest

コマンドを実行すると対話形式でプロジェクト名や追加のライブラリを選択できます。ここではシンプルに Tailwind CSS と Biome のみを追加してテンプレートを選択せずに進めます。作成されたプロジェクトのディレクトリ構造は以下のようになります。

├── biome.json
├── package-lock.json
├── package.json
├── public
   ├── ...
├── README.md
├── src
   ├── components
   └── Header.tsx
   ├── data
   └── demo.punk-songs.ts
   ├── logo.svg
   ├── router.tsx
   ├── routes
   ├── __root.tsx
   ├── demo
   ├── ...
   └── index.tsx
   ├── routeTree.gen.ts
   └── styles.css
├── tsconfig.json
├── vite.config.ts
└── wrangler.jsonc

npm run dev コマンドで開発サーバーを起動し、http://localhost:3000 にアクセスすると以下のようなデフォルトのページが表示されます。

ルーティング

TanStack Start は TanStack Router をベースにしているため、TanStack Router と同等の機能が利用できます。ルーティングが提供する詳細な機能を知りたい場合には TanStack Router のドキュメント を参照すると良いでしょう。

src/router.tsx ファイルでルーターが定義されているため、ここでルーターの設定(スクロールの復元やプリロードの設定など)を行うことができます。routeTree は自動生成されるオブジェクトで、src/routes ディレクトリ内のファイル構造に基づいてルートが定義されます。そのためルーターの設定以外では基本的に src/router.tsx ファイルを編集する必要はありません。

src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
 
export const getRouter = () => {
  const router = createRouter({
    routeTree,
    scrollRestoration: true,
    defaultPreloadStaleTime: 0,
  })
 
  return router
}

ファイルベースのルーティング

TanStack Start ではファイルベースのルーティングが採用されており、src/routes ディレクトリ内に配置された各ファイルがルートコンポーネントとして扱われ型安全なルーティングが提供されます。例えば src/routes/index.tsx ファイルはルートパス / に対応し、src/routes/demo/punk-songs.tsx ファイルは /demo/punk-songs パスに対応します。

試しに /about パスに対応するルートコンポーネントを追加してみましょう。src/routes/about.tsx ファイルを作成すると、以下のコードが自動で生成されます。

src/routes/about.tsx
import { createFileRoute } from '@tanstack/react-router'
 
export const Route = createFileRoute('/about')({
  component: RouteComponent,
})
 
function RouteComponent() {
  return <div>Hello "/about"!</div>
}

createFileRoute 関数ではそのルートに対するオプションを指定できます。例えば head オプションを指定すると、そのルートにアクセスした際の HTML の <head> 内にメタデータを追加できます。

src/routes/about.tsx
export const Route = createFileRoute('/about')({
  component: RouteComponent,
  head: () => ({
    title: 'About Us',
    meta: [
      {
        name: 'description',
        content: 'Learn more about us on this page.',
      },
    ],
  }),
})

routeTree.gen.ts ファイルを確認すると、新たに /about ルートが追加され型定義も自動で更新されていることがわかります。

src/routeTree.gen.ts
import { Route as AboutRouteImport } from './routes/about'
 
const AboutRoute = AboutRouteImport.update({
  id: '/about',
  path: '/about',
  getParentRoute: () => rootRouteImport,
} as any)
 
// ... 省略 ...
 
export interface FileRoutesByFullPath {
  '/': typeof IndexRoute
  '/about': typeof AboutRoute
}

ルート定義に加えて型定義も自動で更新されるため、ルーティングに関する型安全性が保証されます。試しに <Link> コンポーネントの to プロパティを設定しようとすると、以下のように /about ルートが補完候補として表示されることがわかります。

http://localhost:3000/about にアクセスすると以下のように新たに追加したルートコンポーネントが表示されます。

パスパラメータを使用した動的ルーティングもサポートも試してみましょう。/posts/:postId パスに対応するルートコンポーネントを追加するには src/routes/posts/$postId.tsx ファイルを作成します。パスパラメータ postIdRoute.useParams() フックで取得できます。

src/routes/posts/[postId].tsx
import { createFileRoute } from '@tanstack/react-router'
 
export const Route = createFileRoute('/posts/$postId')({
  component: RouteComponent,
})
 
function RouteComponent() {
  const { postId } = Route.useParams()
  return <div>Hello "/posts/{postId}"!</div>
}

useParams() フックの文字列の型も自動で生成されるため、型安全にパスパラメータを扱うことができます。

root ルート

root ルートはすべてのルートの親ルートとして機能し、アプリケーション全体で共通のレイアウトや状態を提供するために使用されます。src/routes/__root.tsx ファイルが root ルートに対応しており、ここでヘッダーやフッターなどの共通コンポーネントを定義できます。

src/routes/__root.tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
 
import Header from '../components/Header'
 
import appCss from '../styles.css?url'
 
export const Route = createRootRoute({
  head: () => ({
    meta: [
      {
        charSet: 'utf-8',
      },
      {
        name: 'viewport',
        content: 'width=device-width, initial-scale=1',
      },
      {
        title: 'TanStack Start Starter',
      },
    ],
    links: [
      {
        rel: 'stylesheet',
        href: appCss,
      },
    ],
  }),
 
  shellComponent: RootDocument,
})
 
function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        <Header />
        {children}
        <TanStackDevtools
          config={{
            position: 'bottom-right',
          }}
          plugins={[
            {
              name: 'Tanstack Router',
              render: <TanStackRouterDevtoolsPanel />,
            },
          ]}
        />
        <Scripts />
      </body>
    </html>
  )
}

データフェッチングとサーバー関数

データフェッチングは Web アプリケーションを構築するうえで最も基本的な機能の 1 つです。TanStack Start(TanStack Router)はルートごとに非同期でデータをフェッチするための仕組みを提供しています。loader 関数をルートに定義することで、そのルートにアクセスした際にデータを取得できます。

例えば /posts ルートにアクセスした際にブログ記事の一覧を取得するには、createFileRoute 関数のオプションに loader 関数を追加します。loader 関数で return したデータは Route.useLoaderData() フックで取得できます。

src/routes/posts/index.tsx
import { createFileRoute } from '@tanstack/react-router'
 
type Post = {
  id: string
  title: string
}
 
export const Route = createFileRoute('/posts/')({
  component: RouteComponent,
  loader: async () => {
   const json = await fetch('https://jsonplaceholder.typicode.com/posts')
   const posts: Post[] = await json.json()
   return { posts }
  }
})
 
function RouteComponent() {
  const { posts } = Route.useLoaderData()
  return <div>
    <h1>Posts</h1>
 
    <ul>
      {posts.slice(0, 10).map((post) => (
        <li key={post.id}>
          <a href={`/posts/${post.id}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  </div>
}

ルートローダーは「TanStack Router で実行されるすべてのコードはデフォルトで isomorphic(クライアントとサーバーの両方で動作)である」という基本原則に従っています。ルートに直接アクセスした場合、ローダーはサーバー側で実行され、データの取得が完了してから HTML がクライアントに送信されます。一方、クライアント側のナビゲーションによってルートにアクセスした場合、ローダーはクライアント側で実行されます。Chrome の DevTools でネットワークタブを確認すると、クライアント側のナビゲーション時に https://jsonplaceholder.typicode.com/posts へのリクエストが発行されていることがわかります。

この挙動はルートローダーでセンシティブなデータを扱う場合に特に注意が必要です。サーバーもしくはクライアントのいずれかでのみ実行したいコードがある場合には、明示的に実行制御を行う関数を使用する必要があります。

実行場所 関数 説明
サーバー createServerFn() サーバーのみで実行される関数。クライアント側から呼ばれた場合はネットワーク経由でサーバーにリクエストが送信される。
サーバー createServerOnlyFn() サーバーのみで実行される関数。クライアント側から呼ばれた場合はエラーがスローされる。
クライアント createClientOnlyFn() クライアントのみで実行される関数。サーバー側から呼ばれた場合はエラーがスローされる。
クライアント <ClientOnly> コンポーネント クライアント側でのみレンダリングされるコンポーネント。サーバー側では fallback プロパティで指定したコンポーネントがレンダリングされる。

もし API ルートで fetch 関数を使用してデータを取得する場合に API キーが必要な場合には、createServerFn() 関数を使用してサーバー側でのみ実行される関数を定義します。

src/routes/api/getPosts.ts
import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
type Post = {
  id: string
  title: string
}
 
 
export const fetchPosts = createServerFn().handler(async () => {
  const apiKey = process.env.API_KEY
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    headers: {
      Authorization: `Bearer ${apiKey}`,
    },
  })
  const posts = await res.json() as Post[]
  return posts
})
 
export const Route = createFileRoute('/posts/')({
  component: RouteComponent,
  loader: async () => {
    const posts = await fetchPosts()
    return { posts }
  },
})

クライアントルーティングでルートにアクセスした場合には https://jsonplaceholder.typicode.com/posts へのリクエストは発行されず、http://localhost:3000/_serverFn/xxx のような API エンドポイントに対してリクエストが発行されることがわかります。

よりきめ細かい制御が必要な場合 createIsomorphicFn 関数を使用し、サーバーで実行された場合とクライアントで実行された場合の両方のハンドラーを定義できます。

import { createIsomorphicFn } from '@tanstack/react-start'
 
const storage = createIsomorphicFn()
  .server((key: string) {
    // サーバー側ではファイルに保存する
    const fs = require('node:fs')
    return fs.readFileSync(`/data/${key}`, 'utf-8')
  })
  .client((key: string) {
    // クライアント側では localStorage に保存する
    return localStorage.getItem(key)
  })

サーバー関数を使用したフォーム処理

サーバー関数を使用したフォームの送信処理を試してみましょう。まずは createServerFn 関数を使用してフォームが送信された際に実行されるサーバー関数を定義します。src/routes/posts/new.tsx ファイルにコードを追加します。

src/routes/posts/new.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
 
const createPost = createServerFn({ method: "POST" })
  .inputValidator((data) => {
    if (!(data instanceof FormData)) {
      throw new Error('Expected FormData')
    }
    const title = data.get('title')
    const content = data.get('content')
 
    const result = PostSchema.parse({ title, content })
 
    return result
  })
  .handler(async (input) => {
    // データベースに保存する処理などをここで行う
    console.log("Creating post:", input.data.title, input.data.content);
 
    return redirect({ to: "/posts" });
  })

createServerFn 関数の引数のオプションで HTTP メソッドを POST に設定し、inputValidator 関数でフォームデータのバリデーションを行います。handler 関数で実際の処理を実装します。ここではフォームデータを直接受け取るために FormData オブジェクトであることを検証する処理を入れていますが、より簡潔に zod スキーマを使用してバリデーションを行うこともできます。

src/routes/posts/new.tsx
const createPost = createServerFn({ method: 'POST' })
  .inputValidator(PostSchema)
  .handler(async (input) => { ... })

.handler 関数内の処理ではデータベースに保存する処理などを行った後に /posts ルートにリダイレクトしています。

次にルートコンポーネントを実装します。<form> 要素の action 属性にサーバー関数の .url プロパティを設定することで、JavaScript が動作しない環境でもフォームの送信が動作するプログレッシブエンハンスメントが実現されます。

src/routes/posts/new.tsx
const createPost = createServerFn({ method: "POST" })
  .inputValidator((data) => {
    // ... 省略 ...
  })
  .handler(async (input) => {
    // ... 省略 ...
  })
 
export const Route = createFileRoute('/posts/new')({
  component: RouteComponent,
})
 
function RouteComponent() {
  return (
    <div>
      <h1>Create New Post</h1>
      <form
        method="post"
        action={createPost.url}
      >
        <div>
          <label>
            Title:
            <input type="text" name="title" />
          </label>
        </div>
        <div>
          <label>
            Content:
            <textarea name="content" />
          </label>
        </div>
        <button type="submit">Create Post</button>
      </form>
    </div>
  );
}

Chrome の Devtools で <form> 要素を確認すると、action 属性に /_serverFn/xxx のようにサーバー関数の URL が設定されていることがわかります。

サーバールート

サーバールートを使用すると、サーバーサイドでのみ実行される API エンドポイントを簡単に作成できます。サーバールートは通常のルートと同様に src/routes ディレクトリ内にファイルを作成することで定義します。createFileRoute 関数の server.handlers オプションにハンドラー関数を指定します。

src/routes/api/hello.ts
import { createFileRoute } from '@tanstack/react-router'
 
export const Route = createFileRoute('/api/hello')({
  server: {
    handlers: {
      GET: async ({ request }) => {
        return new Response(JSON.stringify({ message: 'Hello, world!' }), {
          headers: { 'Content-Type': 'application/json' },
        })
      },
    },
  },
})

上記の例では /api/hello パスの GET メソッドに対するハンドラー関数を実装しています。ハンドラー関数では Web 標準の Request オブジェクトを受け取り、Response オブジェクトを返しています。ブラウザで http://localhost:3000/api/hello にアクセスすると以下のようにレスポンスが表示されます。

サーバールートと App ルート(通常のルート)は同じファイル内で定義できます。

src/routes/api/hello.tsx
import { createFileRoute } from '@tanstack/react-router'
 
export const Route = createFileRoute('/api/hello')({
  component: RouteComponent,
  server: {
    handlers: {
      POST: async ({ request }) => {
        const data = await request.json()
        return new Response(JSON.stringify({ message: `Hello, ${data.name}!` }), {
          headers: { 'Content-Type': 'application/json' },
        })
      },
    },
  },
})
 
function RouteComponent() {
  return <div>
    <h1>API Hello Route</h1>
 
    <form
      method="post"
      action="/api/hello"
      onSubmit={async (e) => {
        e.preventDefault()
        const formData = new FormData(e.currentTarget)
        const name = formData.get('name')
 
        const response = await fetch('/api/hello', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ name }),
        })
 
        const result = await response.json()
        alert(result.message)
      }}
    >
      <label>
        Name:
        <input type="text" name="name" />
      </label>
      <button type="submit">Send</button>
    </form>
  </div>
}

ISR(Incremental Static Regeneration)

ISR(Incremental Static Regeneration)は、静的に生成されたコンテンツを CDN から配信しながら、バックグラウンドで定期的に再生成する仕組みです。これにより、静的サイトのパフォーマンスの利点と、動的コンテンツの鮮度の両方を享受できます。

TanStack Start の ISR は、標準的な HTTP キャッシュヘッダーを活用しており、あらゆる CDN で動作します。ページレベルとデータレベルの両方でキャッシュの動作を制御できます。

最も一般的な ISR のパターンは、Cache-Control ヘッダーを使用する方法です。stale-while-revalidate ディレクティブで指定した期間は古いコンテンツを許可しつつ、バックグラウンドで新しいコンテンツをフェッチしてキャッシュを更新します。

ルート定義の headers オプションでキャッシュヘッダーを設定できます。キャッシュヘッダーは Tanstack Router が提供するクライアントキャッシュである staleTime と組み合わせて使用できます。

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 }
  },
  headers: () => ({
    // CDN で 1 時間キャッシュし、最大 1 日間は古いコンテンツを許可する
    'Cache-Control':
      'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
  }),
  // クライアントキャッシュを 5 分間有効にする
  // クライアントキャッシュはメモリ上に保存される
  staleTime: 5 * 60_000,
})
 
function RouteComponent() {
  const { post } = Route.useLoaderData()
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

キャッシュを設定しないようにする場合には、Cache-Control ヘッダーに private ディレクティブを設定します。

src/routes/user/profile.tsx
import { createFileRoute } from '@tanstack/react-router'
 
export const Route = createFileRoute('/user/profile')({
  loader: async () => {
    const profile = await fetchUserProfile()
    return { profile }
  },
  headers: () => ({
    // キャッシュを無効化
    'Cache-Control': 'private, no-store',
  }),
})

静的プリレンダリングと組み合わせることで、ビルド時に生成された静的コンテンツを CDN から高速に配信しつつ、バックグラウンドで定期的に再生成できます。プリレンダリングは vite.config.ts ファイルで設定します。

vite.config.ts
// vite.config.ts
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { defineConfig } from 'vite'
 
export default defineConfig({
  plugins: [
    tanstackStart({
      prerender: {
        enabled: true,
        // 事前にレンダリングされたページ内のリンクもクロールし、プリレンダリングする
        crawlLinks: true,
      },
    }),
  ],
})

まとめ

  • TanStack Start は TanStack Router と Vite をベースにしたフルスタック React フレームワークであり、型安全なルーティング、サーバーサイドレンダリング、ストリーミング、サーバー関数、API ルートなどの機能を提供する。
  • ファイルベースのルーティングにより、src/routes ディレクトリ内のファイル構造に基づいてルートが自動生成され、型安全なルーティングが実現される。
  • ルートごとに非同期でデータをフェッチするための loader 関数を定義でき、Route.useLoaderData() フックで取得できる。
  • createServerFn 関数を使用してサーバー側でのみ実行される関数を定義でき、フォームの送信処理などに利用できる。
  • サーバールートを使用してサーバーサイドでのみ実行される API エンドポイントを簡単に作成できる。
  • ISR(Incremental Static Regeneration)はキャッシュヘッダー stale-while-revalidate で制御でき、静的に生成されたコンテンツを CDN から配信しながら、バックグラウンドで定期的に再生成する仕組みを提供する。

参考

記事の理解度チェック

以下の問題に答えて、記事の理解を深めましょう。

TanStack Start でサーバー側でのみ実行される関数を定義する方法はどれですか?

  • import 'server-only';

    もう一度考えてみましょう

  • "use server" ディレクティブ

    もう一度考えてみましょう

  • createServerFn() 関数

    正解!

    createServerFn() はサーバー側でのみ実行される関数を定義します。クライアント側から呼ばれた場合はネットワーク経由でサーバーにリクエストが送信されます。

  • ファイル名を .server.ts にする

    もう一度考えてみましょう

TanStack Start のファイルベースルーティングで、パスパラメータ `/posts/:postId` に対応するファイル名はどれですか?

  • src/routes/posts/$postId.tsx

    正解!

    パスパラメータはファイル名の先頭に $ を付けて表現します。`$postId.tsx` が `/posts/:postId` に対応します。

  • src/routes/posts/[postId].tsx

    もう一度考えてみましょう

  • src/routes/posts/:postId.tsx

    もう一度考えてみましょう

  • src/routes/posts/{postId}.tsx

    もう一度考えてみましょう