mcpField
S
server-functions

server-functions

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

server-functions について

TanStack Start の開発者向けに、`createServerFn` による型安全な RPC を解説するスキルです。入力検証、認証境界、エラー処理、リクエストコンテキスト、キャッシュと整合する更新を扱います。

主な機能

  1. 01GET と POST のサーバー関数作成
  2. 02ローダー、コンポーネント、イベントから呼び出し
  3. 03入力検証とフォームデータ処理
  4. 04エラー、リダイレクト、未検出応答の処理
  5. 05リクエストとレスポンスのコンテキスト取得
  6. 06更新とルートキャッシュの整合

活用シーン

  1. 01データベースアクセスをサーバー関数に集約
  2. 02サーバーへ送るユーザー入力の検証
  3. 03非公開データを扱う関数で認証を強制
  4. 04永続的な更新後にルートデータを再取得

インストール

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

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

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

詳細

サーバー関数

サーバー関数は、createServerFn で作成する型安全な RPC です。サーバー上でのみ実行されますが、ローダー、コンポーネント、フック、イベントハンドラー、その他のサーバー関数など、どこからでも呼び出せます。

CRITICAL:サーバー関数は API エンドポイントです。呼び出し元の UI を表示するルートとは独立してアクセスできます。プライベートデータを扱うサーバー関数では、ハンドラー内(またはミドルウェア経由)で必ず認証を適用してください。ルートの beforeLoad は UX のためのものであり、データへの境界ではありません。セッション/ミドルウェアのパターンについては、start-core/auth-server-primitives を参照してください。 CRITICAL:ローダーはアイソモーフィックであり、クライアントとサーバーの両方で実行されます。データベースクエリ、ファイルシステムへのアクセス、シークレット API キーは、ローダーに直接記述せず、必ず createServerFn 内に記述してください。 CRITICAL:"use server" ディレクティブ、getServerSideProps、その他の Next.js/Remix のサーバーパターンは使用しないでください。TanStack Start では createServerFn のみを使用します。

基本的な使い方

import { createServerFn } from '@tanstack/react-start'

// GET (default)
const getData = createServerFn().handler(async () => {
  return { message: 'Hello from server!' }
})

// POST
const saveData = createServerFn({ method: 'POST' }).handler(async () => {
  return { success: true }
})

ローダーからの呼び出し

import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'

const getPosts = createServerFn({ method: 'GET' }).handler(async () => {
  const posts = await db.query('SELECT * FROM posts')
  return { posts }
})

export const Route = createFileRoute('/posts')({
  loader: () => getPosts(),
  component: PostList,
})

function PostList() {
  const { posts } = Route.useLoaderData()
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  )
}

コンポーネントからの呼び出し

イベントハンドラーからサーバー関数を呼び出すには、useServerFn フックを使用します。

import { useServerFn } from '@tanstack/react-start'

const deletePost = createServerFn({ method: 'POST' })
  .validator((data: { id: string }) => data)
  .handler(async ({ data }) => {
    await db.delete('posts').where({ id: data.id })
    return { success: true }
  })

function DeleteButton({ postId }: { postId: string }) {
  const deletePostFn = useServerFn(deletePost)

  return (
    <button onClick={() => deletePostFn({ data: { id: postId } })}>
      Delete
    </button>
  )
}

キャッシュ整合性を保つミューテーション

読み取りと書き込みは、同じ信頼できるストアを使用するサーバー関数を介して行います。書き込みを待機してから、ルートキャッシュを無効化し、ローダーが永続化された結果を読み取るようにします。

const listIssues = createServerFn({ method: 'GET' }).handler(() => {
  return db.issues.findMany()
})

const createIssue = createServerFn({ method: 'POST' })
  .validator((data: unknown) => {
    if (
      typeof data !== 'object' ||
      data === null ||
      !('title' in data) ||
      typeof data.title !== 'string' ||
      data.title.trim().length === 0
    ) {
      throw new Error('Title is required')
    }
    return { title: data.title.trim() }
  })
  .handler(async ({ data }) => {
    return db.issues.create({ data })
  })

