Gemcook Tech Blog
🏖️

BetaになったTanStack Startを触るぞ!!!

2025/01/14に公開

はじめに

こんにちは!Reactのフルスタックフレームワークといえば、Next.jsが広く知られていますが、新たにOSSのTanStackが「TanStack Start」というフレームワークをリリースしました(現在はbeta版が公開されています!)
この記事では、TanStack Startのセットアップ手順や主要な機能について解説していきます。

https://tanstack.com/start/latest

TanStack Startとは

Vinxiを基盤に構築された、フルスタックなReactフレームワークで、主な機能にはサーバーサイドレンダリング(SSR)ストリーミング、サーバー機能などが含まれます。そして主にそれらはTanStack Routerが90%以上を占め、残りの5%はその他の機能で構成されています。(Youtube参照)

詳しくは以下からご確認ください。

https://vinxi.vercel.app/

セットアップ

それでは、ローカル環境にTanStack Startをインストールして、実際に動かしてみましょう!以下の手順に従ってセットアップを行います。

インストール

新規のフォルダを作成します。

terminal
mkdir myApp
cd myApp
bun init

tsconfig.jsonを作成してください。

tsconfig.json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "moduleResolution": "Bundler",
    "module": "ESNext",
    "target": "ES2022",
    "skipLibCheck": true,
    "strictNullChecks": true,
  },
}

TanStack Start はVinxiとTanStack Routerが必要なのでinstallします。

terminal
bun add @tanstack/start @tanstack/react-router vinxi

ReactとViteのReact pluginをインストールします。

terminal
bun add react react-dom
bun add -d @vitejs/plugin-react

必要に応じてTypescriptを追加でダウンロードしていきます。

terminal
bun add -d typescript @types/react @types/react-dom

package.jsonをアップデート + app.configを作成

Vinxiを入れたのでpackage.jsonをアップデートします。

package.json
{
  // ...
  "type": "module",
  "scripts": {
    "dev": "vinxi dev",
    "build": "vinxi build",
    "start": "vinxi start"
  }
}

app.config.tsを作成してください。

app.config.ts
import { defineConfig } from '@tanstack/start/config'

export default defineConfig({})

フォルダ構成

以下が作成した後の構成になります。

├── app/
│   ├── routes/
│   │   └── __root.tsx
│   ├── client.tsx
│   ├── router.tsx
│   ├── routeTree.gen.ts
│   └── ssr.tsx
├── .gitignore
├── app.config.ts
├── package.json
└── tsconfig.json

基本のテンプレートを追加

TanStack Startをスタートさせるには以下の4つのファイルが必要です。

  • router.tsx
  • ssr.tsx
  • client.tsx
  • __root.tsx

Router Configを作成

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

export function createRouter() {
  const router = createTanStackRouter({
    routeTree,
  })

  return router
}

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

Server Entry Pointを作成

app/ssr.tsx
/// <reference types="vinxi/types/server" />
import {
  createStartHandler,
  defaultStreamHandler,
} from '@tanstack/start/server'
import { getRouterManifest } from '@tanstack/start/router-manifest'

import { createRouter } from './router'

export default createStartHandler({
  createRouter,
  getRouterManifest,
})(defaultStreamHandler)

Client Entry Pointを作成

app/client.tsx
/// <reference types="vinxi/types/client" />
import { hydrateRoot } from 'react-dom/client'
import { StartClient } from '@tanstack/start'
import { createRouter } from './router'

const router = createRouter()

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

rootを作成

app配下にroutesフォルダを作成してその中に__root.tsxを作成してください。

app/routes/__root.tsx
import {
  Outlet,
  ScrollRestoration,
  createRootRoute,
} from '@tanstack/react-router'
import { Meta, Scripts } from '@tanstack/start'
import type { ReactNode } from 'react'

export const Route = createRootRoute({
  head: () => ({
    meta: [
      {
        charSet: 'utf-8',
      },
      {
        name: 'viewport',
        content: 'width=device-width, initial-scale=1',
      },
      {
        title: 'TanStack Start Starter',
      },
    ],
  }),
  component: RootComponent,
})

function RootComponent() {
  return (
    <RootDocument>
      <Outlet />
    </RootDocument>
  )
}

function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
  return (
    <html>
      <head>
        <Meta />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  )
}

ローカルで起動させる

index.tsxをroutes配下に作成してみてください!(中は空っぽでも大丈夫です!)

├── app/
│   ├── routes/
│   │   ├── __root.tsx
│   │   └── index.tsx  ← ここ
│   ├── client.tsx
│   ├── router.tsx
│   ├── routeTree.gen.ts
│   └── ssr.tsx
├── .gitignore
├── app.config.ts
├── package.json
└── tsconfig.json