export const Route = createFileRoute('/issues')({
  loader: () => listIssues(),
  component: IssuesPage,
})

function IssuesPage() {
  const router = useRouter()
  const createIssueFn = useServerFn(createIssue)

  const handleCreate = async (title: string) => {
    await createIssueFn({ data: { title } })
    await router.invalidate({ sync: true })
  }

  // render Route.useLoaderData() and call handleCreate from the form
}

永続的なミューテーションの後に、ローカルのコンポーネント状態だけを更新しないでください。作成、更新、削除の後と、ページを再読み込みした後に、表示されるリストを検証してください。

入力の検証

基本的なバリデーター
const greetUser = createServerFn({ method: 'GET' })
  .validator((data: { name: string }) => data)
  .handler(async ({ data }) => {
    return `Hello, ${data.name}!`
  })

await greetUser({ data: { name: 'John' } })
Zod バリデーター
import { z } from 'zod'

const createUser = createServerFn({ method: 'POST' })
  .validator(
    z.object({
      name: z.string().min(1),
      age: z.number().min(0),
    }),
  )
  .handler(async ({ data }) => {
    return `Created user: ${data.name}, age ${data.age}`
  })

入力の検証では、出力のシリアライズは検証されません。レスポンススキーマを変更した場合は、データベースの選択項目、サービスの戻り値、サーバー関数の結果、ローダーの呼び出し元、UI を更新してください。ハンドラーまたは HTTP 境界を呼び出し、返されたペイロードに新しいフィールドがあることを検証するランタイムテストを追加してください。

FormData
const submitForm = createServerFn({ method: 'POST' })
  .validator((data) => {
    if (!(data instanceof FormData)) {
      throw new Error('Expected FormData')
    }
    return {
      name: data.get('name')?.toString() || '',
      email: data.get('email')?.toString() || '',
    }
  })
  .handler(async ({ data }) => {
    return { success: true }
  })

エラー処理

エラー
const riskyFunction = createServerFn().handler(async () => {
  throw new Error('Something went wrong!')
})

try {
  await riskyFunction()
} catch (error) {
  console.log(error.message) // "Something went wrong!"
}
リダイレクト
import { redirect } from '@tanstack/react-router'

const requireAuth = createServerFn().handler(async () => {
  const user = await getCurrentUser()
  if (!user) {
    throw redirect({ to: '/login' })
  }
  return user
})
Not Found
import { notFound } from '@tanstack/react-router'

const getPost = createServerFn()
  .validator((data: { id: string }) => data)
  .handler(async ({ data }) => {
    const post = await db.findPost(data.id)
    if (!post) {
      throw notFound()
    }
    return post
  })

サーバーコンテキストユーティリティ

サーバー関数のハンドラー内でリクエスト/レスポンスの詳細にアクセスします。

import { createServerFn } from '@tanstack/react-start'
import {
  getRequest,
  getRequestHeader,
  setResponseHeaders,
  setResponseStatus,
} from '@tanstack/react-start/server'

// Public, non-personalized data — safe to cache shared across users.
const getPublicData = createServerFn({ method: 'GET' }).handler(async () => {
  setResponseHeaders({
    // 'public' is correct ONLY when the response does not depend on identity.
    // For anything tied to a session/user/tenant, use 'private' or 'no-store'.
    'Cache-Control': 'public, max-age=300',
  })
  setResponseStatus(200)
  return fetchPublicData()
})

// Authenticated data — must NOT be 'public'.
const getMyData = createServerFn({ method: 'GET' }).handler(async () => {
  const authHeader = getRequestHeader('Authorization')
  // ... auth check ...

  setResponseHeaders({
    // 'private' = only the user-agent may cache. Vary by Cookie/Authorization
    // so any intermediary that does cache keys by identity, not URL alone.
    'Cache-Control': 'private, max-age=60',
    Vary: 'Cookie, Authorization',
  })
  return fetchPersonalizedData()
})

利用可能なユーティリティ:

  • getRequest() — Request オブジェクト全体
  • getRequestHeader(name) — 単一のリクエストヘッダー
  • setResponseHeader(name, value) — 単一のレスポンスヘッダー
  • setResponseHeaders(headers) — 複数のレスポンスヘッダー
  • setResponseStatus(code) — HTTP ステータスコード

ファイル構成

src/utils/
├── users.functions.ts   # createServerFn wrappers (safe to import anywhere)
├── users.server.ts      # Server-only helpers (DB queries, internal logic)
└── schemas.ts           # Shared validation schemas (client-safe)
// users.server.ts — server-only helpers
import { db } from '~/db'

export async function findUserById(id: string) {
  return db.query.users.findFirst({ where: eq(users.id, id) })
}
// users.functions.ts — server functions
import { createServerFn } from '@tanstack/react-start'
import { findUserById } from './users.server'

export const getUser = createServerFn({ method: 'GET' })
  .validator((data: { id: string }) => data)
  .handler(async ({ data }) => {
    return findUserById(data.id)
  })

サーバー関数の静的インポートは安全です。ビルド時に、クライアントバンドル内の実装は RPC スタブに置き換えられます。

よくある間違い

1. CRITICAL:ルートガードに頼ってサーバー関数を保護する

beforeLoad によるリダイレクトで保護されるのはルートの UIであり、データエンドポイントではありません。createServerFn は、攻撃者が直接呼び出せるエンドポイントを公開します。ルートを読み込む必要すらありません。エンドポイントでの認証がセキュリティ境界であり、ルートでの認証は UX のためのものです。

// WRONG — the route guard doesn't reach the handler
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
  return db.orders.findMany() // ← anyone can call the RPC
})
export const Route = createFileRoute('/_authenticated/orders')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
  },
  loader: () => getMyOrders(),
})

// CORRECT — auth enforced on the handler itself
const getMyOrders = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    return db.orders.findMany({ where: { userId: context.session.userId } })
  })

認証が必要なすべての createServerFn に authMiddleware(またはハンドラー内の同等のチェック)を適用してください。セッション/ミドルウェアのパターン全体については、start-core/auth-server-primitives を、ファクトリーの合成については start-core/middleware を参照してください。

2. CRITICAL:サーバー専用のコードをローダーに記述する
// WRONG — loader is ISOMORPHIC, runs on BOTH client and server
export const Route = createFileRoute('/posts')({
  loader: async () => {
    const posts = await db.query('SELECT * FROM posts')
    return { posts }
  },
})

// CORRECT — use createServerFn for server-only logic
const getPosts = createServerFn({ method: 'GET' }).handler(async () => {
  const posts = await db.query('SELECT * FROM posts')
  return { posts }
})

export const Route = createFileRoute('/posts')({
  loader: () => getPosts(),
})
3. CRITICAL:Next.js/Remix/React Router DOM のパターンを使用する

ファイルが src/pages/、app/layout.tsx、_app/ にある場合、または react-router-dom や next/ から何かをインポートしている場合、それは誤ったフレームワーク向けのコードです。TanStack Start では src/routes/、createFileRoute、createServerFn を使用します。

// WRONG — "use server" is a React directive, not used in TanStack Start
'use server'
export async function getUser() { ... }

// WRONG — getServerSideProps is Next.js Pages Router
export async function getServerSideProps() { ... }

// WRONG — Next.js App Router server component data fetching
export default async function Page() {
  const data = await fetch(...).then(r => r.json())
  return <div>{data}</div>
}

// WRONG — Remix
export async function loader({ request }) { ... }
export async function action({ request }) { ... }

// WRONG — react-router-dom (a different library)
import { Link, useNavigate } from 'react-router-dom'

// CORRECT — TanStack Start
import { createServerFn } from '@tanstack/react-start'
import { Link, useNavigate, createFileRoute } from '@tanstack/react-router'

const getUser = createServerFn({ method: 'GET' })
  .handler(async () => { ... })

export const Route = createFileRoute('/users/$id')({
  loader: ({ params }) => getUser({ data: { id: params.id } }),
  component: UserPage,
})

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

4. HIGH:サーバー関数に動的インポートを使用する
// WRONG — can cause bundler issues
const { getUser } = await import('~/utils/users.functions')