では、準備ができたのでサーバーを起動させていきましょう。

terminal
bun run dev

そうです。index.tsxはファイル作成しただけで何も記載していないのに

Hello "/"と表示されていますね!以下のルーティングについてでも説明していますが、サーバーを起動した際に自動でテンプレートをセットしてくれます。

TanStack Startの特徴

ここから数点TanStack Startの良いなと思った点を見ていきます。

ルーティングについて(File-based Route Generation)

TanStack StartではFile-based Route Generationになります。昨今のWebフレームワークではFile-basedでのルーティングが多いと思います。TanStack Startも同じです。

一度、posts.tsxを作成していきましょう。

├── app/
│   ├── routes/
│   │   ├── __root.tsx
│   │   └── posts.tsx ← ここ
│   ├── client.tsx
│   ├── router.tsx
│   ├── routeTree.gen.ts
│   └── ssr.tsx

posts.tsxをapp/routes/posts.tsxに作成します。

すると、以下のようにTanStack側が自動で生成してくれます。これがFile-based Route Generationとなります。

ルーティングについて

ルーティングについてはTanStack Routerの部分でもあるので
こちらで詳細を確認して頂ければと思います。

posts.tsx
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/posts")({
    component: () => <div>Hello "/posts"!</div>,
});

Server Functions

続いてはTanStack Startの中でも大きな特徴であるServer Functionsについてです。

Server Functionsとは何か

Server Functionsは、サーバー上でのみ実行されるどこからでも呼び出すことが可能な関数のことです。

Server Functionsの特徴としては

  • HTTPでアクセスすることはできない
  • ローダー、フック、コンポーネントなどどこからでも呼び出すことができます。

また、Next.jsではサーバー側で実行したい関数をuse serverと明記する必要がありますが、TanStack Startではその必要はありません。

Server Functionを作成する

Server Functionをapp配下に今回はserverFunctions.tsを作成します。ファイル名は特に指定はありません。

今回はJSONPlaceholderから「記事」をfetchしてくるサーバー関数を作成します。

app/serverFunctions.ts
import { createServerFn } from '@tanstack/start';
import axios from "axios";

export const fetchPost = createServerFn({ method: 'GET' })
  .validator((d: PostId) => d)
  .handler(async ({ data }) => {
    const post = await axios
      .get(
        `https://jsonplaceholder.typicode.com/posts/${data.postId}`
      )
      .then((r) => r.data)
      .catch((err) => {
        console.error(err);
        if (err.status === 404) {
          throw notFound();
        }
        throw err;
      });

    return post;
  });

基本的にシンタックスとしてはシンプルだと思います。createServerFn()を呼び出して返り値をhandler()に記述していきます。
createServerFn()の引数にはmethodを入れることが可能でGETPOSTを使うことができます。

https://tanstack.com/router/latest/docs/framework/react/start/server-functions

バリデーション

Server Functionではクライアントから受け取った値に対してバリデーションをすることができます。

クライアントとサーバー間でのやり取りをする上で型を保証することはとても大切なことで、特にユーザーからの入力などを扱う場合はとても重要です。

バリデーションは自分で実装することも可能ですが今回はバリデーションライブラリのzodを使用していきます。

zod install
terminal
bun add zod

app/serverFunctions.ts
import { notFound } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/start';
import axios from 'axios';

type PostId = {
  postId: string;
};

type Post = {
  id: number;
  title: string;
  body: string;
};

export const fetchPost = createServerFn({ method: 'GET' })
  .validator((d: PostId) => d)
  .handler(async ({ data }) => {
    const post = await axios
      .get<Post>(`https://jsonplaceholder.typicode.com/posts/${data.postId}`)
      .then((r) => r.data)
      .catch((err) => {
        console.error(err);
        if (err.status === 404) {
          throw notFound();
        }
        throw err;
      });

    return post;
  });

Middleware

Server Functionに対してMiddlewareを使用することも可能です。MiddlewareはcreateMiddleware()を呼び出して作成していきます。

const loggingMiddleware = createMiddleware().server(async ({ next, data }) => {
  console.log('Request received:', data)
  const result = await next()
  console.log('Response processed:', result)
  return result
})

またMiddlewareはクライアント側で実行することも可能で、client contextをserver側に送信することも可能です。

const requestLogger = createMiddleware()
  .client(async ({ next, context }) => {
    return next({
      sendContext: {
        // workspaceIdをserver側に送信
        workspaceId: context.workspaceId,
      },
    })
  })
  .server(async ({ next, data, context }) => {
    // contextでclient側からの受け取りが可能に
    console.log('Workspace ID:', context.workspaceId)
    return next()
  })

https://tanstack.com/router/latest/docs/framework/react/start/middleware