// CORRECT — static imports are safe, build handles environment shaking
import { getUser } from '~/utils/users.functions'
5. HIGH:サーバー関数を呼び出さずに待機している

createServerFn は関数を返します。() を付けて呼び出す必要があります:

// WRONG — getItems is a function, not a Promise
const data = await getItems

// CORRECT — call the function
const data = await getItems()

// With validated input
const data = await getItems({ data: { id: '1' } })
6. CRITICAL:認証済みのレスポンスを Cache-Control: public でキャッシュする

Cache-Control: public, max-age=N は、ユーザーとの間にあるすべての CDN、プロキシ、共有キャッシュに対し、このレスポンスを誰にでも返せることを伝えます。レスポンスがセッション(ユーザー、テナント、ロール)に依存する場合、最初のユーザーへのレスポンスがキャッシュされ、次のユーザーに再送されるため、テナント間のデータ漏洩が発生します。

// WRONG — auth'd response, public cache, leaks to next user via CDN
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
  const session = await requireSession() // identity-dependent
  setResponseHeaders({ 'Cache-Control': 'public, max-age=300' })
  return db.orders.findMany({ where: { userId: session.userId } })
})

// CORRECT — private + Vary so any cache that does store it keys by identity
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
  const session = await requireSession()
  setResponseHeaders({
    'Cache-Control': 'private, max-age=60',
    Vary: 'Cookie, Authorization',
  })
  return db.orders.findMany({ where: { userId: session.userId } })
})

// ALSO CORRECT — opt out entirely for sensitive data
setResponseHeaders({ 'Cache-Control': 'no-store' })

経験則:ハンドラーがセッション/Cookie/認証ヘッダーを読み取るか、IDに応じて処理を分岐する場合、そのレスポンスは public ではありません。デフォルトは private(機密データの場合は no-store)とし、誰がリクエストしてもバイト単位で同一のレスポンスに限り public を使用してください。フルページの ISR/Cache-Control については、start-core/deployment も参照してください。

7. MEDIUM:useServerFn でラップするタイミング

サーバー関数が throw redirect() または throw notFound() を使用する場合、useServerFn は必須です。このフックによって throw がルーターに渡され、リダイレクトが実際に遷移を行うようになります。データを返すだけのサーバー関数(直接呼び出すか、useMutation/useQuery を介して呼び出す場合)では、このフックは任意です。

// Plain data — direct call is fine (also fine to pass to useMutation/useQuery)
<button onClick={() => deletePost({ data: { id } })}>Delete</button>
useMutation({ mutationFn: deletePost })

// Throws redirect/notFound — MUST wrap with useServerFn so the router handles the throw
const signupFn = useServerFn(signup) // signup throws redirect on success
<button onClick={() => signupFn({ data: form })}>Sign up</button>

迷った場合は、useServerFn でラップしてください。単純なデータを返す関数では何もせず、後からリダイレクトが追加される可能性がある関数にも安全なデフォルトです。

8. CRITICAL:ローダーから相対 API URL を自分自身にフェッチする
// WRONG — this loader also runs during SSR, where a relative URL may fail
export const Route = createFileRoute('/issues')({
  loader: () => fetch('/api/issues').then((response) => response.json()),
})

// CORRECT — call the server function; the client build gets an RPC stub
export const Route = createFileRoute('/issues')({
  loader: () => listIssues(),
})

相対 fetch は、ブラウザーのみで実行されるイベントハンドラーでは問題ありません。しかし、あらゆる環境で使えるローダーデータの取得方法ではありません。

関連項目

  • start-core/execution-model — コードがどこで実行されるかを理解する
  • start-core/middleware — サーバー関数をミドルウェアと組み合わせる
  • start-core/auth-server-primitives — セッション、Cookie、OAuth、CSRF、レート制限(認証のサーバー側を扱います。getCurrentUser と外部セッションヘルパーはモジュールスコープではなく、ここに置きます)
  • @tanstack/router-core#router-core/auth-and-guards — ルーティング側の UX ガード。データの認証処理は、サーバー関数、サーバールート、または API エンドポイントのハンドラー/ミドルウェアで行います

コメント