実際にServer Functionを呼んでみる

では上記で作成したfetchPostを呼べるようなページを作成してきましょう。postIdをquery paramsで受け取り、ページ遷移時に取得します。

まずはpostsを受け取るページを作成します。

├── app/
│   ├── routes/
│   │   ├── __root.tsx
│   │   ├── index.tsx
│   │   ├── posts.tsx ← ここ
│   │   └── posts_.$postId.tsx 

今回はpostsを取得するServer Functionは同一ファイルで作成して呼びます。この際、Server Functionはファイルのトップレベルで呼ぶ必要があるので一番上に定義します。

routes/posts.tsx

import { createFileRoute, Link } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/start';
import axios from 'axios';

const fetchPosts = createServerFn({ method: 'GET' }).handler(async () => {
  console.info('Fetching posts...');
  return axios
    .get('https://jsonplaceholder.typicode.com/posts')
    .then((r) => r.data.slice(0, 10));
});

export const Route = createFileRoute('/posts')({
  loader: async () => fetchPosts(),
  component: RouteComponent,
});

function RouteComponent() {
  const posts = Route.useLoaderData();
  console.log(posts);

  return (
    <>
      <div>Hello "/posts"!</div>
      {posts.map((post) => (
        <div key={post.id}>
          <h2>
            <Link
              to="/posts/$postId"
              params={{
                postId: post.id,
              }}
            >
              {post.title}
            </Link>
          </h2>
        </div>
      ))}
    </>
  );
}

タイトルを表示できたところで各postsの詳細を取得するために、postIdをquery paramsで受け取り、postの中身を表示するページを作成していきます。

├── app/
│   ├── routes/
│   │   ├── __root.tsx
│   │   ├── index.tsx
│   │   ├── posts.tsx 
│   │   └── posts_.$postId.tsx ← ここ
routes/posts_.$postId.tsx
import { createFileRoute } from '@tanstack/react-router';
import { fetchPost } from '../serverFunctions';

export const Route = createFileRoute('/posts_/$postId')({
  component: PostComponent,
  loader: ({ params: { postId } }) => fetchPost({ data: { postId } }),
});

function PostComponent() {
  const post = Route.useLoaderData();

  return (
    <div>
      <h4>{post.title}</h4>
      <div>{post.body}</div>
    </div>
  );
}

loaderで受け取った値はRoute.useLoaderDataで取得することが可能です。

クライアント側でServer Functionを呼ぶ方法

上記ではloaderでServer Functionを呼び出しましたがuseServerFn()を呼び出し引数としてServer Functionを入れてあげることでクライアント側で呼び出すことが可能です。以下の様にuseQueryとの併用をすることもできます。

export function Time() {
  const getTime = useServerFn(getServerTime)

  const timeQuery = useQuery({
    queryKey: 'time',
    queryFn: () => getTime(),
  })
}

API Routes

TanStack Startでも別のサーバを必要とせずにアプリケーション内にサーバサイドエンドポイントを作成できます。

TanStack Startではapiハンドラーを作成する必要があるのでapp配下に作成していきます。

app/api.ts
import {
  createStartAPIHandler,
  defaultAPIFileRouteHandler,
} from '@tanstack/start/api'

export default createStartAPIHandler(defaultAPIFileRouteHandler)

apiはapp/routes配下に作成します。呼び出しはcreateAPIFileRoute()を記述することです。以下のコードはサーバーが起動している場合はファイルを作成した時点でセットアップしてくれます。

app/routes/api/hello.ts
import { createAPIFileRoute } from '@tanstack/start/api'

export const APIRoute = createAPIFileRoute('/hello')({
  GET: async ({ request }) => {
    return new Response('Hello, World! from ' + request.url)
  },
})

https://tanstack.com/router/latest/docs/framework/react/start/api-routes/#defining-an-api-route

Tanstack Startのこれから

現状はbetaですが、これからRSC(React Server Components)の対応も今後していくと11月のReact Summit USでも製作者のTannarさんがコードをスライドに載せていましたね!

const $renderPosts = createServerFn().handler(async () => {
  const posts = await fetchPosts();
  return (
    <div>
      {posts.map((post) => (
        <Post post={post} />
      ))}
    </div>
  );
});

終わり

TanStack Start は、執筆時点(2024年1月)ではベータ版ですが、Next.js や Remix をはじめとする多様な React フレームワークの中で、TanStack Start がどのように変化していくのか注目したいところです!

TanStack 製ライブラリとの高い互換性は、開発者にとって魅力的な特徴の一つです。今後のアップデートと進化に期待したいです!!

参考

https://youtu.be/AuHqwQsf64o?si=n-u72pEbPSZYO5lL

Gemcook Tech Blog
Gemcook Tech Blog

Discussion