# Spiceflow

> Type safe API and React Server Components framework

This file contains the full content of all documentation pages. For a compact index, see [llms.txt](https://getspiceflow.com/llms.txt). To download all pages as a zip, use [docs.zip](https://getspiceflow.com/docs.zip).

---
title: Type-safe APIs and React Server Components
url: "https://getspiceflow.com/index.md"
description: "Spiceflow is a type-safe API and React RSC framework. Build APIs and full-stack apps for Node.js, Bun, and Cloudflare Workers."
---

import { HeroSection } from './components/hero-section.tsx'
import Readme from '../../README.md'

<Above>
  <HeroSection />
</Above>

<div align="center" className="hidden w-full">
  <br />

  <br />

  <br />

  <h1 id="spiceflow">spiceflow</h1>
  <p>type safe API and React Server Components framework for Node, Bun, and Cloudflare</p>

  <br />

  <br />
</div>

Spiceflow is a type-safe API framework and full-stack React RSC framework focused on absolute simplicity. It works across all JavaScript runtimes: Node.js, Bun, and Cloudflare Workers. Read the source code on [GitHub](https://github.com/remorses/spiceflow). Full documentation lives at [getspiceflow.com](https://getspiceflow.com).

## Features

* Full-stack React framework with React Server Components (RSC), server actions, layouts, and automatic client code splitting
* Works everywhere: Node.js, Bun, and Cloudflare Workers with the same code
* Type safe schema based validation via Zod
* Type safe fetch client with full inference on path params, query, body, and response
* Simple and intuitive API using web standard Request and Response
* Can easily generate OpenAPI spec based on your routes
* Support for [Model Context Protocol](https://modelcontextprotocol.io/) to easily wire your app with LLMs
* Supports async generators for streaming via server sent events
* Modular design with `.use()` for mounting sub-apps
* Built-in [OpenTelemetry](https://opentelemetry.io/) tracing with zero overhead when disabled

## Installation

```bash
npm install spiceflow@rsc
```

<Info title="Important">
  Spiceflow is still in pre-release. Install with `spiceflow@rsc`, not `spiceflow@latest`.
</Info>

## AI Agents

To let your AI coding agent know how to use spiceflow, run:

```bash
npx -y skills add remorses/spiceflow -g -y
```

Every documentation page is also available as raw markdown: append `.md` to any [getspiceflow.com](https://getspiceflow.com) URL, for example `https://getspiceflow.com/react-data.md`.

## Basic Usage

API routes return JSON automatically. React pages use `.page()` and `.layout()` for server-rendered UI with client interactivity:

```tsx
import { Spiceflow } from 'spiceflow'
import { Counter } from './counter'

export const app = new Spiceflow()
  .get('/api/hello', () => {
    return { message: 'Hello, World!' }
  })
  .layout('/*', async ({ children }) => {
    return (
      <html>
        <body>{children}</body>
      </html>
    )
  })
  .page('/', async () => {
    return (
      <div>
        <h1>Home</h1>
        <Counter />
      </div>
    )
  })
  .page('/about', async () => {
    return <h1>About</h1>
  })

app.listen(3000)
```

<Expandable title={<Markdown inline children="When to use .route() vs .get()/.post()" />}>
  Use `.route()` instead of `.get()`/`.post()` when you want to pass Zod schemas for validation — it accepts `request`, `response`, `query`, and `params` schemas.
</Expandable>

## Two Ways to Use Spiceflow

Spiceflow works as a **standalone API framework** or as a **full-stack React framework** — same router, same type safety, same code.

**API only** — no Vite, no React. Just install `spiceflow` and build type-safe APIs with Zod validation, streaming, OpenAPI, and a type-safe fetch client:

```ts
import { Spiceflow } from 'spiceflow'

const app = new Spiceflow()
  .get('/hello', () => ({ message: 'Hello!' }))

app.listen(3000)
```

**Full-stack React (RSC)** — add the Vite plugin to get server components, client components, layouts, server actions, and automatic code splitting. All API features still work alongside React pages:

```ts
// vite.config.ts
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import spiceflow from 'spiceflow/vite'

export default defineConfig({
  plugins: [react(), spiceflow({ entry: './src/main.tsx' })],
})
```

## How a Spiceflow App Works

Everything is one chained expression on a single `Spiceflow` instance: API routes, middleware, loaders, layouts, and pages. The chain is the source of truth for both runtime routing and TypeScript inference — the typed fetch client, `router.href()`, and `useLoaderData()` all read their types from `typeof app`.

```diagram
                       new Spiceflow()  ──  one chain, one type
                              │
        ┌─────────────────────┼──────────────────────┐
        v                     v                      v
   .get / .post          .use(middleware)      .loader / .layout / .page
   .route (Zod)          serveStatic, cors     React Server Components
   JSON + SSE APIs       mounted sub-apps      server actions, client chunks
        │                     │                      │
        └─────────────────────┼──────────────────────┘
                              v
              app.listen(3000)  /  app.handle(request)
           Node.js  <──────>  Bun  <──────>  Cloudflare Workers
```

For the React side, Vite builds three environments from the same entry: **rsc** (server components and actions), **ssr** (HTML rendering), and **client** (hydration and navigation). Every `"use client"` file automatically becomes its own browser chunk. Server actions marked `"use server"` become POST endpoints that re-render the page with fresh data after they run.

A realistic app entry looks like this:

```tsx
// src/main.tsx
import { Spiceflow, parseFormData } from 'spiceflow'
import { Head, Link, router } from 'spiceflow/react'
import { z } from 'zod'
import { Counter } from './app/counter'

export const app = new Spiceflow()
  .get('/api/hello', () => ({ message: 'Hello!' }))
  .loader('/dashboard/*', async ({ request }) => {
    const user = await getUser(request)
    return { user }
  })
  .layout('/*', async ({ children }) => {
    return (
      <html>
        <Head>
          <Head.Meta charSet="UTF-8" />
        </Head>
        <body>{children}</body>
      </html>
    )
  })
  .page('/dashboard', async ({ loaderData }) => {
    return (
      <div>
        <h1>Welcome {loaderData.user.name}</h1>
        <Counter />
        <Link href={router.href('/')}>Home</Link>
      </div>
    )
  })
  .listen(3000)

// Register the app type for type-safe routing everywhere
declare module 'spiceflow/react' {
  interface SpiceflowRegister { app: typeof app }
}
```

### Static pages run middleware

`.staticPage()` renders a page **once at build time** into `.html` and `.rsc` files. On Node.js and Cloudflare Workers, middleware registered with `.use()` still runs before every static page request, so redirects, auth checks, and response headers work on static pages too:

```tsx
export const app = new Spiceflow()
  .use(({ request, redirect }) => {
    if (new URL(request.url).pathname === '/about-us') throw redirect('/about')
  })
  .staticPage('/about', async () => <About />)
```

On **Cloudflare Workers** the prerendered files are stored where only the Worker can read them, so Cloudflare never serves them before your middleware. Add an `ASSETS` binding in `wrangler.jsonc`; the Worker runs your `.use()` blocks, then returns the stored file without rendering again. See [Cloudflare prerendered pages](/cloudflare#prerendered-pages-staticpage).

Middleware also runs while `vite build` prerenders the page. That request has no cookies or user headers, so a middleware that redirects it makes the build fail.

## Documentation

The README is only an overview. The full documentation is split into feature docs — **always read the doc for the feature you are working on before writing code**. The API is small but opinionated, and the opinions are not guessable from other frameworks.

### API framework

| When you work on                                                                                                                                                                           | Read                                          |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- |
| Routes, Zod validation, typed errors, middleware, static files, CORS, streaming/SSE, `.onError()`, `noStackTraces`, `listen()`, `waitUntil`, Node.js adapters, Next.js mounting, base path | [API Framework](/api)                         |
| Calling the API with the typed fetch client, WebMCP browser tools                                                                                                                          | [Fetch Client](/fetch-client)                 |
| Generating OpenAPI documents, response maps, hiding routes                                                                                                                                 | [OpenAPI](/openapi)                           |
| Exposing routes as LLM tools over Model Context Protocol                                                                                                                                   | [MCP](/mcp)                                   |
| OpenTelemetry spans, Server-Timing, custom tracers                                                                                                                                         | [Tracing](/tracing) and [Strada](/strada)     |
| Sending non-JSON types (`Date`, `Map`, `Set`, `BigInt`) over the wire                                                                                                                      | [Custom Serialization](/custom-serialization) |

### React framework (RSC)

| When you work on                                                                                                                                           | Read                              |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| Vite setup, Tailwind, shadcn/ui, app entry, layouts, `<Head>` SEO, query params, client components, code splitting, `router`, `Link`, redirects, 404 pages | [React Framework](/react)         |
| Loaders, `useLoaderData`, streaming with `use()`, forms, server actions, `parseFormData`, `useActionState`, `ErrorBoundary`                                | [Data & Actions](/react-data)     |
| The `SpiceflowRegister` type registry, `knownPaths`, multi-app workspaces                                                                                  | [Type-Safe Routing](/type-safety) |
| Rendering remote components from another server                                                                                                            | [Federation](/federation)         |

### Guides

| When you work on                                                                 | Read                                        |
| -------------------------------------------------------------------------------- | ------------------------------------------- |
| Writing vitest tests for routes, pages, and server actions                       | [Testing](/testing)                         |
| Auth middleware, proxying, non-blocking auth, cookie patterns, graceful shutdown | [Middleware Patterns](/middleware-patterns) |
| Authenticating server actions and routes — they are public endpoints             | [Security](/security)                       |
| Porting an app from Remix or React Router                                        | [Migrate from Remix](/migrate-from-remix)   |
| How Spiceflow compares to Next.js, Hono, and Elysia                              | [Comparisons](/comparisons)                 |
| Installing and structuring shadcn/ui components                                  | [shadcn/ui](/shadcn)                        |
| A dependency crashing with `useState is undefined` at startup                    | [use client trap](/docs/use-client-trap)    |

### Deployment

| When you deploy to                                                       | Read                                  |
| ------------------------------------------------------------------------ | ------------------------------------- |
| Cloudflare Workers: setup, bindings, `waitUntil`, KV caching, edge cache | [Cloudflare](/cloudflare)             |
| Any host, to understand what happens across deploys                      | [Deployment Skew](/deployment-skew)   |
| Cloudflare service bindings between Workers                              | [Service Bindings](/service-bindings) |
| Docker or any container platform                                         | [Docker](/docker)                     |

## Testing

Test your spiceflow app directly with vitest. No browser, no build step, sub-second feedback. Call the app through the typed fetch client, call server actions as plain functions, and assert on responses:

```ts
import { createSpiceflowFetch } from 'spiceflow/client'
import { SpiceflowTestResponse } from 'spiceflow/testing'
import { app } from './main.js'

const f = createSpiceflowFetch(app)

// API routes return typed JSON
const data = await f('/api/hello')
expect(data).toEqual({ message: 'Hello, World!' })

// Page routes return SpiceflowTestResponse with rendered HTML
const res = await f('/about')
if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')
expect(await res.text()).toContain('About')
```

The spiceflow Vite plugin auto-detects vitest and configures everything. See the [Testing guide](/testing) for authentication patterns, stateful workflows, and dependency injection.

## Comparisons

Spiceflow is a **full React RSC framework**, not only an API layer. Same app can serve JSON, pages, layouts, server actions, and OpenAPI.

Hono and Elysia are HTTP frameworks. Next.js is a React platform with file-based App Router caching. Spiceflow is both a typed API and a React app in one chain. Every request re-runs matching layouts. No static/dynamic split.

See [Comparisons](/comparisons) for Next.js, Hono, and Elysia in detail.


---
title: "Routing, validation, middleware, and streaming"
url: "https://getspiceflow.com/api.md"
description: "Define Spiceflow routes with Zod validation, typed errors, middleware, static files, CORS, SSE streaming, and Node or Cloudflare adapters."
---

# Routing, validation, middleware, and streaming

Spiceflow routes are defined by **chaining** `.get()`, `.post()`, and `.route()` calls on a single app expression. `.route()` accepts Zod schemas for `request`, `response`, `query`, and `params`, giving you runtime validation and full type inference in one place.

## Route Chaining

To preserve full type safety on the fetch client, routes must be chained in a single expression. Declaring the app separately and adding routes later loses the inferred types.

<Expandable title={<Markdown inline children="Why chaining matters" />}>
  When you declare routes separately, TypeScript can't infer the combined route types across multiple statements. The fetch client needs the full chain to infer path params, query params, body types, and response types.

  ```ts
  // This is an example of what NOT to do when using Spiceflow

  import { Spiceflow } from 'spiceflow'

  // DO NOT declare the app separately and add routes later
  export const app = new Spiceflow()

  // Do NOT do this! Defining routes separately will lose type safety
  app.get('/hello', () => {
    return 'Hello, World!'
  })
  // Do NOT do this! Adding routes separately like this will lose type safety
  app.post('/echo', async ({ request }) => {
    const body = await request.json()
    return body
  })
  ```
</Expandable>

## Returning JSON

Spiceflow automatically serializes objects returned from handlers to JSON. Return plain objects directly — this is the preferred approach because the typed fetch client can infer the response type automatically:

```ts
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow()
  .get('/user', () => {
    // Preferred — return type is inferred by the typed fetch client
    return { id: 1, name: 'John', email: 'john@example.com' }
  })
  .post('/data', async ({ request }) => {
    const body = await request.json()
    return {
      received: body,
      timestamp: new Date().toISOString(),
      processed: true,
    }
  })
```

When you need to return a non-200 status code, use the `json()` helper instead of `Response.json()`. It works the same way at runtime but preserves the data type and status code in the type system — so the fetch client gets full type safety for each status code:

```ts
import { Spiceflow, json } from 'spiceflow'

// Preferred — type-safe, fetch client knows this is a 404 with { message: string }
throw json({ message: 'Not found' }, { status: 404 })

// Avoid — Response.json() erases the type, fetch client sees unknown
throw Response.json({ message: 'Not found' }, { status: 404 })
```

## Routes & Validation

Define routes with Zod schemas for automatic request and response validation. Use `.route()` with `request`, `response`, `query`, and `params` schemas for full type safety.

### Request Validation

```ts
import { z } from 'zod'
import { Spiceflow } from 'spiceflow'

new Spiceflow().route({
  method: 'POST',
  path: '/users',
  request: z.object({
    name: z.string(),
    email: z.string().email(),
  }),
  async handler({ request }) {
    const body = await request.json() // here body has type { name: string, email: string }
    return `Created user: ${body.name}`
  },
})
```

<Expandable title={<Markdown inline children="How body parsing works" />}>
  To get the body of the request, call `request.json()` to parse the body as JSON. Spiceflow does not parse the body automatically — there is no `body` field in the route argument. Instead you call either `request.json()` or `request.formData()` to get the body and validate it at the same time. The returned data will have the correct schema type instead of `any`.

  The `request` object in every handler and middleware is a `SpiceflowRequest`, which extends the standard Web `Request`. On top of the standard API, it adds:

  * **`request.parsedUrl`** — a lazily cached `URL` object, so you don't need to write `new URL(request.url)` yourself. Accessing `.pathname`, `.searchParams`, etc. is one property access away
  * **`request.json()` / `request.formData()`** — parse and validate the body against the route schema in one step, returning typed data instead of `any`
  * **`request.originalUrl`** — the raw transport URL before Spiceflow normalizes `.rsc` pathnames
</Expandable>

### Response Schema

```ts
import { z } from 'zod'
import { Spiceflow } from 'spiceflow'

new Spiceflow().route({
  method: 'GET',
  path: '/users/:id',
  request: z.object({
    name: z.string(),
  }),
  response: z.object({
    id: z.number(),
    name: z.string(),
  }),
  async handler({ request, params }) {
    const typedJson = await request.json() // this body will have the correct type
    return { id: Number(params.id), name: typedJson.name }
  },
})
```

### Typed Error Responses

When a route declares a status-code response map, use the `json()` helper from `spiceflow` to return or throw non-200 responses with full type safety. Unlike `Response.json()`, `json()` carries the data type and status code through the type system — so TypeScript validates that the status code exists in the response schema and the body matches the declared shape.

```ts
import { Spiceflow, json } from 'spiceflow'
import { z } from 'zod'

new Spiceflow().route({
  method: 'GET',
  path: '/users/:id',
  response: {
    200: z.object({ id: z.string(), name: z.string() }),
    404: z.object({ message: z.string() }),
  },
  handler({ params }) {
    const user = findUser(params.id)
    if (!user) {
      // TypeScript validates: 404 is in the response map, and { message: string } matches the 404 schema
      throw json({ message: 'not found' }, { status: 404 })
    }
    return { id: user.id, name: user.name }
  },
})
```

Name the human-readable field **`message`**, never `error`. `SpiceflowFetchError` builds its `.message` from that key, so callers read `err.message` like on any other `Error`. With any other key (`error`, `detail`, `title`) the client falls back to `JSON.stringify(body)` and `err.message` becomes a raw blob like `{"error":"Not found"}`. Machine-readable data goes in **sibling fields** such as `code` or `retryAfter`.

User-facing error messages should include **how to fix** the problem, not only what went wrong. Agents and CLIs that hit the error can then self-correct. If the user is logged out, include the login URL or CLI command:

```ts
if (!session) {
  throw json(
    {
      message:
        'Not logged in. Sign in at https://app.example.com/login or run: mycli login',
    },
    { status: 401 },
  )
}
```

This applies to HTTP responses, thrown server-action errors, and any message a user or agent will read. Internal errors can stay technical.

If you pass a status code that's not in the response map, or a body that doesn't match the schema for that status, `tsc` reports an error:

```ts
// @ts-expect-error — 500 is not in the response schema
throw json({ message: 'server error' }, { status: 500 })

// @ts-expect-error — number doesn't match { message: string } for 404
throw json(42, { status: 404 })
```

The fetch client picks up these types automatically — each non-200 status becomes a typed `SpiceflowFetchError` with the exact body shape. See [Preserving Client Type Safety](/openapi#preserving-client-type-safety) for the full client-side pattern.

## Not Found Handler

For API routes (`.route()`, `.get()`, etc.), use `/*` as a catch-all to handle unmatched requests. For React pages, use `children === null` in a layout instead (see [Redirects and Not Found](/react#redirects-and-not-found)). More specific routes always take precedence regardless of registration order:

```ts
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow()
  .route({
    method: 'GET',
    path: '/users',
    handler() {
      return { users: [] }
    },
  })
  .route({
    method: 'GET',
    path: '/users/:id',
    handler({ params }) {
      return { id: params.id }
    },
  })
  // Catch-all for unmatched GET requests
  .route({
    method: 'GET',
    path: '/*',
    handler() {
      return new Response('Page not found', { status: 404 })
    },
  })
  // Or use .all() to catch any method
  .route({
    method: '*',
    path: '/*',
    handler({ request }) {
      return new Response(`Cannot ${request.method} ${request.parsedUrl.pathname}`, {
        status: 404,
      })
    },
  })

// Specific routes work as expected
// GET /users returns { users: [] }
// GET /users/123 returns { id: '123' }
// GET /unknown returns 'Page not found' with 404 status
```

<Info title="Important">
  Do **not** use named wildcards like `*filePath`. Only bare `*` is supported. Named wildcards silently fail to match any request. Access the wildcard value via `params['*']` instead.
</Info>

## Mounting Sub-Apps

```ts
import { Spiceflow } from 'spiceflow'
import { z } from 'zod'

const mainApp = new Spiceflow()
  .route({
    method: 'POST',
    path: '/users',
    async handler({ request }) {
      return `Created user: ${(await request.json()).name}`
    },
    request: z.object({
      name: z.string(),
    }),
  })
  .use(
    new Spiceflow().route({
      method: 'GET',
      path: '/',
      handler() {
        return 'Users list'
      },
    }),
  )
```

## Base Path

For standalone API servers (without Vite), set the base path in the constructor:

```ts
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow({ basePath: '/api/v1' })
app.route({
  method: 'GET',
  path: '/hello',
  handler() {
    return 'Hello'
  },
}) // Accessible at /api/v1/hello
```

### Vite Base Path

When using Spiceflow as a full-stack RSC framework with Vite, configure the base path via Vite's `base` option instead of the constructor:

```ts
// vite.config.ts
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import spiceflow from 'spiceflow/vite'

export default defineConfig({
  base: '/my-app',
  plugins: [react(), spiceflow({ entry: 'src/main.tsx' })],
})
```

<Expandable title={<Markdown inline children="Base path rules" />}>
  The base path must be an absolute path starting with `/`. CDN URLs and relative paths are not supported.

  Do not set `basePath` in the Spiceflow constructor when using Vite — Spiceflow will throw an error if both are set. The Vite `base` option is the single source of truth.
</Expandable>

<Expandable title={<Markdown inline children="What gets auto-prepended and what doesn't" />}>
  **What gets the base path auto-prepended:**

  * `Link` component `href` — `<Link href="/dashboard" />` automatically renders as `<a href="/my-app/dashboard">`. If the href already includes the base prefix, it is not added again (`<Link href="/my-app/dashboard" />` stays as-is). To disable auto-prepending entirely, use the `rawHref` prop: `<Link rawHref href="/docs/docs" />` — useful when your path legitimately starts with the same string as the base
  * `redirect()` Location header — `redirect("/login")` sends `Location: /my-app/login`
  * `router.push()` and `router.replace()` — `router.push("/settings")` navigates to `/my-app/settings`
  * `router.pathname` — returns the path **without** the base prefix (e.g. `/dashboard`, not `/my-app/dashboard`)
  * Static asset URLs (`<script>`, `<link>` CSS tags) — handled automatically by Vite
  * `serveStatic` file resolution — strips the base prefix before looking up files on disk

  **What does NOT get auto-prepended:**

  * Raw `<a href="/path">` tags (not using the `Link` component) — use `Link` instead
  * External URLs and protocol-relative URLs (`//cdn.com/...`) — left as-is
  * `fetch()` calls inside your app code — you need to construct the URL yourself
  * `request.url` and `request.parsedUrl` in middleware — contain the full URL including the base prefix
</Expandable>

## Route Discovery

Use `app.getRoutes()` to inspect routes registered on the app and its mounted sub-apps. Each item contains only `path`, `method`, and `kind`. Pages appear once with the `GET` method. Dynamic parameters stay in the path pattern, so you can filter them or replace them with values from your database:

```ts
const staticPagePaths = app
  .getRoutes()
  .filter((route) => route.kind === 'page')
  .filter((route) => !route.path.includes(':') && !route.path.includes('*'))
  .map((route) => route.path)
```

This makes build-time routes such as `sitemap.xml` and `llms.txt` small. Define the pages first so their route metadata is available to the static route handler:

```tsx
import { Spiceflow } from 'spiceflow'
import llmsText from './llms.md?raw'

const origin = 'https://example.com'

const site = new Spiceflow()
  .page('/', async () => <h1>Home</h1>)
  .page('/about', async () => <h1>About</h1>)
  .page('/posts/:slug', async ({ params }) => <h1>{params.slug}</h1>)

export const app = site
  .staticGet('/sitemap.xml', () => {
    const paths = site
      .getRoutes()
      .filter((route) => route.kind === 'page')
      .filter((route) => !route.path.includes(':') && !route.path.includes('*'))
      .map((route) => route.path)
    const urls = paths
      .map((path) => `  <url><loc>${new URL(path, origin).href}</loc></url>`)
      .join('\n')
    const xml = [
      '<?xml version="1.0" encoding="UTF-8"?>',
      '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
      urls,
      '</urlset>',
    ].join('\n')

    return new Response(xml, {
      headers: { 'content-type': 'application/xml; charset=utf-8' },
    })
  })
  .staticGet('/llms.txt', () => {
    return new Response(llmsText, {
      headers: { 'content-type': 'text/markdown; charset=utf-8' },
    })
  })
```

Resolve dynamic paths such as `/posts/:slug` yourself before adding them to the sitemap. `app.href('/posts/:slug', { slug })` can replace the parameters after you load the values.

## Middleware

Middleware functions run before route handlers. They can log, authenticate, modify responses, or short-circuit the request entirely.

```ts
import { Spiceflow } from 'spiceflow'

new Spiceflow().use(({ request }) => {
  console.log(`Received ${request.method} request to ${request.parsedUrl.pathname}`)
})
```

### Mounted Apps

Middleware is scoped to the app where you register it. **Parent app middleware runs for child sub-app routes too**, but **sub-app middleware does not run for parent or sibling routes**.

```ts
import { Spiceflow } from 'spiceflow'

const admin = new Spiceflow({ basePath: '/admin' })
  .use(() => {
    console.log('admin only')
  })
  .get('/users', () => 'users')

new Spiceflow()
  .use(() => {
    console.log('root')
  })
  .use(admin)
  .get('/health', () => 'ok')

// GET /admin/users -> runs "root" and "admin only"
// GET /health      -> runs only "root"
```

If you want a mounted app's middleware to run for **every** request, create that mounted app with `scoped: false`:

```ts
const globalMiddleware = new Spiceflow({ scoped: false }).use(({ request }) => {
  console.log(request.parsedUrl.pathname)
})

new Spiceflow().use(globalMiddleware)
```

### Response Modification

Call `next()` to get the response from downstream handlers, then modify it before sending:

```ts
import { Spiceflow } from 'spiceflow'

new Spiceflow()
  .use(async ({ request }, next) => {
    const response = await next()
    if (response) {
      // Add a custom header to all responses
      response.headers.set('X-Powered-By', 'Spiceflow')
    }
    return response
  })
  .route({
    method: 'GET',
    path: '/example',
    handler() {
      return { message: 'Hello, World!' }
    },
  })
```

### Static Files

Use `serveStatic()` to serve files from a directory:

```ts
import { Spiceflow, serveStatic } from 'spiceflow'

export const app = new Spiceflow()
  .use(serveStatic({ root: './public' }))
  .route({
    method: 'GET',
    path: '/health',
    handler() {
      return { ok: true }
    },
  })
  .route({
    method: 'GET',
    path: '/*',
    handler() {
      return new Response('Not Found', { status: 404 })
    },
  })
```

Static middleware only serves `GET` and `HEAD` requests. It checks the exact file path first, and if the request points to a directory it tries `index.html` inside that directory.

<Expandable title={<Markdown inline children="Priority rules" />}>
  * Concrete routes win over static files. A route like `/health` is handled by the route even if `public/health` exists.
  * Static files win over root catch-all routes like `/*` and `*`.
  * If static does not find a file, the request falls through to the next matching route.
  * When multiple static middlewares are registered, they are checked in registration order. The first middleware that finds a file wins.

  Example behavior:

  ```text
  request /logo.png
    -> router matches `/*`
    -> static checks `public/logo.png`
    -> if file exists, static serves it
    -> otherwise the `/*` route runs
  ```

  Directory requests without an `index.html` fall through instead of throwing filesystem errors like `EISDIR`.
</Expandable>

You can stack multiple static roots:

```ts
export const app = new Spiceflow()
  .use(serveStatic({ root: './public' }))
  .use(serveStatic({ root: './uploads' }))
```

In this example, `./public/logo.png` wins over `./uploads/logo.png` because `./public` is registered first.

> Vite client build assets (`dist/client`) are served automatically in production — no need to register a `serveStatic` middleware for them.

### Static Routes (Pre-rendered)

Use `.staticGet()` to define API routes that are **pre-rendered at build time** and served as static files. The handler runs once during `vite build`, and the response body is written to `dist/client/` so it can be served directly without hitting the server at runtime:

```ts
export const app = new Spiceflow()
  .staticGet('/api/manifest.json', () => ({
    name: 'my-app',
    version: '1.0.0',
    features: ['rsc', 'streaming'],
  }))
  .staticGet('/robots.txt', () =>
    new Response('User-agent: *\nAllow: /', {
      headers: { 'content-type': 'text/plain' },
    }),
  )
```

In development, `staticGet` routes behave like normal `.get()` handlers — the handler runs on every request. At build time, Spiceflow calls each handler and writes the output to disk. The route path should include a file extension (`.json`, `.xml`, `.txt`) so the static file server can detect the correct MIME type.

For authorization, proxy, non-blocking auth, cookies, and graceful shutdown patterns, see [Middleware Patterns](/middleware-patterns).

## Adding CORS Headers

```ts
import { cors } from 'spiceflow/cors'
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow().use(cors()).route({
  method: 'GET',
  path: '/hello',
  handler() {
    return 'Hello, World!'
  },
})
```

`cors()` always runs **first** among the middlewares in scope, wherever you register it. Errors, 401s and redirects thrown by an auth middleware still get CORS headers, and preflight `OPTIONS` requests get a `204` even when they carry no credentials.

## Error Handling

When a route handler or middleware throws an error, Spiceflow catches it and returns a JSON response with the error message and stack trace. **By default, unhandled errors are also logged to the console** with `Spiceflow unhandled error:` so you can see what went wrong during development.

Use `.onError()` to customize error handling. Registering an `.onError` callback **replaces the default logging**, so errors are only handled by your callback:

```ts
import { Spiceflow } from 'spiceflow'

const app = new Spiceflow()
  .get('/users/:id', async ({ params }) => {
    const user = await findUser(params.id)
    if (!user) throw Object.assign(new Error('User not found'), { status: 404 })
    return user
  })
  .onError(({ error, path }) => {
    // Custom error handling replaces default console.error logging
    console.error(`Error on ${path}:`, error.message)
    return new Response('Something went wrong', { status: 500 })
  })
```

If you return a `Response` from `.onError`, it becomes the response for that request. If you don't return anything, Spiceflow falls back to its default JSON error response (but skips the default logging since you have a handler registered).

To silence error logs entirely (useful in tests), register a no-op handler:

```ts
const app = new Spiceflow()
  .get('/test', () => { throw new Error('expected') })
  .onError(() => {})
```

Errors with a `status` property (or `statusCode`) are used as the HTTP status code. Invalid or out-of-range status codes are normalized to 500:

```ts
// Returns 400 Bad Request
throw Object.assign(new Error('Invalid input'), { status: 400 })
```

### Stack traces and redaction

The default error body includes the **stack** so you and your coding agents can find the failing line quickly. Pass `noStackTraces: true` to remove it in production:

```ts
const app = new Spiceflow({ noStackTraces: true })
  .get('/fail', () => {
    throw new Error('upstream failed')
  })

// GET /fail -> 500 {"message":"upstream failed"}
```

Only the app that handles the request reads this option. Set it on the root app; values on apps mounted with `.use()` are ignored. It also applies to SSE `event: error` payloads and to the fallback errors from `listen()` and `handleForNode()`.

Spiceflow always **redacts** secrets in error bodies, with or without stacks. Only secrets are touched: JWTs, private keys, connection string passwords, known API key formats (`sk_live_`, `ghp_`, `AKIA`, ...), long hex or high-entropy strings, and credential-looking values after names like `token=`, `password:`, `x-api-key:` or `Bearer`. Plain words stay readable, so `invalid token: expired` or `Bearer realm missing` are not changed. File paths, route names, and UUIDs stay readable too:

```json
{
  "message": "fetch failed for /select-account?token=[REDACTED] with postgres://user:[REDACTED]@db:5432/app",
  "stack": "Error: fetch failed ...\n    at /select-account (file:///app/dist/rsc/assets/index-DlK3m9Zq.js:12:4)"
}
```

Errors with a **4xx status** are public. Their message is written for the client, so it is never redacted. Use this for messages you want users to see exactly:

```ts
throw Object.assign(new Error('Header must be "Bearer <token>"'), { status: 400 })
```

A thrown `Response` is also sent as is. Extra fields and the stack of a 4xx error are still sanitized.

React pages never send stacks in production. RSC and server action errors reach the browser only as a message, redacted unless the error is public.

## Async Generators (Streaming)

Route handlers that are **async generators** automatically produce a Server-Sent Events response. Each `yield` sends a `data: ...\n\n` chunk to the client. Works with `.get()`, `.post()`, and `.route()`.

```ts
// server.ts
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow().route({
  method: 'GET',
  path: '/api/progress',
  async *handler() {
    yield { status: 'generating', progress: 0.5 }
    await someWork()
    yield { status: 'done', progress: 1.0 }
  },
})

export type App = typeof app
```

The typed fetch client detects async generator routes and returns an `AsyncGenerator` instead of a plain object. TypeScript infers the yield type automatically via `ReplaceGeneratorWithAsyncGenerator` in the type system.

```ts
// client.ts
import { createSpiceflowFetch } from 'spiceflow/client'

const safeFetch = createSpiceflowFetch('http://localhost:3000')

const stream = await safeFetch('/api/progress')
if (stream instanceof Error) throw stream

// stream is AsyncGenerator<{ status: string, progress: number }>
for await (const event of stream) {
  console.log(event.status, event.progress)
}
```

#### Aborting a Stream

Pass an `AbortController` signal to cancel the stream. The server stops the generator and cleans up.

```ts
const controller = new AbortController()

// abort after 5 seconds
setTimeout(() => controller.abort(), 5000)

const stream = await safeFetch('/api/progress', {
  signal: controller.signal,
})
if (stream instanceof Error) throw stream

for await (const event of stream) {
  console.log(event)
}
// loop exits cleanly on abort, no error thrown
```

With **curl**, use `-N` to disable buffering and stream events line by line:

```bash
curl -N http://localhost:3000/api/progress
```

## Server Lifecycle

`listen()` returns an object with `port`, `server`, and `stop()` for programmatic control:

```ts
const listener = await app.listen(3000)

console.log(`Listening on port ${listener.port}`)

await listener.stop()
```

> In Vite dev and during prerender, Spiceflow skips starting a real server. `listen()` still returns an object, but `port` and `server` are `undefined` and `stop()` is a noop, so cleanup code can stay unconditional.

## Graceful Shutdown

The `preventProcessExitIfBusy` middleware prevents platforms like Fly.io from killing your app while processing long requests. See [Middleware Patterns](/middleware-patterns#graceful-shutdown) for usage.

## Background Tasks (`waitUntil`)

All background promises (fire-and-forget work like analytics, logging, cache writes) **must** use `waitUntil` from the handler context. Never do `void somePromise()` or `somePromise().catch(...)` directly; the runtime may kill the process before the promise settles.

```ts
export const app = new Spiceflow().route({
  method: 'POST',
  path: '/process',
  async handler({ request, waitUntil }) {
    const data = await request.json()

    waitUntil(
      fetch('https://analytics.example.com/track', {
        method: 'POST',
        body: JSON.stringify({ event: 'processed', data }),
      }),
    )

    return { success: true }
  },
})
```

On Cloudflare Workers, `waitUntil` automatically delegates to the Workers `ExecutionContext.waitUntil`. On Node.js it is a no-op by default; pass a custom implementation via `new Spiceflow({ waitUntil: (p) => { ... } })` if you need background work to be tracked. See [Cloudflare docs](/cloudflare#background-tasks-waituntil) for full examples including Cloudflare integration and custom implementations.

## Node.js Handlers

In user-facing code, you should almost never convert a Node.js `req`/`res` pair into a standard `Request` yourself. Spiceflow already exposes the right adapter for each situation, so this conversion should stay inside Spiceflow rather than in app code.

<Expandable title={<Markdown inline children="Which adapter to use" />}>
  * If you want to run your app on a port in Node.js or Bun, use `app.listen(3000)`. Spiceflow sets up the server adapter for you. Cloudflare Workers are the main exception because there is no port-based server to listen on there.
  * If you need to plug a Spiceflow app into a classic Node.js handler API that gives you `req` and `res` (for example a Next.js pages API route), use `app.handleForNode(req, res)`.
  * If you are already inside a modern WHATWG-style handler that gives you a standard `Request`, just delegate with `return app.handle(request)`.

  If you find yourself writing manual request-conversion glue in app code, that is usually a sign that you should use one of these Spiceflow entrypoints instead.
</Expandable>

```ts
import { Spiceflow } from 'spiceflow'
import type { IncomingMessage, ServerResponse } from 'node:http'

export const app = new Spiceflow().get('/hello', () => {
  return { hello: 'world' }
})

// Run directly on Node.js or Bun
app.listen(3000)

// Use inside a classic Node.js req/res handler
export async function nodeHandler(req: IncomingMessage, res: ServerResponse) {
  await app.handleForNode(req, res)
}

// Use inside a standard Request handler
export default {
  fetch(request: Request) {
    return app.handle(request)
  },
}
```

## Next.js Integration

```ts
// pages/api/[...path].ts
import { getJwt } from '@app/utils/ssr' // exasmple session function
import type { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  // IMPORTANT! nothing should be run before calling handleForNode that could read the request body!
  await mcpAuthApp.handleForNode(req, res)
}

export const config = {
  api: {
    bodyParser: false,
  },
}
```

## Class Instances

If you need to store a Spiceflow router as a property in a class instance, use the `AnySpiceflow` type.

<Expandable title={<Markdown inline children="Avoid <code>this</code> in route handlers" />}>
  Do not use `this` inside route handlers to reference the parent class. The `this` context inside handlers always refers to the Spiceflow instance, not your class instance. Instead, capture the parent class reference in a variable outside the handlers.
</Expandable>

```ts
import { Spiceflow, AnySpiceflow } from 'spiceflow'

export class ChatDurableObject {
  private router: AnySpiceflow
  private state: DurableObjectState

  constructor(state: DurableObjectState, env: Env) {
    this.state = state
    const self = this // Capture parent class reference - IMPORTANT!

    this.router = new Spiceflow()
      .route({
        method: 'GET',
        path: '/messages',
        async handler() {
          // Use 'self' instead of 'this' to access parent class
          // this.state would NOT work here - 'this' refers to Spiceflow instance
          const messages = (await self.state.storage.get('messages')) || []
          return { messages }
        },
      })
      .route({
        method: 'POST',
        path: '/messages',
        async handler({ request }) {
          const { message } = await request.json()
          // Use 'self' to access parent class properties
          const messages = (await self.state.storage.get('messages')) || []
          messages.push({ id: Date.now(), text: message })
          await self.state.storage.put('messages', messages)
          return { success: true }
        },
      })
  }

  fetch(request: Request) {
    return this.router.handle(request)
  }
}
```


---
title: "Pages, layouts, Head tags, and navigation"
url: "https://getspiceflow.com/react.md"
description: "Build RSC apps with Vite: layouts, Head SEO tags, client components, code splitting, typed router, Link, redirects, cookies, and 404 pages."
---

# Pages, layouts, Head tags, and navigation

Spiceflow includes a full-stack React framework built on React Server Components (RSC). It uses Vite with `@vitejs/plugin-rsc` under the hood. Server components run on the server by default, and you use `"use client"` to mark interactive components that need to run in the browser.

## Install

Install the dependencies and create a Vite config:

```bash
npm install spiceflow@rsc react react-dom
```

```ts
// vite.config.ts
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import spiceflow from 'spiceflow/vite'

export default defineConfig({
  plugins: [
    react(),
    spiceflow({
      entry: './src/main.tsx',
    }),
  ],
})
```

## Cloudflare RSC Setup

For Cloudflare Workers deployment with RSC, see [Cloudflare docs](/cloudflare). See [`example-cloudflare/`](https://github.com/remorses/spiceflow/tree/main/example-cloudflare) for a complete working example.

## Tailwind CSS

Install `@tailwindcss/vite` and `tailwindcss`, then add the Vite plugin:

```bash
npm install @tailwindcss/vite tailwindcss
```

```ts
// vite.config.ts
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import spiceflow from 'spiceflow/vite'

export default defineConfig({
  plugins: [
    spiceflow({ entry: './src/main.tsx' }),
    react(),
    tailwindcss(),
  ],
})
```

Create a `globals.css` file with Tailwind and any CSS variables you need:

```css
/* src/globals.css */
@import 'tailwindcss';

:root {
  --radius: 0.625rem;
  --background: var(--color-white);
  --foreground: var(--color-neutral-800);
}
```

Import it at the top of your app entry so styles apply globally:

```tsx
// src/main.tsx
import './globals.css'
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow()
  .layout('/*', async ({ children }) => {
    return (
      <html>
        <body className="bg-white dark:bg-gray-900 text-black dark:text-white">
          {children}
        </body>
      </html>
    )
  })
  .page('/', async () => {
    return (
      <div className="flex flex-col items-center gap-4 p-8">
        <h1 className="text-4xl font-bold">Welcome</h1>
      </div>
    )
  })
```

## shadcn/ui

Spiceflow works with [shadcn/ui](https://ui.shadcn.com) out of the box. Instead of the usual `tsconfig.json` paths hack (`@/*`), use `package.json` `exports` for component imports — it's a standard Node.js feature that works across runtimes and lets other workspace packages import your components too. See [shadcn docs](/shadcn) for the full setup guide and [`example-shadcn/`](https://github.com/remorses/spiceflow/tree/main/example-shadcn) for a working example.

## Directory Paths

> Only available when using the Vite plugin.

Server components sometimes need to read files from the filesystem at runtime — for example, reading images from `public/` to generate Open Graph images, or writing cached files to disk. Using `import.meta.dirname` breaks on platforms like Vercel where the function runs from a different directory than where you built.

`publicDir` and `distDir` resolve to the correct absolute paths in every environment:

```tsx
import { publicDir, distDir } from 'spiceflow'
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'

export async function generateOgImage(slug: string) {
  const template = await readFile(path.join(publicDir, 'og-template.png'))
  // ... generate image
  await writeFile(path.join(distDir, 'cache', `${slug}.png`), result)
}
```

|                | `publicDir`                                            | `distDir`  |
| -------------- | ------------------------------------------------------ | ---------- |
| **Dev**        | `<cwd>/public`                                         | `<cwd>`    |
| **Production** | `<outDir>/client` (where Vite copies public/ contents) | `<outDir>` |

## App Entry

The entry file defines your routes using `.page()` for pages and `.layout()` for layouts. This file runs in the RSC environment on the server. Keep the route chain focused on handlers and move type-safe link building into components or other modules.

```tsx
// src/main.tsx
import { Spiceflow, serveStatic } from 'spiceflow'
import { router, Head, Link } from 'spiceflow/react'
import { z } from 'zod'
import { Counter } from './app/counter'
import { Nav } from './app/nav'

export const app = new Spiceflow()
  .use(serveStatic({ root: './public' }))
  .layout('/*', async ({ children }) => {
    return (
      <html>
        <Head>
          <Head.Meta charSet="UTF-8" />
        </Head>
        <body>
          <Nav />
          {children}
        </body>
      </html>
    )
  })
  .page('/', async () => {
    const data = await fetchSomeData()
    return (
      <div>
        <h1>Welcome</h1>
        <p>Server-rendered data: {data.message}</p>
        <Counter />
        <Link href={router.href('/users/:id', { id: '42' })}>View User 42</Link>
        <Link href={router.href('/search', { q: 'spiceflow' })}>Search</Link>
      </div>
    )
  })
  .page('/about', async () => {
    return (
      <div>
        <h1>About</h1>
        <Link href={router.href('/')}>Back to Home</Link>
      </div>
    )
  })
  .page('/users/:id', async ({ params }) => {
    return (
      <div>
        <h1>User {params.id}</h1>
      </div>
    )
  })
  // Object-style .page() with query schema — enables type-safe query params
  .page({
    path: '/search',
    query: z.object({ q: z.string(), page: z.number().optional() }),
    handler: async ({ query }) => {
      const results = await search(query.q, query.page)
      return (
        <div>
          <h1>Results for "{query.q}"</h1>
          {results.map((r) => (
            <p key={r.id}>{r.title}</p>
          ))}
        </div>
      )
    },
  })
  .listen(3000)

// Register the app type for type-safe routing everywhere
declare module 'spiceflow/react' {
  interface SpiceflowRegister { app: typeof app }
}
```

`router.href()` gives you **type-safe links** in component modules and other files outside the route chain. TypeScript validates that the path exists, params are correct, and query values match the schema. Invalid paths or missing params are caught at compile time.

Add the `declare module` block at the bottom of your app entry file. This registers your app's routes globally — then `import { router } from 'spiceflow/react'` anywhere in the project gives you a fully typed router without needing to pass generics or import the app type.

<Expandable title={<Markdown inline children="When registered APIs cause circular types" />}>
  Circular TypeScript errors (TS7022) happen when any API that reads from `SpiceflowRegister` (i.e. `typeof app`) appears in a handler's **return value**. The return type feeds back into `typeof app`, creating a cycle. This affects `router.href()`, `router.getLoaderData()`, `createSpiceflowFetch()`, and any future API typed against the registered app.

  **Safe** (no circular):

  * Any registered API in **JSX children or attributes** (`.page()`, `.layout()` returning JSX)
  * Any registered API in **JSX event handlers** (`onClick`, etc.)
  * `throw` expressions: `throw redirect(router.href(...))` in any handler
  * Any registered API in **client components, server components, and separate files**

  **Circular** (causes TS7022):

  * Any registered API in a `.loader()` **return value** (e.g. `return { url: router.href(...) }`)
  * Any registered API in a `.get()` or `.post()` **return value** (e.g. `return { result: await fetchClient(...) }`)
  * `return redirect(router.href(...))` inside `.page()` on paths **with loaders**

  The rule: circular happens when a `RegisteredApp`-typed expression reaches a **return value** that TypeScript needs to infer `typeof app`. JSX, `throw`, and event handler callbacks don't feed return types. See `spiceflow/src/type-repros/registered-app-circular.test.ts` for the exact boundaries.
</Expandable>

## Layouts

Define a root `.layout('/*', ...)` with the document shell (`<html>`, `<head>`, `<body>`). More specific layouts should only return shared parent UI like sidebars, nav, or section chrome — not another `<html>` shell. Wildcard layouts also match their base path, so `/app/*` wraps both `/app` and `/app/settings`.

```tsx
export const app = new Spiceflow()
  .layout('/*', async ({ children }) => {
    return (
      <html>
        <body>{children}</body>
      </html>
    )
  })
  .layout('/app/*', async ({ children }) => {
    return <section className="app-shell">{children}</section>
  })
  .layout('/docs/*', async ({ children }) => {
    return <section className="docs-shell">{children}</section>
  })
  .page('/app', async () => {
    return <h1>App home</h1>
  })
  .page('/app/settings', async () => {
    return <h1>App settings</h1>
  })
  .page('/docs', async () => {
    return <h1>Docs home</h1>
  })
  .page('/docs/getting-started', async () => {
    return <h1>Getting started</h1>
  })
```

<Expandable title={<Markdown inline children="Nesting rules" />}>
  Only the root layout should render the full HTML document shell. If a nested layout also renders `<html>`, the shell repeats and you end up nesting full HTML documents inside each other. Only add scoped layouts when many pages share the same parent components.
</Expandable>

## SEO

Use `<Head>`, `<Head.Title>`, and `<Head.Meta>` from `spiceflow/react` for type-safe, automatically deduplicated head tags that are correctly injected during SSR. Page tags override layout tags with the same key.

Every page should have a `<Head.Title>` and a `<Head.Meta name="description">`. These are the two most important tags for SEO — they control what appears in search engine results.

<Info title="Important">
  **`<Head>` only works in a server component.** It does not render anything; it records its children during the RSC render, and spiceflow reads them back to build the document head. A `'use client'` module never runs in that render, so a `<Head>` inside one contributes nothing. Importing `Head` from a `'use client'` module **fails the build**, and rendering one throws, rather than dropping your `<title>` silently.
</Info>

Put the `<Head>` in the `.page()` or `.layout()` handler, then render the client component next to it:

```tsx
// app.tsx — server
.page('/', async () => {
  return (
    <>
      <Head>
        <Head.Title>Make ChatGPT undetectable</Head.Title>
        <Head.Meta name="description" content="Rewrite AI text so it reads like a human wrote it." />
      </Head>
      <InteractiveEditor />
    </>
  )
})
```

```tsx
// interactive-editor.tsx — client, no <Head> here
'use client'

export function InteractiveEditor() {
  const [text, setText] = useState('')
  return <textarea value={text} onChange={(e) => setText(e.target.value)} />
}
```

To change the title from the browser after the page has loaded, set `document.title` in an effect. `<Head>` is for the server-rendered document.

<Expandable title={<Markdown inline children="Title and description guidelines" />}>
  **Title:** Keep titles under 60 characters so they don't get truncated in search results. Put the most important keywords first. Use a consistent format like `Page Name | Site Name`.

  **Description:** Keep descriptions between 120–160 characters. Summarize the page content clearly — this is the snippet shown below the title in search results. Each page should have a unique description that accurately reflects its content.

  Always use `<Head>`, `<Head.Title>`, and `<Head.Meta>` from `spiceflow/react` instead of raw `<head>`, `<title>`, and `<meta>` tags. The `Head` components are type-safe, automatically deduplicated (page tags override layout tags with the same key), and correctly injected into the document head during SSR.
</Expandable>

```tsx
.page('/', async () => {
  return (
    <div>
      <Head>
        <Head.Title>Spiceflow – Build Type-Safe APIs</Head.Title>
        <Head.Meta name="description" content="A fast, type-safe API and RSC framework for TypeScript." />
      </Head>
      <h1>Welcome</h1>
    </div>
  )
})
```

If you want a consistent title prefix or suffix across all pages, create a wrapper component:

```tsx
function PageHead({ title, description }: { title: string; description: string }) {
  return (
    <Head>
      <Head.Title>{title} | My App</Head.Title>
      <Head.Meta name="description" content={description} />
    </Head>
  )
}

// Then use it in any page
.page('/about', async () => {
  return (
    <div>
      <PageHead title="About" description="Learn more about our team and mission." />
      <h1>About</h1>
    </div>
  )
})
```

## Query Params

Define a `query` schema on routes and pages that accept query parameters — even when all params are optional. Use the object notation for `.page()` and `.route()` so the query requirements are documented in the route definition and accessible with full type safety in the handler:

```tsx
import { Spiceflow } from 'spiceflow'
import { z } from 'zod'

export const app = new Spiceflow()
  // Object notation gives you typed query access
  .page({
    path: '/products',
    query: z.object({
      category: z.string().optional(),
      sort: z.enum(['price', 'name', 'date']).optional(),
      page: z.coerce.number().optional(),
    }),
    handler: async ({ query }) => {
      // query.category is string | undefined — fully typed
      // query.sort is 'price' | 'name' | 'date' | undefined
      // query.page is number | undefined
      const products = await getProducts(query)
      return (
        <div>
          <h1>Products</h1>
          {products.map((p) => <p key={p.id}>{p.name}</p>)}
        </div>
      )
    },
  })
```

<Expandable title={<Markdown inline children="Why always define a query schema" />}>
  Without a query schema, `query` is `Record<string, string | undefined>` — you lose autocomplete, typos go unnoticed, and there's no documentation of what the page accepts.

  Always define a `query` schema on routes and pages that accept query parameters. Use `href()` to build links to these pages — when a route has a query schema, `href` enforces the correct query keys at compile time. If you rename or remove a query param from the schema, every `href()` call that references it becomes a type error — no stale links.
</Expandable>

**Use `href()` to build links to these pages.** When a route has a query schema, `href` enforces the correct query keys at compile time. If you rename or remove a query param from the schema, every `href()` call that references it becomes a type error — no stale links:

```tsx
'use client'
import { router, Link } from 'spiceflow/react'

export function ProductFilters() {
  return (
    <nav>
      {/* TypeScript validates these query keys against the schema */}
      <Link href={router.href('/products', { category: 'shoes', sort: 'price' })}>
        Shoes by Price
      </Link>
      <Link href={router.href('/products', { sort: 'date', page: 2 })}>
        Page 2, newest first
      </Link>

      {/* @ts-expect-error — 'color' is not in the query schema */}
      <Link href={router.href('/products', { color: 'red' })}>Red</Link>
    </nav>
  )
}
```

The same pattern works for API routes with `.route()`. Query params are automatically coerced from strings to match the schema type — you don't need `z.coerce.number()`, just use `z.number()` directly:

```tsx
export const app = new Spiceflow()
  .route({
    method: 'GET',
    path: '/api/search',
    query: z.object({
      q: z.string(),
      limit: z.number().optional(),
      offset: z.number().optional(),
    }),
    handler({ query }) {
      // query.q is string, query.limit is number | undefined
      return searchDatabase(query.q, query.limit, query.offset)
    },
  })
```

**Array query params** use repeated keys in the URL: `?tag=a&tag=b` (not comma-separated). Single values are automatically wrapped into arrays when the schema expects `z.array()`:

```tsx
// URL: /api/posts?tag=react or /api/posts?tag=react&tag=typescript
export const app = new Spiceflow().route({
  method: 'GET',
  path: '/api/posts',
  query: z.object({
    tag: z.array(z.string()),
    limit: z.number().optional(),
  }),
  handler({ query }) {
    // query.tag is always string[], even with a single ?tag=react
    // query.limit is number | undefined, coerced from the string automatically
    return getPostsByTags(query.tag)
  },
})
```

## Client Components

Mark interactive components with `"use client"` at the top of the file. These are hydrated in the browser and can use hooks like `useState`.

```tsx
// src/app/counter.tsx
'use client'

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  )
}
```

## Don't export plain objects from `'use client'` files

In RSC, the entire module marked with `'use client'` becomes an opaque client reference on the server. You can render client component references as JSX, but you **cannot spread or iterate** them. If a server component imports a plain object (like a component map or config) from a `'use client'` file and tries to spread it, the spread produces nothing.

```tsx
// BAD: server component can't spread this
// my-components.tsx
'use client'
import { useState } from 'react'
function Counter() { /* ... uses useState */ }
function P({ children }) { return <p className="prose">{children}</p> }
export const components = { p: P, counter: Counter }

// GOOD: keep the map in a server-compatible file
// counter.tsx
'use client'
export function Counter() { /* ... uses useState */ }

// my-components.tsx (no 'use client')
import { Counter } from './counter'
function P({ children }) { return <p className="prose">{children}</p> }
export const components = { p: P, counter: Counter }
```

Only components that use browser-only APIs (hooks, DOM refs) need `'use client'`. Pure JSX, config objects, and component maps must stay in server-compatible modules. Import individual client components into the server module, not the other way around.

## Code Splitting

Code splitting of client components is **automatic** — you don't need `React.lazy()` or dynamic `import()`. Each `"use client"` file becomes a separate chunk, and the browser only loads the chunks needed for the current page.

<Expandable title={<Markdown inline children="How it works" />}>
  When the RSC flight stream is sent to the browser, it contains references to client component chunks rather than the actual code. The browser resolves and loads only the chunks referenced on the current page. If route `/about` uses `<Map />` and route `/dashboard` uses `<Chart />`, visiting `/about` will never download the Chart component's JavaScript.
</Expandable>

<Expandable title={<Markdown inline children="Barrel file pitfall" />}>
  Avoid barrel files with `"use client"`. If you have a single file with `"use client"` that re-exports many components, all of them end up in one chunk — defeating code splitting. Instead, put `"use client"` in each individual component file:

  ```tsx
  // BAD — one big chunk for everything
  // src/components/index.tsx
  'use client'
  export { Chart } from './chart'
  export { Map } from './map'
  export { Table } from './table'
  ```

  ```tsx
  // GOOD — each component is its own chunk
  // src/components/chart.tsx
  'use client'
  export function Chart() {
    /* ... */
  }

  // src/components/map.tsx
  ;('use client')
  export function Map() {
    /* ... */
  }

  // Re-export barrel has no directive, just passes through
  // src/components/index.tsx
  export { Chart } from './chart'
  export { Map } from './map'
  ```
</Expandable>

## View Transitions

Client navigations already run inside `startTransition`, so wrapping page content in React 19.3 `<ViewTransition>` animates route changes. Spiceflow tags each navigation with `addTransitionType('navigation-forward')` or `addTransitionType('navigation-back')` so you can pick different enter/exit CSS.

```tsx
import { ViewTransition } from 'react'

.layout('/*', async ({ children }) => {
  return (
    <html>
      <body>
        <ViewTransition
          enter={{
            'navigation-forward': 'slide-from-right',
            'navigation-back': 'slide-from-left',
          }}
          exit={{
            'navigation-forward': 'slide-to-left',
            'navigation-back': 'slide-to-right',
          }}
        >
          {children}
        </ViewTransition>
      </body>
    </html>
  )
})
```

`PUSH`, `REPLACE`, and history forward use `navigation-forward`. History back uses `navigation-back`. Server actions that re-render the current page do not add a type, so they do not steal a route animation. Wrap only the subtree you want to animate. React skips the animation in browsers without the View Transition API.

Define the class names in CSS with `::view-transition-old(...)` and `::view-transition-new(...)`. See the [React `<ViewTransition>` docs](https://react.dev/reference/react/ViewTransition).

## Resource Preloading

React 19 exports `preload`, `preinit`, `prefetchDNS`, and `preconnect` from `react-dom`. Call them in your component render body and they emit `<link>` tags into SSR HTML so the browser starts fetching before any JS runs. Works in both server and client components; duplicates are auto-deduplicated.

```tsx
import { preload, preinit, prefetchDNS, preconnect } from 'react-dom'

function App() {
  preload('/assets/hero.mp4', { as: 'video' })
  preload('/fonts/Inter.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' })
  preinit('/styles/dashboard.css', { as: 'style' })   // downloads AND inserts
  prefetchDNS('https://api.example.com')
  preconnect('https://cdn.example.com', { crossOrigin: 'anonymous' })

  return <div>{/* ... */}</div>
}
```

| Function      | Effect               | Use for               |
| ------------- | -------------------- | --------------------- |
| `preload`     | Download and cache   | Videos, images, fonts |
| `preinit`     | Download and execute | Stylesheets, scripts  |
| `prefetchDNS` | DNS lookup           | API domains           |
| `preconnect`  | DNS + TCP + TLS      | CDNs, auth providers  |

## `use client` trap in optimized dependencies

If a `node_modules` dependency mixes server and client code in one entry, Vite can flatten the `'use client'` boundary into a server chunk — crashing at startup with errors like `useState is undefined`. See [docs/use-client-trap.md](https://github.com/remorses/spiceflow/blob/main/docs/use-client-trap.md) for symptoms, diagnosis, and fixes.

## Progress Bar

Render `<ProgressBar />` once in the root layout. For manual client-side async work, wrap the call in `ProgressBar.start()` / `ProgressBar.end()`:

```tsx
// src/main.tsx
import { Spiceflow } from 'spiceflow'
import { ProgressBar } from 'spiceflow/react'
import { SaveButton } from './app/save-button'

export const app = new Spiceflow().layout('/*', async ({ children }) => {
  return (
    <html>
      <body>
        <ProgressBar />
        {children}
        <SaveButton />
      </body>
    </html>
  )
})

// src/app/save-button.tsx
'use client'

import { ProgressBar } from 'spiceflow/react'

export function SaveButton() {
  return (
    <button
      onClick={async () => {
        ProgressBar.start()
        try {
          await fetch('/api/save', { method: 'POST' })
        } finally {
          ProgressBar.end()
        }
      }}
    >
      Save
    </button>
  )
}
```

Manual calls share the same state as router navigation, so if a navigation and a client fetch overlap, the bar stays visible until both have finished.

<Expandable title={<Markdown inline children="React export shape" />}>
  Do not mix React component exports with non-React exports like `const`, `Context`, or plain helper functions in the same public module. That can break HMR / Fast Refresh because the module stops behaving like a pure component module.

  If a component needs imperative helpers, attach them as static properties on the component instead of exporting separate helpers. For example, prefer `ProgressBar.start()` / `ProgressBar.end()` over standalone `startProgressBar()` or `endProgressBar()` exports.
</Expandable>

## Redirecting After Actions

When a server action needs to navigate to a different page (e.g. after creating a resource), use `redirect` inside the action instead of `router.push()` on the client. Since every server action triggers a page re-render, calling `router.push()` after the action would briefly flash the re-rendered current page before navigating away.

In standalone `"use server"` action files, always wrap the redirect target with `router.href()` for type safety — TypeScript will catch invalid paths and missing params at compile time:

```tsx
// src/actions.ts
'use server'

import { redirect } from 'spiceflow'
import { router } from 'spiceflow/react'
import { parseFormData } from 'spiceflow'
import type { z } from 'zod'
import { projectSchema } from './schemas.ts'

export async function createProject(formData: FormData) {
  const { name } = parseFormData(projectSchema, formData)
  const project = await db.projects.create({ name })
  // router.href validates the path and params against the route table at compile time
  throw redirect(router.href('/orgs/:orgId/projects/:projectId', {
    orgId: project.orgId,
    projectId: project.id,
  }))
}
```

For inline actions defined directly inside a `.page()` or `.layout()` handler (in the same file as `export const app`), prefer the handler context `redirect` with a plain string or the `params` option to sidestep circular type issues (see [when router.href() causes circular types](#app-entry)):

```tsx
import { Spiceflow, parseFormData } from 'spiceflow'
import { z } from 'zod'

const projectSchema = z.object({ name: z.string().min(1) })
const fields = projectSchema.keyof().enum

export const app = new Spiceflow()
  .page('/orgs/:orgId/projects/:projectId', async ({ params }) => {
    const project = await db.projects.find(params.projectId)
    return <ProjectPage project={project} />
  })
  .page('/orgs/:orgId/projects/new', async ({ params, redirect }) => {
    async function createProject(formData: FormData) {
      'use server'
      const { name } = parseFormData(projectSchema, formData)
      const project = await db.projects.create({ name, orgId: params.orgId })
      // Use plain string redirect inside app-entry inline actions to avoid circular types
      throw redirect('/orgs/:orgId/projects/:projectId', {
        params: { orgId: params.orgId, projectId: project.id },
      })
    }

    return (
      <form action={createProject}>
        <input name={fields.name} required />
        <button type="submit">Create</button>
      </form>
    )
  })
```

`router.push()`, `router.replace()`, `router.back()`, `router.forward()`, and `router.go()` are still the right choice for pure client-side navigation that doesn't involve a server action (e.g. tab switches, select dropdowns, back buttons). These APIs are all fire-and-forget — do not build awaitable wrappers around navigation commits and then call them inside a React client form action.

### Setting Cookies from Server Actions

Server actions **can set cookies**: pass `headers` as the second argument to `redirect()`. The browser stores the `set-cookie` header from the action response, the router follows the redirect client-side, and loaders re-run with the new cookie — no full page reload needed. Never create a GET route + `window.location.href` full-page navigation just to set a cookie.

```tsx
// src/actions.ts
'use server'

import { redirect } from 'spiceflow'
import { router } from 'spiceflow/react'

export async function switchOrg({ orgId }: { orgId: string }) {
  await assertMembership(orgId)
  throw redirect(router.href('/dashboard'), {
    headers: {
      'set-cookie': `active_org=${orgId}; Path=/; HttpOnly; SameSite=Lax`,
    },
  })
}
```

## Router

Import `router` from `spiceflow/react` for type-safe navigation, URL building, and imperative loader data access. It works in **client components, server components, non-route modules, page handlers, and layout handlers**. Avoid any registered API (`router.href()`, `createSpiceflowFetch()`, etc.) in **return values** of `.loader()`, `.get()`, or `.post()` handlers in the app entry file; JSX, `throw`, and event handlers are always safe. See [when registered APIs cause circular types](#app-entry) for details. `useLoaderData` and `useRouterState` are exported separately from `spiceflow/react`.

`router` is a **stable singleton** — the same object reference every time. It's safe to use in component bodies, pass to hook dependency arrays, or reference at module scope. The reference never changes between renders, so it won't trigger unnecessary re-renders or effect re-runs.

Use `href()` for links so route and query changes are caught by TypeScript.

```tsx
// src/app/nav.tsx
'use client'

import { router, Link } from 'spiceflow/react'

export function Nav() {

  return (
    <nav>
      <Link href={router.href('/')}>Home</Link>
      <Link href={router.href('/about')}>About</Link>
      <Link href={router.href('/users/:id', { id: '1' })}>User 1</Link>
      <Link href={router.href('/search', { q: 'docs', page: 1 })}>Search Docs</Link>
    </nav>
  )
}
```

<Expandable title={<Markdown inline children="Using router in mounted sub-apps" />}>
  `router` sees all routes registered on the root app, regardless of where you import it. Component modules used by mounted sub-apps still see the whole route table — not just the sub-app's own routes:

  ```tsx
  // src/features/billing/billing-page.tsx
  import { router, Link } from 'spiceflow/react'

  export function BillingPage() {
    // router is typed against the WHOLE app, not just billingApp
    return (
      <div>
        <h1>Billing</h1>
        {/* Link to a route defined in a different sub-app */}
        <Link href={router.href('/users/:id', { id: '42' })}>Back to profile</Link>
      </div>
    )
  }
  ```

  No need to thread `app` through props or imports — every import is still fully type-checked against the root app's route table.
</Expandable>

Wildcard routes like `/orgs/:orgId/*` accept **template literals** with interpolated values. TypeScript template literal types ensure only strings matching a registered route pattern are accepted:

```tsx
// Pattern form — pass params as an object
router.href('/orgs/:orgId/*', { orgId: 'acme', '*': 'projects' })
// → "/orgs/acme/projects"

// Template literal form — params already in the string
const orgId = 'acme'
router.href(`/orgs/${orgId}/projects`)
// → "/orgs/acme/projects"

// Works with any depth under the wildcard
const projectId = 'p1'
router.href(`/orgs/${orgId}/projects/${projectId}/settings`)
// → "/orgs/acme/projects/p1/settings"
```

The pattern form gives the strongest type checking — param names, query keys, and route existence are all validated. The template literal form is checked against registered route prefixes, but once values are interpolated TypeScript no longer knows the original param names. Invalid prefixes like `/settings/foo` still error at compile time either way.

`router` works on the server too — use it in server components to build type-safe links without needing the `app` closure:

```tsx
// src/app/org-breadcrumb.tsx (server component — no "use client")
import { router, Link } from 'spiceflow/react'

export async function OrgBreadcrumb({ orgId }: { orgId: string }) {
  return (
    <nav>
      <Link href={router.href('/')}>Home</Link>
      <span> / </span>
      <Link href={router.href(`/orgs/${orgId}/projects`)}>Projects</Link>
    </nav>
  )
}
```

<Expandable title={<Markdown inline children="Always use href() for links" />}>
  Every `Link` href and every programmatic navigation path should go through `href()`. Raw string paths like `<Link href="/users/42">` bypass type checking — if the route is renamed from `/users/:id` to `/profiles/:id`, the raw string silently becomes a 404 while `href('/users/:id', { id: '42' })` immediately fails `tsc`. When a route path changes or gets removed, `tsc` catches every stale `href()` call at compile time.

  This applies to client and server component modules. The `router` import is the same typed singleton everywhere outside loaders and API route handlers.
</Expandable>

## Navigation & State

The `router` object handles type-safe client-side navigation. `router.push`, `router.replace`, and `router.href` accept typed paths with autocomplete — params and query values are validated at compile time:

```tsx
// src/app/search-filters.tsx
'use client'

import { router, useRouterState } from 'spiceflow/react'

export function SearchFilters() {
  const { pathname, searchParams } = useRouterState()

  const query = searchParams.get('q') ?? ''
  const page = Number(searchParams.get('page') ?? '1')
  const sort = searchParams.get('sort') ?? 'relevance'

  function setPage(n: number) {
    router.push({
      search: '?' + new URLSearchParams({ q: query, page: String(n), sort }),
    })
  }

  function setSort(newSort: string) {
    router.push({
      search: '?' + new URLSearchParams({ q: query, page: '1', sort: newSort }),
    })
  }

  return (
    <div>
      <p>
        Showing results for "{query}" — page {page}, sorted by {sort}
      </p>
      <button onClick={() => setSort('date')}>Sort by Date</button>
      <button onClick={() => setPage(page + 1)}>Next Page</button>
    </div>
  )
}
```

`useRouterState()` subscribes to navigation changes and re-renders the component when the URL changes. It returns the current `pathname`, `search`, `hash`, and a parsed `searchParams` (a read-only `URLSearchParams`).

You can also navigate to a different pathname with search params, or use `router.replace` to update without adding a history entry:

```tsx
import { router } from 'spiceflow/react'

function Example() {

  // Navigate to a new path with search params
  router.push({
    pathname: '/search',
    search: '?' + new URLSearchParams({ q: 'spiceflow' }),
  })

  // Replace current history entry (back button skips this)
  router.replace({
    search: '?' + new URLSearchParams({ tab: 'settings' }),
  })

  // Or just use a plain string
  router.push('/search?q=spiceflow&page=1')
}
```

<Expandable title={<Markdown inline children="Navigation methods are fire-and-forget" />}>
  `router.push()`, `router.replace()`, `router.back()`, `router.forward()`, and `router.go()` schedule navigation and return immediately. Do not wrap them in helpers that wait for the next navigation commit and then call those helpers from a React client form action — React keeps the form action transition pending until the action returns, so awaiting that same commit can deadlock the page.
</Expandable>

## Redirects and Not Found

**Always `throw redirect(...)`, never `return redirect(...)`.** Both work at runtime, but `throw` is safer for TypeScript: it prevents the redirect from contributing to the handler's inferred return type, which avoids circular TS7022 errors when using `SpiceflowRegister`. It also short-circuits the handler immediately, making control flow explicit.

**Every app must render a 404 page** that explains the page was not found. The built-in `DefaultNotFoundPage` is unstyled. When no page matches, layout `children` is `null`. Handle that in the **root** `/*` layout: `LayoutContent` renders the first layout, so if that layout returns `{children}` and children is null, the page is blank white.

<Expandable title={<Markdown inline children="Do not add a catch-all <code>.page('/\\*')</code> next to API routes" />}>
  A wildcard **page** matches every GET, including mounted API routes (`/api/v2/*`) and aliases like `/signup`. Those requests then 404 as HTML instead of reaching the real handler.

  Put the not-found UI in the root layout instead. Set `response.status = 404` when `children == null`.
</Expandable>

Use the handler context `redirect` and `response.status` inside `.page()` and `.layout()` handlers to control navigation and HTTP status codes:

```tsx
import { Spiceflow } from 'spiceflow'

function NotFound({ path }: { path?: string }) {
  return (
    <main>
      <h1>Page not found</h1>
      <p>{path ? `The page ${path} was not found.` : 'This page was not found.'}</p>
    </main>
  )
}

export const app = new Spiceflow()
  .page('/login', async () => <Login />)
  .layout('/*', async ({ children, request, response }) => {
    if (children == null) response.status = 404
    return (
      <AppLayout>
        {children ?? <NotFound path={request.parsedUrl.pathname} />}
      </AppLayout>
    )
  })
  .page('/dashboard', async ({ request, redirect }) => {
    const user = await getUser(request)
    if (!user) {
      throw redirect('/login')
    }
    return <Dashboard user={user} />
  })
  .page('/posts/:id', async ({ params, response }) => {
    const post = await getPost(params.id)
    if (!post) {
      response.status = 404
      return <NotFound path={`/posts/${params.id}`} />
    }
    return <Post post={post} />
  })
  // Layouts can throw redirect — useful for auth guards that protect
  // an entire section of your app
  .layout('/admin/*', async ({ children, request, redirect }) => {
    const user = await getUser(request)
    if (!user?.isAdmin) {
      throw redirect('/login')
    }
    return <AdminLayout>{children}</AdminLayout>
  })

export type App = typeof app
```

Context `redirect()` accepts a plain string URL plus an optional second argument for custom status codes and headers. It is intentionally not type-safe against the route table, so it does not pull `typeof app` back into handler context inference:

```tsx
// 301 permanent redirect
.page('/old-login', async ({ redirect }) => {
  throw redirect('/login', { status: 301 })
})

// Redirect with custom headers
.page('/logout', async ({ redirect }) => {
  throw redirect('/login', {
    headers: { 'set-cookie': 'session=; Max-Age=0' },
  })
})
```

<Expandable title={<Markdown inline children="Response status, headers, and HTTP behavior" />}>
  **`response.status` and `response.headers`** — every page and layout handler receives a mutable `response` object on the context. Set `response.status` to control the HTTP status code (defaults to 200). Set `response.headers` to add custom headers like `cache-control` or `set-cookie`.

  **Correct HTTP status codes.** Unlike Next.js, where redirects always return a 200 status with client-side handling, Spiceflow returns the actual HTTP status code in the response — `307` for redirects (with a `Location` header) and whatever you set via `response.status` for pages. This works even when the throw happens after an `await`, because the SSR layer intercepts the error from the RSC stream before flushing the HTML response. Search engines see correct status codes, and `fetch()` calls with `redirect: "manual"` get the real `307` response.

  **Client-side navigation.** When a user clicks a `<Link>` that navigates to a page throwing context `redirect()`, the router performs the redirect client-side without a full page reload.
</Expandable>

<Expandable title={<Markdown inline children="Authentication: pages vs API routes" />}>
  Pages and layouts should always `throw redirect('/login')` from handler context when the user is not authenticated. API routes (`.get()`, `.post()`, etc.) should return a JSON error with a 401 status instead. This keeps the experience clean: users visiting a protected page get redirected to login instead of seeing a raw JSON blob, while API consumers get a proper typed error response they can handle programmatically.

  ```tsx
  // Page — redirect to login
  .page('/dashboard', async ({ request, redirect }) => {
    const user = await getUser(request)
    if (!user) throw redirect('/login')
    return <Dashboard user={user} />
  })

  // Layout — redirect to login (protects all nested pages)
  .layout('/app/*', async ({ children, request, redirect }) => {
    const user = await getUser(request)
    if (!user) throw redirect('/login')
    return <AppLayout>{children}</AppLayout>
  })

  // API route — return JSON 401
  .get('/api/profile', async ({ request }) => {
    const user = await getUser(request)
    if (!user) return json({ message: 'Not authenticated' }, { status: 401 })
    return json({ user })
  })

  // Middleware — protect all routes in a sub-app with JSON 401
  const api = new Spiceflow()
    .use(async ({ request }) => {
      const user = await getUser(request)
      if (!user) return json({ message: 'Not authenticated' }, { status: 401 })
    })
    .get('/profile', async ({ request }) => {
      const user = await getUser(request)
      return json({ user })
    })

  app.use(api, { prefix: '/api' })
  ```
</Expandable>


---
title: "Loaders, forms, actions, and error boundaries"
url: "https://getspiceflow.com/react-data.md"
description: "Share route data with loaders, stream promises with use(), submit React 19 forms, call server actions, and catch errors with ErrorBoundary."
---

# Loaders, forms, actions, and error boundaries

## Loaders

Loaders run on the server before page and layout handlers. They solve a common problem: when you need the same data in both server components and client components, or in both a layout and a page, without prop drilling or React context.

Prefer putting route data in loaders when that data is shared by more than one part of the route tree. This keeps fetching in one place, avoids fetching the same data once in a layout and again in a page, and lets React components read the current route data directly instead of receiving long prop chains.

**Use loaders instead of passing route data through props.** Props are still great for local UI state, callbacks, and reusable primitives like `<Button variant="ghost" />`, but route data should usually come from `useLoaderData()`. This avoids prop drilling, keeps components movable, and stays type safe because the hook is inferred from the route loader path.

Split data by route ownership instead of making one parent loader parse every URL. Put data that every dashboard page needs in `/dashboard/*`, data that every project page needs in `/dashboard/projects/:projectId/*`, and page-only data next to the page route. Components can call `useLoaderData()` multiple times when they need data from multiple loader levels.

Calling `useLoaderData()` is cheap. It reads the already-loaded request data from React context, so different components and different React trees can call it directly without causing extra loader executions or extra network requests. Do not centralize loader data into a custom props object just to pass it back down through the tree. That pattern repeats the loader return type by hand and can drift out of sync. Prefer calling `useLoaderData('/route/pattern')` where the data is needed, so TypeScript keeps the component shape linked to the real loader return value.

If a parent layout rarely needs to know a child route param, use the web-standard `URLPattern` API instead of a hand-written regex. This is most useful for parent chrome that must sit above several child layouts, like tabs that span both the sidebar and content frame. Prefer splitting layouts and loaders cleanly so you do not need this pattern in normal route trees.

```tsx
export const app = new Spiceflow()
  .loader('/dashboard/*', async ({ request }) => {
    const projectId = new URLPattern({ pathname: '/dashboard/projects/:projectId/*' })
      .exec(request.url)?.pathname.groups.projectId ?? null

    const user = await getUser(request)

    return { user, projectId }
  })
  .layout('/dashboard/*', async ({ loaderData, children }) => {
    return (
      <DashboardShell>
        {loaderData.projectId && <ProjectTabs projectId={loaderData.projectId} />}
        {children}
      </DashboardShell>
    )
  })
```

Loaders only run for requests that also match a `.page()` or `.layout()`. They are not standalone endpoints. If you want to serve content without rendering a page or layout, use `.get()`, `.route()`, or another API handler instead.

```tsx
export const app = new Spiceflow()
  .page('/login', async () => <Login />)
  // Auth loader for the dashboard route
  .loader('/dashboard', async ({ request, redirect }) => {
    const user = await getUser(request.headers.get('cookie'))
    if (!user) throw redirect('/login')
    return { user }
  })
  // Page-specific loader
  .loader('/dashboard', async () => {
    const stats = await getStats()
    return { stats }
  })
  .layout('/dashboard', async ({ loaderData, children }) => {
    // loaderData.user is available here from the dashboard loader
    return (
      <html>
        <body>
          <nav>{loaderData.user.name}</nav>
          {children}
        </body>
      </html>
    )
  })
  .page('/dashboard', async ({ loaderData }) => {
    // Both loaders matched, data is merged by specificity
    // loaderData = { user: ..., stats: ... }
    return <Dashboard />
  })
```

A Remix-style dashboard can put shared shell data in a parent loader, project data in a nested loader, then page data in loaders placed next to each page. The layout, page, and client components all read from the same request-scoped loader data without threading props through every layer:

```tsx
export const app = new Spiceflow()
  .loader('/dashboard/*', async ({ request }) => {
    const user = await getUser(request)
    const projects = await getProjects(user.id)
    return { user, projects }
  })
  .layout('/dashboard/*', async ({ children }) => {
    return <DashboardShell>{children}</DashboardShell>
  })
  .loader('/dashboard/projects/:projectId/*', async ({ params }) => {
    const project = await getProject(params.projectId)
    const environments = await getProjectEnvironments(params.projectId)
    return { project, environments }
  })
  .loader('/dashboard/projects/:projectId/secrets', async ({ params }) => {
    const secrets = await getSecrets(params.projectId)
    return { secrets }
  })
  .page('/dashboard/projects/:projectId/secrets', async () => {
    return <SecretsPage />
  })

declare module 'spiceflow/react' {
  interface SpiceflowRegister { app: typeof app }
}
```

```tsx
// src/app/dashboard-shell.tsx
'use client'

import { useLoaderData } from 'spiceflow/react'
import type { ReactNode } from 'react'

export function DashboardShell({ children }: { children: ReactNode }) {
  const { user, projects } = useLoaderData('/dashboard/*')
  return (
    <div>
      <aside>
        <p>{user.name}</p>
        {projects.map((project) => <a key={project.id} href={project.href}>{project.name}</a>)}
      </aside>
      <main>{children}</main>
    </div>
  )
}
```

```tsx
// src/app/secrets-page.tsx
'use client'

import { useLoaderData } from 'spiceflow/react'

export function SecretsPage() {
  const { project, environments } = useLoaderData('/dashboard/projects/:projectId/*')
  const { secrets } = useLoaderData('/dashboard/projects/:projectId/secrets')

  return (
    <section>
      <h1>{project.name}</h1>
      <p>{environments.length} environments</p>
      <SecretsTable />
    </section>
  )
}

export function SecretsTable() {
  const { secrets } = useLoaderData('/dashboard/projects/:projectId/secrets')
  return secrets.map((secret) => <div key={secret.id}>{secret.name}</div>)
}
```

Prefer this over prop drilling route data through every component:

```tsx
// Avoid this for route data. It grows brittle as pages get deeper.
export function SecretsPage({ project, environments, secrets }) {
  return <SecretsTable secrets={secrets} />
}
```

The loader version is type safe end to end. If you rename `secrets` to `secretRows` in the loader, every `useLoaderData('/dashboard/projects/:projectId/secrets')` call that still reads `secrets` becomes a TypeScript error.

```tsx
// More specific loaders can still read merged data when that is simpler.
export function ProjectHeader() {
  const { user, project } = useLoaderData('/dashboard/projects/:projectId/*')
  return <h1>{project.name} for {user.name}</h1>
}
```

When you need only one loader level, read only that level:

```tsx
export function ProjectSwitcher() {
  const { projects } = useLoaderData('/dashboard/*')
  return projects.map((project) => <a href={project.href}>{project.name}</a>)
}
```

```tsx
// Server routes can still read loaderData directly when rendering simple markup.
export const app = new Spiceflow()
  .loader('/account', async ({ request }) => {
    const user = await getUser(request)
    return { user }
  })
  .page('/account', async ({ loaderData }) => {
    return <h1>{loaderData.user.name}</h1>
  })
```

Loaders are a route-data boundary, not a replacement for every prop. Keep props for local state and component options. Use loaders for server data tied to the current route.

When multiple loaders match a route (e.g. `/*` and `/dashboard` both match `/dashboard`), their return values are merged into a single flat object. More specific loaders override less specific ones on key conflicts.

Loader data is type safe when the app is registered globally with `SpiceflowRegister`. `useLoaderData('/dashboard/projects/:id')` and `router.getLoaderData('/dashboard/projects/:id')` infer the merged object returned by every matching loader, so renaming a loader field or removing it becomes a TypeScript error in every component that reads it.

**Serialization**: loader return values are serialized through the React RSC flight format, not JSON. You can return JSX (including server components and client component elements with their props), `Promise`, async iterators, `Map`, `Set`, `Date`, `BigInt`, typed arrays, and any client component reference — all deserialized faithfully on the client. This means a loader can return a fully rendered `<Sidebar user={user} />` element and another component can receive it as `loaderData.sidebar` and drop it into the tree.

**Reading loader data in client components** uses the `useLoaderData` hook from `spiceflow/react`:

```tsx
// src/app/sidebar.tsx
'use client'

import { useLoaderData } from 'spiceflow/react'

export function Sidebar() {
  // Type-safe: path narrows the return type to the loaders matching '/dashboard'
  const { user, stats } = useLoaderData('/dashboard')
  return (
    <aside>
      {user.name} — {stats.totalViews} views
    </aside>
  )
}
```

Loader data updates automatically on client-side navigation — when the user navigates to a new route, the server re-runs the matching loaders and the new data arrives atomically with the new page content via the RSC flight stream.

**Reading loader data imperatively** uses the `router` import. This works in client code outside React components and during active server render. Call it inside component scope, event handlers, or helper functions tied to the current render flow instead of binding request-sensitive access at module scope:

```tsx
// src/app/editor-toolbar.tsx
'use client'

import { router, useLoaderData } from 'spiceflow/react'

async function readCurrentDocument() {
  return router.getLoaderData('/editor/:id')
}

export function EditorToolbar() {
  const { document } = useLoaderData('/editor/:id')

  async function refresh() {
    const next = await readCurrentDocument()
    console.log(next.document.title)
  }

  return <button onClick={refresh}>{document.title}</button>
}
```

## Streaming with `use()`

Loaders can return an unawaited `Promise` to start a slow fetch without blocking the page render. The promise travels through the RSC flight stream to the client, where a `'use client'` component calls `use(promise)` inside a `<Suspense>` boundary. The page HTML is sent immediately with the fallback; the real content streams in once the promise settles.

```tsx
// src/main.tsx
import { Suspense } from 'react'
import { HeavyStats } from './app/heavy-stats'

app
  .loader('/dashboard', async ({ request }) => {
    const user = await getUser(request)         // fast, awaited — blocks nothing
    const statsPromise = getExpensiveStats()    // slow, NOT awaited — streams later
    return { user, statsPromise }
  })
  .page('/dashboard', async ({ loaderData }) => {
    return (
      <div>
        <h1>Welcome {loaderData.user.name}</h1>
        <Suspense fallback={<p>Loading stats…</p>}>
          <HeavyStats statsPromise={loaderData.statsPromise} />
        </Suspense>
      </div>
    )
  })
```

```tsx
// src/app/heavy-stats.tsx
'use client'

import { use } from 'react'

export function HeavyStats({ statsPromise }: { statsPromise: Promise<Stats> }) {
  const stats = use(statsPromise)   // suspends here until the promise resolves
  return <div>{stats.totalViews} views</div>
}
```

The mechanics: the loader finishes immediately after `getUser()`, so the page server component renders and the RSC flight stream starts. The `statsPromise` is serialized into the stream as a pending promise reference. On the client, `use(statsPromise)` suspends `HeavyStats` and React shows the `<Suspense>` fallback. When the promise resolves on the server, the result is flushed into the same flight stream and React replaces the fallback with the real component — no extra HTTP round-trip.

This is faster than `await getExpensiveStats()` in the loader because the page skeleton reaches the browser immediately instead of waiting for the slow fetch to finish before any HTML is sent.

**Error handling**: if a loader throws a `redirect()` or `notFound()`, the entire request short-circuits — the page handler never runs. If a loader throws any other error, it renders through the nearest error boundary instead of showing a blank page.

## Parallel Data Fetching

Spiceflow already parallelizes at the framework level — all matched loaders run concurrently, then layouts and the page render concurrently after loaders finish. Within a single handler, use `Promise.all` for independent fetches instead of sequential `await`s:

```tsx
.page('/dashboard', async () => {
  const [user, posts, analytics] = await Promise.all([
    getUser(),
    getPosts(),
    getStats(),
  ])
  return <Dashboard user={user} posts={posts} analytics={analytics} />
})
```

## Forms & Server Actions

Forms use React 19's `<form action>` with server functions marked `"use server"`. They work before JavaScript loads (progressive enhancement).

Forms also support normal browser submissions when `action` is a string URL. This is standard HTML behavior in Spiceflow: the browser submits the form to the URL and performs a full document navigation.

```tsx
.page('/search', async () => {
  return (
    <form method="get" action="/results">
      <input name="q" />
      <button type="submit">Search</button>
    </form>
  )
})
```

Prefer a server or client action when the form should feel app-like. Passing a function to `action` lets React handle submission in a transition instead of doing a full browser reload. A server action can mutate data, then automatically re-render the current page with fresh server data or throw the handler context `redirect` to navigate. A client action can update local state, call APIs, or schedule a client navigation with `router.push()` / `router.replace()`.

```tsx
<form action={saveSettings}>
  <input name="name" />
  <Button type="submit">Save</Button>
</form>
```

**Successful server actions re-run matching loaders and reconcile the current page.** Do not call `router.refresh()` afterward. Use it only when data changes outside a server action.

Every submit button should show a loading state while its form action is in progress. Use `useFormStatus` from `react-dom` in your Button component to auto-detect pending forms — the button shows a spinner automatically when it's inside a `<form>` with a pending action:

Prefer file-level `"use server"` (a dedicated file like `src/actions.tsx`) over inline `"use server"` inside function bodies. Inline is fine for simple form actions defined directly in a server component page, or when the action needs the handler context `redirect`. If you find yourself passing actions as props to client components, import them from a `"use server"` file instead — it keeps action logic centralized and reusable.

```tsx
// src/app/button.tsx
'use client'
import { useFormStatus } from 'react-dom'

export function Button({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  const { pending } = useFormStatus()
  const loading = props.type === 'submit' && pending
  return (
    <button disabled={loading} {...props}>
      {loading ? 'Loading...' : children}
    </button>
  )
}
```

Then use it in forms — no manual loading state needed. Use `parseFormData` to validate form fields with a Zod schema, and `schema.keyof().enum` for type-safe input `name` attributes (typos become compile errors):

```tsx
import { z } from 'zod'
import { parseFormData } from 'spiceflow'
import { Button } from './app/button'

const subscribeSchema = z.object({ email: z.string().email() })
const fields = subscribeSchema.keyof().enum

.page('/thank-you', async () => <ThankYou />)
.page('/subscribe', async ({ redirect }) => {
  async function subscribe(formData: FormData) {
    'use server'
    const { email } = parseFormData(subscribeSchema, formData)
    await addSubscriber(email)
    throw redirect('/thank-you')
  }
  return (
    <form action={subscribe}>
      <input name={fields.email} type="email" required />
      <Button type="submit">Subscribe</Button>
    </form>
  )
})
```

Use `useActionState` to display return values from the action. The action receives the previous state as its first argument and `FormData` as the second:

```tsx
// src/actions.tsx
'use server'

import { z } from 'zod'
import { parseFormData } from 'spiceflow'

export const subscribeSchema = z.object({ email: z.string().email() })

export async function subscribe(prev: string, formData: FormData) {
  const { email } = parseFormData(subscribeSchema, formData)
  await addSubscriber(email)
  return `Subscribed ${email}!`
}
```

```tsx
// src/app/newsletter.tsx
'use client'
import { useActionState } from 'react'
import { Button } from './button'
import { subscribeSchema } from '../actions'

const fields = subscribeSchema.keyof().enum

export function NewsletterForm({
  action,
}: {
  action: (prev: string, formData: FormData) => Promise<string>
}) {
  const [message, formAction] = useActionState(action, '')
  return (
    <form action={formAction}>
      <input name={fields.email} type="email" required />
      <Button type="submit">Subscribe</Button>
      {message && <p>{message}</p>}
    </form>
  )
}
```

```tsx
// In your server component page
.page('/newsletter', async () => {
  return <NewsletterForm action={subscribe} />
})
```

Server actions called directly from client event handlers also trigger the same automatic re-render:

```tsx
// src/actions.ts
'use server'

export async function deletePost(id: string) {
  await db.posts.delete(id)
}
```

```tsx
// src/app/delete-button.tsx
'use client'

import { deletePost } from '../actions'

export function DeleteButton({ id }: { id: string }) {
  return (
    <button
      onClick={async () => {
        await deletePost(id)
        // page re-renders automatically — no router.refresh() needed
      }}
    >
      Delete
    </button>
  )
}
```

### Action Buttons Without FormData

When a server action takes **no arguments** (or only typed arguments, not `FormData`), skip the `<form>` wrapper entirely. Use `useTransition` for pending state instead of `useFormStatus` (which requires a parent `<form>`):

```tsx
// src/app/checkout-button.tsx
'use client'

import { useTransition } from 'react'
import { startCheckout } from '../actions'

export function CheckoutButton() {
  const [pending, startTransition] = useTransition()
  return (
    <button
      type="button"
      disabled={pending}
      onClick={() => startTransition(() => startCheckout())}
    >
      {pending ? 'Redirecting...' : 'Subscribe'}
    </button>
  )
}
```

This is simpler than wrapping in a `<form action={startCheckout}>` with `useFormStatus()`, and avoids layout issues caused by the extra `<form>` element. Use `<form>` only when you actually need `FormData` (input fields) or progressive enhancement (works before JS loads).

<Expandable title={<Markdown inline children="Avoid deadlocks in client form actions" />}>
  `router.refresh()` is fire-and-forget. Do not build awaitable navigation or refresh helpers and then use them inside a React client form action (`<form action={async () => { ... }}>`). React keeps that form action transition pending until the action returns, so awaiting the refresh or navigation commit from inside the action can deadlock the page.

  Server actions already return a fresh RSC payload, so do not refresh after them.
</Expandable>

## Server Actions

Use `"use server"` to define functions that run on the server but can be called from client components (e.g. form actions).

```tsx
// src/app/actions.tsx
'use server'

import { z } from 'zod'
import { getActionRequest, parseFormData } from 'spiceflow'

export const contactSchema = z.object({ name: z.string().min(1) })

export async function submitForm(formData: FormData) {
  const { signal } = getActionRequest()
  const { name } = parseFormData(contactSchema, formData)
  // signal is aborted when the client disconnects or cancels —
  // pass it to any downstream work so it cancels automatically
  await saveToDatabase(name, { signal })
}
```

On the client, `getActionAbortController()` returns the `AbortController` for the most recent in-flight call to a server action, or `undefined` if nothing is in-flight. Call `.abort()` to cancel the fetch.

**Server actions are public POST endpoints.** Any HTTP client can call them — not just your own browser. CSRF protection (Origin header check) prevents cross-site form submissions, but it does not authenticate the caller. If a server action mutates data, creates resources, or does anything user-specific, it must authenticate and authorize the request explicitly. The same rule applies to all API routes (`.get()`, `.post()`, etc.) and any middleware that modifies state. See the [Security guide](/security) for patterns.

Server actions include CSRF protection. The `Origin` header of POST requests is checked against the app's origin. This check is **disabled in development** (when `vite dev` is running) so tunnels and proxies work without issues. In production, the origin check works automatically on any hosting platform (Cloudflare Workers, Node.js, Vercel, etc.) because the browser's `Origin` header matches the server's URL.

<Expandable title={<Markdown inline children="allowedActionOrigins (rare, only for reverse proxies)" />}>
  If you use a reverse proxy that rewrites the request URL before it reaches your app (so `request.url` differs from the browser's origin), server actions return `403 Forbidden: origin mismatch`. Use `allowedActionOrigins` to allow additional origins:

  ```tsx
  const app = new Spiceflow({
    allowedActionOrigins: [
      'https://my-app.example.com',
      /\.my-proxy\.dev$/,
    ],
  })
  ```

  Each entry can be an exact origin string or a `RegExp` tested against the request's `Origin` header. You do **not** need this on Cloudflare Workers, Vercel, Fly.io, or any platform where the request URL already matches your domain.
</Expandable>

## Streaming UI from Server Actions

Server actions can return JSX directly — including via async generators that stream React elements to the client incrementally. The RSC flight protocol serializes each yielded element as it arrives, and the client deserializes them into real React elements you can render.

This is useful for AI chat interfaces where the model generates structured output with tool calls. Instead of streaming raw text, you stream rendered UI:

```tsx
// src/app/actions.tsx
'use server'

import { getActionRequest } from 'spiceflow'
import { WeatherCard } from './weather-card'
import { StockChart } from './stock-chart'

export async function* chat(
  messages: { role: string; content: string }[],
): AsyncGenerator<React.ReactElement> {
  // Pass the request signal to downstream work so the LLM call
  // is cancelled when the client aborts (e.g. clicks "Stop")
  const { signal } = getActionRequest()
  const stream = await callLLM(messages, { signal })

  for await (const event of stream) {
    if (event.type === 'text') {
      yield <p>{event.content}</p>
    }
    if (event.type === 'tool_call' && event.name === 'get_weather') {
      const weather = await fetchWeather(event.args.city)
      yield <WeatherCard city={event.args.city} weather={weather} />
    }
    if (event.type === 'tool_call' && event.name === 'get_stock') {
      const data = await fetchStock(event.args.symbol)
      yield <StockChart symbol={event.args.symbol} data={data} />
    }
  }
}
```

```tsx
// src/app/chat.tsx
'use client'

import { z } from 'zod'
import { useState, useTransition, type ReactNode } from 'react'
import { getActionAbortController } from 'spiceflow/react'
import { parseFormData } from 'spiceflow'
import { chat } from './actions'

const chatSchema = z.object({ message: z.string().min(1) })
const fields = chatSchema.keyof().enum

export function Chat() {
  const [parts, setParts] = useState<ReactNode[]>([])
  const [isPending, startTransition] = useTransition()

  function send(formData: FormData) {
    const { message } = parseFormData(chatSchema, formData)
    setParts([])
    startTransition(async () => {
      const stream = await chat([{ role: 'user', content: message }])
      for await (const jsx of stream) {
        setParts((prev) => [...prev, jsx])
      }
    })
  }

  return (
    <div>
      <div>{parts.map((part, i) => <div key={i}>{part}</div>)}</div>
      <form action={send}>
        <input name={fields.message} placeholder="Ask something..." />
        <button type="submit" disabled={isPending}>Send</button>
        {isPending && (
          <button type="button" onClick={() => getActionAbortController(chat)?.abort()}>
            Stop
          </button>
        )}
      </form>
    </div>
  )
}
```

Each yielded element — whether a text paragraph, a weather card, or a stock chart — arrives as a fully rendered React component. The client doesn't need to know how to render tool calls; it just accumulates whatever JSX the server sends.

## Error Handling with ErrorBoundary

If a server action throws, the error is caught by the nearest `ErrorBoundary`. The error message is preserved (sanitized to strip secrets) and displayed to the user in both development and production builds.

Use `ErrorBoundary` from `spiceflow/react` to catch errors from form actions. It provides `ErrorBoundary.ErrorMessage` and `ErrorBoundary.ResetButton` sub-components that read the error and reset function from context — so they work as standalone elements anywhere in the `fallback` tree.

Actions should **throw errors** instead of returning error strings. Return **objects** for rich success data instead of scalars. Use `parseFormData` for validation — it throws a `ValidationError` when the schema fails, which `ErrorBoundary` catches automatically:

```tsx
// src/actions.ts
'use server'

import { z } from 'zod'
import { parseFormData } from 'spiceflow'

export const postSchema = z.object({ title: z.string().min(1, 'Title is required') })

export async function createPost(formData: FormData) {
  const { title } = parseFormData(postSchema, formData)
  const post = await db.posts.create({ title })
  return { id: post.id }
}
```

```tsx
// src/app/create-post.tsx
'use client'

import { ErrorBoundary } from 'spiceflow/react'
import { createPost, postSchema } from '../actions'

const fields = postSchema.keyof().enum

export function CreatePostForm() {
  return (
    <ErrorBoundary
      fallback={
        <div>
          <ErrorBoundary.ErrorMessage className="text-red-500" />
          <ErrorBoundary.ResetButton>Try again</ErrorBoundary.ResetButton>
        </div>
      }
    >
      <form action={createPost}>
        <input name={fields.title} required />
        <Button type="submit">Create</Button>
      </form>
    </ErrorBoundary>
  )
}
```

`ErrorBoundary.ErrorMessage` renders a `<div>` with `white-space: pre-wrap` and `ErrorBoundary.ResetButton` renders a `<button>`. Both accept all their respective HTML element props via `...props` spread, so you can pass `className`, `style`, `data-testid`, etc. Long error messages are **truncated** to 10 lines by default with a "Show more" toggle. Override with `<ErrorBoundary.ErrorMessage maxLines={5} />`.

**ErrorBoundary catches errors from three sources:**

| Source                                            | Where it runs | ErrorBoundary catches?                             |
| ------------------------------------------------- | ------------- | -------------------------------------------------- |
| Server action throws                              | Server        | Yes                                                |
| `parseFormData` in a server action (no try/catch) | Server        | Yes (`ValidationError` propagates)                 |
| `parseFormData` in a client form action           | Browser       | Yes (thrown inside React's form action transition) |

When the form action throws, the `ErrorBoundary` catches the error, hides the form, and renders the `fallback` with the error message and a reset button. Clicking "Try again" restores the form. The error boundary also auto-resets when the user navigates to a different page.

The recommended pattern is to run `parseFormData` **client-side** inside the form action, then call the server action with the validated data. This gives instant validation feedback without a server round-trip, and the `ValidationError` is still caught by `ErrorBoundary`:

```tsx
// src/app/create-contact.tsx
'use client'

import { parseFormData } from 'spiceflow'
import { ErrorBoundary } from 'spiceflow/react'
import { contactSchema } from '../schemas'
import { createContact } from '../actions'

const fields = contactSchema.keyof().enum

export function CreateContactForm() {
  return (
    <ErrorBoundary fallback={...}>
      <form action={async (formData: FormData) => {
        const data = parseFormData(contactSchema, formData) // client-side validation
        await createContact(data)                           // server action
      }}>
        <input name={fields.name} />
        <input name={fields.email} type="email" />
        <Button type="submit">Create</Button>
      </form>
    </ErrorBoundary>
  )
}
```

### Error Position: `above` and `below`

By default, `ErrorBoundary` **replaces** the form with the fallback when an error occurs. This causes layout shift and the user loses sight of their filled inputs. Use `above` or `below` to keep the form visible and interactive alongside the error message:

```tsx
<ErrorBoundary below fallback={
  <div className="text-red-500">
    <ErrorBoundary.ErrorMessage />
    <ErrorBoundary.ResetButton>Dismiss</ErrorBoundary.ResetButton>
  </div>
}>
  <form action={submitForm}>
    <input name={fields.name} />
    <Button type="submit">Save</Button>
  </form>
</ErrorBoundary>
```

`below` puts the error **below** the form. `above` puts it **above**. The form stays fully interactive; the user can fix their inputs and resubmit directly without clicking reset first. This works because form action errors don't invalidate the children's render tree; the error comes from the action, not from rendering.

For **direct action calls** (onClick handlers, not forms), use try/catch since the error doesn't propagate through React's rendering. Wrap in `startTransition` if you want pending state (`isPending`) and non-blocking behavior while the server data loads:

```tsx
import { useTransition } from 'react'

function DeleteButton({ id }: { id: string }) {
  const [isPending, startTransition] = useTransition()
  return (
    <button
      disabled={isPending}
      onClick={() => {
        startTransition(async () => {
          try {
            await deletePost({ id })
          } catch (e) {
            alert(e.message)
          }
        })
      }}
    >
      {isPending ? 'Deleting...' : 'Delete'}
    </button>
  )
}
```


---
title: "Test routes, pages, and server actions"
url: "https://getspiceflow.com/testing.md"
description: "Call app.handle() from Vitest to unit-test Spiceflow routes, pages, server actions, and middleware. No browser, no build, sub-second feedback."
---

# Test routes, pages, and server actions

Test your spiceflow app directly with vitest. Call `app.handle()` on page and API routes, call server actions as plain functions, and assert on responses. No browser, no build, sub-second feedback.

## Setup

Add the spiceflow vite plugin to your vitest config. The plugin auto-detects vitest and configures everything.

```ts
// vite.config.ts
import { defineConfig } from 'vite'
import spiceflow from 'spiceflow/vite'

export default defineConfig({
  plugins: [spiceflow({ entry: './src/main.tsx' })],
})
```

No extra `resolve.conditions` or environment config needed.

## Testing API Routes

Use `createSpiceflowFetch(app)` for type-safe API testing. It calls `app.handle()` internally and parses responses.

```ts
import { test, expect } from 'vitest'
import { createSpiceflowFetch } from 'spiceflow/client'
import { app } from './main.js'

const f = createSpiceflowFetch(app)

test('GET /api/projects returns json', async () => {
  const result = await f('/api/projects')
  expect(result).toMatchInlineSnapshot(`
    {
      "projects": [],
    }
  `)
})

test('GET /api/projects/:id with params', async () => {
  const result = await f('/api/projects/:id', { params: { id: '42' } })
  expect(result).toMatchInlineSnapshot(`
    {
      "id": "42",
      "name": "My Project",
    }
  `)
})
```

Paths and params are fully typed. Invalid paths or missing required params are compile errors.

## Testing Page Routes

Page routes also work with `createSpiceflowFetch`. They return a `SpiceflowTestResponse` with the rendered JSX.

```ts
import { test, expect } from 'vitest'
import { createSpiceflowFetch } from 'spiceflow/client'
import { SpiceflowTestResponse } from 'spiceflow/testing'
import { app } from './main.js'

const f = createSpiceflowFetch(app)

test('GET /dashboard renders page with layout', async () => {
  const res = await f('/dashboard')
  if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')
  expect(res.status).toBe(200)

  // Full HTML with layouts
  expect(await res.text()).toContain('Dashboard')

  // Page-only HTML (no layout wrapper)
  expect(await res.text(res.page)).toContain('<h1>Dashboard</h1>')

  // Loader data
  expect(res.loaderData).toEqual({ projects: [] })
})

test('page with params', async () => {
  const res = await f('/users/:id', { params: { id: '42' } })
  if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')
  expect(await res.text()).toContain('User 42')
})
```

**`res.text()`** renders the full composed page (layouts wrapping the page) to HTML. Client components that use `useLoaderData` get the correct data.

**`res.text(node)`** renders a specific node. Pass `res.page` for the page only, or `res.layouts[0].element` for a single layout.

**`res.page`** gives raw JSX for vitest inline snapshots without rendering.

## Testing with Authentication

Declare two fetch clients at the top of your test file. One unauthenticated, one with a Bearer token.

```ts
import { test, expect } from 'vitest'
import { createSpiceflowFetch } from 'spiceflow/client'
import { SpiceflowTestResponse } from 'spiceflow/testing'
import { app } from './main.js'

const f = createSpiceflowFetch(app)
const authed = createSpiceflowFetch(app, {
  headers: { authorization: 'Bearer test-token' },
})

test('unauthenticated request returns error', async () => {
  const result = await f('/admin')
  expect(result).toBeInstanceOf(Error)
})

test('authenticated request renders admin page', async () => {
  const res = await authed('/admin')
  if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')
  expect(await res.text()).toContain('Admin Panel')
})

test('authenticated API call returns user data', async () => {
  const result = await authed('/api/me')
  expect(result).toMatchInlineSnapshot(`
    {
      "user": "tommy",
      "token": "test-token",
    }
  `)
})

test('unauthenticated API call returns error', async () => {
  const result = await f('/api/me')
  expect(result).toBeInstanceOf(Error)
})
```

Middleware runs before page handlers, just like in production.

## Server Actions

Server actions in `"use server"` files become plain functions in vitest (the directive is stripped automatically). Call them directly.

```ts
import { test, expect } from 'vitest'
import { runAction } from 'spiceflow/testing'
import { createProject, deleteProject } from './actions.js'

test('createProject returns the new project', async () => {
  const project = await createProject('My App')
  expect(project).toMatchInlineSnapshot(`
    {
      "id": "1",
      "name": "My App",
    }
  `)
})
```

Actions that call `getActionRequest()` need the `runAction` wrapper to provide request context.

```ts
test('action reads request headers', async () => {
  const result = await runAction(() => headerAwareAction(), {
    request: new Request('http://localhost', {
      method: 'POST',
      headers: { authorization: 'Bearer admin-token' },
    }),
  })
  expect(result.auth).toBe('Bearer admin-token')
})
```

Actions that call `redirect()` throw a Response. Use `.catch()` to capture it.

```ts
test('action redirects after mutation', async () => {
  const error = await runAction(() => submitForm()).catch((e) => e)
  if (!(error instanceof Response)) throw new Error('expected redirect')
  expect(error.status).toBe(307)
  expect(error.headers.get('location')).toBe('/dashboard')
})
```

## Stateful Workflows

Test full user workflows: render a page, call an action, render again, verify the state changed.

```ts
import { test, expect } from 'vitest'
import { createSpiceflowFetch } from 'spiceflow/client'
import { SpiceflowTestResponse } from 'spiceflow/testing'
import { app, projectStore } from './main.js'
import { createProject } from './actions.js'

const f = createSpiceflowFetch(app)

test('dashboard shows projects after creation', async () => {
  projectStore.length = 0

  // Empty state
  const empty = await f('/dashboard')
  if (!(empty instanceof SpiceflowTestResponse)) throw new Error('expected page')
  expect(await empty.text()).toContain('No projects yet')

  // Create a project via action
  await createProject('My New App')

  // Page now shows the project
  const filled = await f('/dashboard')
  if (!(filled instanceof SpiceflowTestResponse)) throw new Error('expected page')
  const html = await filled.text()
  expect(html).toContain('My New App')
  expect(html).not.toContain('No projects yet')
})
```

Client components using `useLoaderData` get correct data because the loader runs on each request and the result is provided to the rendering context.

## Dependency Injection with State

Use `.state()` to register dependencies your handlers need (database clients, KV stores, auth services). In tests, pass overrides via `createSpiceflowFetch(app, { state })` to swap real services for test doubles.

```ts
// main.tsx
import { Spiceflow } from 'spiceflow'

// Define the app with typed state for a KV store and auth
export const app = new Spiceflow()
  .state('kv', productionKV as KVStore)
  .state('user', null as User | null)
  .get('/api/projects', async ({ state }) => {
    if (!state.user) return new Response('Unauthorized', { status: 401 })
    const projects = await state.kv.get(`projects:${state.user.id}`)
    return { projects: projects ?? [] }
  })
  .get('/api/projects/:id', async ({ state, params }) => {
    if (!state.user) return new Response('Unauthorized', { status: 401 })
    const project = await state.kv.get(`project:${params.id}`)
    if (!project) return new Response('Not Found', { status: 404 })
    return project
  })
  .post('/api/projects', async ({ state, request }) => {
    if (!state.user) return new Response('Unauthorized', { status: 401 })
    const body = await request.json()
    await state.kv.put(`project:${body.id}`, body)
    return body
  })

interface KVStore {
  get(key: string): Promise<any>
  put(key: string, value: any): Promise<void>
}

interface User {
  id: string
  name: string
}
```

In tests, create a fake KV store and pass it as state. No mocking modules, no patching globals. The typed state ensures your test doubles match the expected interface.

```ts
// main.test.ts
import { test, expect } from 'vitest'
import { createSpiceflowFetch } from 'spiceflow/client'
import { app } from './main.js'

// In-memory KV for tests
const store = new Map<string, any>()
const fakeKV = {
  async get(key: string) { return store.get(key) },
  async put(key: string, value: any) { store.set(key, value) },
}

const testUser = { id: 'u1', name: 'Tommy' }

// Typed fetch with test state injected
const f = createSpiceflowFetch(app, {
  state: { kv: fakeKV, user: testUser },
})

// Unauthenticated client (user: null)
const anon = createSpiceflowFetch(app, {
  state: { kv: fakeKV, user: null },
})

test('authenticated user can create and list projects', async () => {
  store.clear()

  // Create a project
  await f('/api/projects', {
    method: 'POST',
    body: { id: 'p1', name: 'My App' },
  })

  // Verify KV was written
  expect(store.get('project:p1')).toEqual({ id: 'p1', name: 'My App' })
})

test('unauthenticated user gets 401', async () => {
  const result = await anon('/api/projects')
  expect(result).toBeInstanceOf(Error)
})
```

**When to use state for DI:**

* **External services** (KV, database, email, storage) you want to replace with in-memory fakes in tests
* **Auth context** (current user, session) so you don't need to set up real auth flows
* **Feature flags** or config that varies between tests

State is deep-cloned per request by default, so each `app.handle()` call gets its own copy. Overrides via `createSpiceflowFetch` or `app.handle(req, { state })` replace the defaults for that call only.

## Testing with better-auth

When your app uses [better-auth](https://better-auth.com) for authentication, set the database path via an **environment variable** so tests run against an in-memory SQLite database (`AUTH_DB=:memory:` in vitest config). The `bearer()` plugin enables `Authorization: Bearer <token>` headers, and `emailAndPassword` lets tests create real users via `auth.api.signUpEmail`. Drizzle migrations run in a setup file before tests start.

The [example-better-auth test file](https://github.com/remorses/spiceflow/blob/main/example-better-auth/src/main.test.ts) is a complete working example. It covers:

* `describe('public routes')` — pages that render without auth
* `describe('protected routes')` — sign up a user in `beforeAll`, set `.headers` on the fetch client, verify pages and APIs
* `describe('unauthenticated access')` — verify middleware blocks access and redirects to login
* `describe('multiple users')` — create two users with separate fetch clients, verify data isolation
* `describe('server actions with auth')` — call actions with `runAction` + authed request, verify mutations and redirects
* `describe('Org workflow')` — full multi-step journey: create user → create org → redirect to dashboard → verify empty state → create project → verify dashboard shows it → delete project → verify empty again. Also tests cross-user access denial.

Key techniques:

* **`createSpiceflowFetch(app, { headers })`** with a Bearer token to simulate an authenticated user
* **Mutable `.headers` field** set in `beforeAll` after user creation for describe-scoped auth
* **`runAction` with a custom request** to pass auth tokens to server actions that call `getActionRequest()`
* **Catching redirect responses** with `.catch((e) => e)` then asserting on `status` and `location` header
* **`res.loaderData`** to verify the loader returned correct data from the real database
* **`res.page`** for inline JSX snapshots; **`res.text()`** for full HTML assertions

## Type Safety

Register your app type once so `router.href()` and `createSpiceflowFetch()` paths are fully typed.

```ts
// main.tsx
export const app = new Spiceflow()
  .get('/api/projects', () => ({ projects: [] }))
  .page('/dashboard', async () => <div>Dashboard</div>)

declare module 'spiceflow/react' {
  interface SpiceflowRegister {
    app: typeof app
  }
}
```

Invalid paths and missing params are caught at compile time.

```ts
// @ts-expect-error - path does not exist
router.href('/nonexistent')

// @ts-expect-error - missing required params
f('/api/projects/:id')
```

## Tracing

Use `createTestTracer()` to capture spans during `app.handle()` and snapshot the trace tree. The tracer records every span created by spiceflow's instrumentation and renders them as an ASCII tree via `.text()`.

```ts
import { test, expect } from 'vitest'
import { Spiceflow } from 'spiceflow'
import { createTestTracer } from 'spiceflow/testing'

const tracer = createTestTracer()
const app = new Spiceflow({ tracer })
  .use(async (ctx, next) => { await next() }) // auth middleware
  .get('/api/projects', () => ({ projects: [] }))

test('request spans', async () => {
  tracer.clear()
  await app.handle(new Request('http://localhost/api/projects'))
  expect(tracer.text()).toMatchInlineSnapshot(`
    "GET /api/projects (200)
    ├── middleware - anonymous
    └── handler - /api/projects"
  `)
})
```

`tracer.text()` renders the span tree as ASCII with status codes on the root span. `tracer.spans` gives raw access to span objects. `tracer.clear()` resets between tests.

## Formatting HTML for Snapshots

Raw HTML from `res.text()` is a single long string with all attributes. For readable inline snapshots, use **posthtml** to strip noisy attributes and **posthtml-beautify** to indent the output.

```bash
pnpm add -D posthtml posthtml-beautify
```

### Strip attributes and format

```ts
import { test, expect } from 'vitest'
import posthtml from 'posthtml'
import beautify from 'posthtml-beautify'
import { createSpiceflowFetch } from 'spiceflow/client'
import { SpiceflowTestResponse } from 'spiceflow/testing'
import { app } from './main.js'

const f = createSpiceflowFetch(app)

/** posthtml plugin that removes specified attributes from all nodes */
function removeAttrs(attrs: string[]) {
  return (tree: any) => {
    tree.walk((node: any) => {
      if (node.attrs) {
        for (const attr of attrs) delete node.attrs[attr]
      }
      return node
    })
    return tree
  }
}

test('page structure without styling noise', async () => {
  const res = await f('/dashboard')
  if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')

  const html = await res.text()
  const { html: clean } = await posthtml([
    removeAttrs(['class', 'style']),
    beautify({ rules: { blankLines: '' } }),
  ]).process(html)

  expect(clean).toMatchInlineSnapshot(`
    "<html lang="en">
      <head></head>
      <body>
        <div>
          <h1>Dashboard</h1>
          <p>No projects yet</p>
        </div>
      </body>
    </html>
    "
  `)
})
```

The `blankLines: ''` option removes empty lines between sibling elements that `posthtml-beautify` adds by default.

### Find elements with tree.match

Use `tree.match()` to locate specific elements by tag or attributes, then assert on their properties.

```ts
test('button has correct attributes and text', async () => {
  const res = await f('/settings')
  if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')

  const html = await res.text()
  const buttons: { tag: string; attrs: any; text: string; childCount: number }[] = []

  await posthtml()
    .use((tree) => {
      tree.match({ tag: 'button' }, (node) => {
        const text = (node.content || [])
          .filter((c): c is string => typeof c === 'string')
          .join('')
        buttons.push({
          tag: node.tag!,
          attrs: node.attrs || {},
          text,
          childCount: (node.content || []).length,
        })
        return node
      })
      return tree
    })
    .process(html)

  expect(buttons).toMatchInlineSnapshot(`
    [
      {
        "attrs": {
          "class": "btn-primary",
          "type": "submit",
        },
        "childCount": 1,
        "tag": "button",
        "text": "Save",
      },
    ]
  `)
})
```

`tree.match()` accepts matcher objects: `{ tag: 'div' }`, `{ attrs: { id: 'main' } }`, or both combined. It walks the full tree recursively.

## Testing on Cloudflare Workers

Run tests **inside the real workerd runtime** with [`@cloudflare/vitest-pool-workers`](https://developers.cloudflare.com/workers/testing/vitest-integration/). This gives you access to `cloudflare:workers` APIs (`env`, `waitUntil`, D1, KV, etc.) in your test files — the same environment as production. D1 is simulated locally as an **in-memory SQLite database** by Miniflare; no real Cloudflare infrastructure is involved.

See the complete working example: [example-vitest-cloudflare](https://github.com/remorses/spiceflow/tree/main/example-vitest-cloudflare)

### Setup

Install the pool workers package alongside the Cloudflare Vite plugin:

```bash
pnpm add -D @cloudflare/vitest-pool-workers @cloudflare/vite-plugin wrangler
```

Configure `vite.config.ts` to swap between `cloudflareTest()` (vitest) and `cloudflare()` (dev/build) based on the `VITEST` env variable.

`readD1Migrations()` runs on the **Node.js side** (at config time), reads the SQL files from your `migrations/` folder, and passes them as a miniflare binding called `TEST_MIGRATIONS`. The actual migration application happens later inside workerd via the setup file.

```ts
// vite.config.ts
import path from 'node:path'
import { cloudflare } from '@cloudflare/vite-plugin'
import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-pool-workers'
import spiceflow from 'spiceflow/vite'
import { defineConfig } from 'vite'

export default defineConfig(async () => {
  // Reads .sql files from migrations/ on the Node.js side, before workerd starts.
  // Passed to miniflare as the TEST_MIGRATIONS binding so workerd can apply them.
  const migrations = await readD1Migrations(path.join(__dirname, 'migrations')).catch(() => [])

  return {
    plugins: [
      process.env.VITEST
        ? cloudflareTest({
            wrangler: { configPath: './wrangler.jsonc' },
            miniflare: { bindings: { TEST_MIGRATIONS: migrations } },
          })
        : cloudflare({
            viteEnvironment: { name: 'rsc', childEnvironments: ['ssr'] },
          }),
      spiceflow({ entry: './src/main.tsx' }),
    ],
    test: {
      setupFiles: ['./src/apply-migrations.ts'],
    },
  }
})
```

### Applying D1 Migrations

The setup file runs **inside workerd** before each test file. It applies the SQL migrations to the fresh in-memory D1 using two APIs from Cloudflare's virtual modules:

* **`env` from `cloudflare:workers`** — the same bindings object available in production handlers (`env.DB`, `env.KV`, etc.), wired to Miniflare's in-memory implementations
* **`applyD1Migrations` from `cloudflare:test`** — test-only helper that runs pending SQL migrations against a D1 binding; tracks which have been applied so it is safe to call multiple times

```ts
// src/apply-migrations.ts
import { applyD1Migrations } from 'cloudflare:test'
import { env } from 'cloudflare:workers'

// Applies all .sql files passed via TEST_MIGRATIONS to the in-memory env.DB.
// Idempotent: skips migrations already applied.
await applyD1Migrations(env.DB, env.TEST_MIGRATIONS)
```

### Storage Isolation Model

**All storage** — D1, KV, R2, and Durable Objects — follows the same isolation model: **per test file, shared within a file**.

Each test file gets a completely fresh storage snapshot. workerd implements this by pushing a new SQLite snapshot onto an on-disk stack at the start of each file, then popping it at the end — discarding every write. Tests within the same file share state; different files are completely isolated and run concurrently.

This is why `apply-migrations.ts` runs once per file: the fresh snapshot has no tables yet, so migrations are applied to each file's clean DB before its tests start.

```diagram
pnpm test
│
├─ vite.config.ts (Node.js)
│   └─ readD1Migrations() → SQL strings → TEST_MIGRATIONS binding
│
├─ users.test.ts                      ├─ posts.test.ts
│   Fresh D1 + KV + DO storage            Fresh D1 + KV + DO storage
│   ├─ setup: apply migrations             ├─ setup: apply migrations
│   ├─ test 1 → INSERT, create DO          ├─ test 1 → INSERT, create DO
│   └─ test 2 → sees test 1's state        └─ test 2 → sees test 1's state
│   (file ends → snapshot discarded)      (file ends → snapshot discarded)
│
│   Files run concurrently, each sees only its own storage.
```

**Durable Objects** follow the exact same model. DO instances created in one test file don't exist in another. `listDurableObjectIds(namespace)` from `cloudflare:test` only returns IDs created within the current file's snapshot.

**If you need per-test isolation within a file:** clean up manually in `beforeEach`/`afterEach` — e.g. `DELETE FROM table` for D1 or `env.KV.delete(key)` for KV.

**If you need shared state across files** (e.g. integration tests that build up accumulated data): run with `--max-workers=1 --no-isolate`.

**WebSockets + Durable Objects** don't work with per-file isolation. Use `--max-workers=1 --no-isolate` as a workaround.

### Writing Tests

Import `env` and `waitUntil` directly from `cloudflare:workers` — the same virtual module available in your Worker handlers. `env` gives typed access to all bindings declared in `wrangler.jsonc`.

```ts
import { test, expect } from 'vitest'
import { env, waitUntil } from 'cloudflare:workers'
import { SpiceflowTestResponse } from 'spiceflow/testing'
import { createSpiceflowFetch } from 'spiceflow/client'
import { app } from './main.js'

const f = createSpiceflowFetch(app)

// Pages work the same as in Node.js tests
test('GET / renders home page', async () => {
  const res = await f('/')
  if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')
  expect(await res.text()).toContain('Home')
})

// D1 is available via env — same API as production
test('D1: insert and query a user', async () => {
  await env.DB.prepare('INSERT INTO users (name) VALUES (?)').bind('Alice').run()
  const row = await env.DB.prepare('SELECT * FROM users WHERE name = ?').bind('Alice').first()
  expect(row).toMatchObject({ name: 'Alice' })
})

// waitUntil works inside workerd
test('waitUntil is callable', () => {
  expect(() => waitUntil(Promise.resolve('done'))).not.toThrow()
})
```

**Cloudflare virtual module imports used in tests:**

| Import              | Module               | Purpose                                           |
| ------------------- | -------------------- | ------------------------------------------------- |
| `env`               | `cloudflare:workers` | Bindings from `wrangler.jsonc` (D1, KV, R2, etc.) |
| `waitUntil`         | `cloudflare:workers` | Extend Worker lifetime for background tasks       |
| `applyD1Migrations` | `cloudflare:test`    | Apply SQL migrations to a D1 binding in tests     |

### Type Safety for Bindings

`env` from `cloudflare:workers` is typed via the `Cloudflare.Env` interface. Run `wrangler types` to auto-generate `worker-configuration.d.ts` from your `wrangler.jsonc` bindings:

```bash
wrangler types
```

This produces a file like:

```ts
// worker-configuration.d.ts  (generated, do not edit)
declare namespace Cloudflare {
  interface Env {
    DB: D1Database
    MY_KV: KVNamespace
  }
}
// makes `env` from cloudflare:workers typed as Cloudflare.Env
interface Env extends Cloudflare.Env {}
```

For **test-only bindings** that only exist in miniflare (like `TEST_MIGRATIONS`), augment `Cloudflare.Env` manually in a separate `.d.ts` file so you don't touch the generated file:

```ts
// src/env.d.ts
declare namespace Cloudflare {
  interface Env {
    TEST_MIGRATIONS: D1Migration[]
  }
}

interface D1Migration {
  name: string
  queries: string[]
}
```

After this, `env.DB`, `env.MY_KV`, and `env.TEST_MIGRATIONS` are all fully typed everywhere — in test files, setup files, and Worker handlers alike. The `Cloudflare.Env` namespace is open for augmentation, so TypeScript merges all declarations.

For testing Durable Objects, Queues, Workflows, and the full list of available test helpers, see the [Cloudflare Workers Vitest test APIs reference](https://developers.cloudflare.com/workers/testing/vitest-integration/test-apis/).

## What's Not Supported

Vitest mode bypasses RSC Flight serialization. Some features require the full RSC environment and should be tested with e2e tests (Playwright).

* **Error boundaries**: errors in page handlers return a 500 JSON response. The error boundary fallback UI is not rendered. Use e2e tests for error boundary behavior.
* **Server actions via form POST**: the RSC action protocol (decoding form data into server function calls) is not available. Test action logic by calling functions directly instead.
* **Client-side navigation**: there's no browser, so `router.push()`, hydration, and client-side transitions can't be tested. Use e2e tests for navigation flows.


---
title: "Auth, proxy, cookies, and graceful shutdown"
url: "https://getspiceflow.com/middleware-patterns.md"
description: "Spiceflow middleware patterns for authorization, request proxying, non-blocking auth, cookies, and graceful shutdown on platforms like Fly.io."
---

# Auth, proxy, cookies, and graceful shutdown

Advanced middleware patterns for authentication, proxying, cookies, and graceful shutdown.

## Authorization

You can handle authorization in a middleware. The example below checks if the user is logged in and uses `.state()` to track the session across handlers:

```ts
import { z } from 'zod'
import { Spiceflow } from 'spiceflow'

new Spiceflow()
  .state('session', null as Session | null)
  .use(async ({ request: req, state }, next) => {
    const res = new Response()

    const { session } = await getSession({ req, res })
    if (!session) {
      return
    }
    state.session = session
    const response = await next()

    const cookies = res.headers.getSetCookie()
    for (const cookie of cookies) {
      response.headers.append('Set-Cookie', cookie)
    }

    return response
  })
  .route({
    method: 'POST',
    path: '/protected',
    async handler({ state }) {
      const { session } = state
      if (!session) {
        throw new Error('Not logged in')
      }
      return { ok: true }
    },
  })
```

## Proxy

```ts
import { Spiceflow } from 'spiceflow'
import type { MiddlewareHandler } from 'spiceflow'

export const app = new Spiceflow()

function createProxyMiddleware({
  target,
  changeOrigin = false,
}): MiddlewareHandler {
  return async ({ request }) => {
    const { pathname, search } = request.parsedUrl

    const proxyReq = new Request(
      new URL(pathname + search, target),
      request,
    )

    if (changeOrigin) {
      proxyReq.headers.set('origin', new URL(target).origin || '')
    }
    console.log('proxying', proxyReq.url)
    const res = await fetch(proxyReq)

    return res
  }
}

app.use(
  createProxyMiddleware({
    target: 'https://api.openai.com',
    changeOrigin: true,
  }),
)

// or with a basePath
app.use(
  new Spiceflow({ basePath: '/v1/completions' }).use(
    createProxyMiddleware({
      target: 'https://api.openai.com',
      changeOrigin: true,
    }),
  ),
)

app.listen(3030)
```

## Non-Blocking Auth

Sometimes authentication is only required for specific routes, and you don't want to block public routes while waiting for authentication. You can use `Promise.withResolvers()` to start fetching user data in parallel, allowing public routes to respond immediately while protected routes wait for authentication to complete.

The example below demonstrates this pattern - the `/public` route responds instantly while `/protected` waits for authentication:

```ts
import { Spiceflow } from 'spiceflow'

new Spiceflow()
  .state('userId', Promise.resolve(''))
  .state('userEmail', Promise.resolve(''))
  .use(async ({ request, state }, next) => {
    const sessionKey = request.headers.get('sessionKey')
    const userIdPromise = Promise.withResolvers<string>()
    const userEmailPromise = Promise.withResolvers<string>()

    state.userId = userIdPromise.promise
    state.userEmail = userEmailPromise.promise

    async function resolveUser() {
      if (!sessionKey) {
        userIdPromise.resolve('')
        userEmailPromise.resolve('')
        return
      }
      const user = await getUser(sessionKey)
      userIdPromise.resolve(user?.id ?? '')
      userEmailPromise.resolve(user?.email ?? '')
    }

    resolveUser()
  })
  .route({
    method: 'GET',
    path: '/protected',
    async handler({ state }) {
      const userId = await state.userId
      if (!userId) throw new Error('Not authenticated')
      return { message: 'Protected data' }
    },
  })
  .route({
    method: 'GET',
    path: '/public',
    handler() {
      return { message: 'Public data' }
    },
  })

async function getUser(sessionKey: string) {
  await new Promise((resolve) => setTimeout(resolve, 100))
  return sessionKey === 'valid'
    ? { id: '123', email: 'user@example.com' }
    : null
}
```

## Cookies

Spiceflow works with standard Request and Response objects, so you can use any cookie library like the `cookie` npm package to handle cookies.

### Set, Get, and Clear Cookies

```ts
import { Spiceflow } from 'spiceflow'
import { parse, serialize } from 'cookie'

export const app = new Spiceflow()
  .route({
    method: 'GET',
    path: '/set-cookie',
    handler({ request }) {
      const cookies = parse(request.headers.get('Cookie') || '')

      const response = new Response(
        JSON.stringify({
          message: 'Cookie set!',
          existingCookies: cookies,
        }),
        { headers: { 'Content-Type': 'application/json' } },
      )

      response.headers.set(
        'Set-Cookie',
        serialize('session', 'abc123', {
          httpOnly: true,
          secure: true,
          sameSite: 'strict',
          maxAge: 60 * 60 * 24 * 7,
          path: '/',
        }),
      )

      return response
    },
  })
  .route({
    method: 'GET',
    path: '/get-cookie',
    handler({ request }) {
      const cookies = parse(request.headers.get('Cookie') || '')
      return { sessionId: cookies.session || null, allCookies: cookies }
    },
  })
  .route({
    method: 'POST',
    path: '/clear-cookie',
    handler({ request }) {
      const response = new Response(
        JSON.stringify({ message: 'Cookie cleared!' }),
        { headers: { 'Content-Type': 'application/json' } },
      )

      response.headers.set(
        'Set-Cookie',
        serialize('session', '', {
          httpOnly: true,
          secure: true,
          sameSite: 'strict',
          expires: new Date(0),
          path: '/',
        }),
      )

      return response
    },
  })

app.listen(3000)
```

### Cookie-Based Auth Middleware

You can also use cookies in middleware for authentication or session handling:

```ts
import { Spiceflow } from 'spiceflow'
import { parse, serialize } from 'cookie'

export const app = new Spiceflow()
  .state('userId', null as string | null)
  .use(async ({ request, state }, next) => {
    // Parse cookies from incoming request
    const cookies = parse(request.headers.get('Cookie') || '')

    // Extract user ID from session cookie
    if (cookies.session) {
      // In a real app, you'd verify the session token
      state.userId = cookies.session
    }

    const response = await next()

    // Optionally refresh the session cookie
    if (state.userId && response) {
      response.headers.set(
        'Set-Cookie',
        serialize('session', state.userId, {
          httpOnly: true,
          secure: true,
          sameSite: 'strict',
          maxAge: 60 * 60 * 24, // 24 hours
          path: '/',
        }),
      )
    }

    return response
  })
  .route({
    method: 'GET',
    path: '/profile',
    handler({ state }) {
      if (!state.userId) {
        return new Response('Unauthorized', { status: 401 })
      }

      return { userId: state.userId, message: 'Welcome back!' }
    },
  })
```

## Graceful Shutdown

The `preventProcessExitIfBusy` middleware prevents platforms like Fly.io from killing your app while processing long requests (e.g., AI payloads). When the process receives SIGTERM during a deploy, it waits for all active requests and pending `waitUntil` promises to complete before exiting.

```ts
import { Spiceflow, preventProcessExitIfBusy } from 'spiceflow'

export const app = new Spiceflow()
  .use(
    preventProcessExitIfBusy({
      maxWaitSeconds: 60, // 1 minute max wait (default: 60)
      checkIntervalMs: 250, // Check interval (default: 250ms)
    }),
  )
  .route({
    method: 'POST',
    path: '/ai/generate',
    async handler({ request }) {
      const prompt = await request.json()
      const result = await generateAIResponse(prompt)
      return result
    },
  })

app.listen(3000)
```


---
title: Migrate a Remix or React Router app
url: "https://getspiceflow.com/migrate-from-remix.md"
description: "Step-by-step guide to convert a Remix or React Router v7 app to Spiceflow with typed loaders, server actions, layouts, and React Server Components."
---

# Migrate a Remix or React Router app

Spiceflow replaces Remix's file-based routing with explicit route registration. The core concepts map directly: loaders stay loaders, actions become server actions, and everything remains type-safe through a register pattern.

This guide covers each piece of a Remix app and shows the clean Spiceflow equivalent.

## Route registration

Remix uses file naming conventions (`routes/dashboard.tsx`, `routes/dashboard._index.tsx`). Spiceflow uses explicit chained methods on a single app instance.

```tsx
// src/main.tsx
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow()
  .page('/dashboard', async () => <DashboardPage />)
  .page('/dashboard/settings', async () => <SettingsPage />)
  .get('/api/health', async () => ({ status: 'ok' }))
  .post('/api/webhooks', async ({ request }) => {
    const body = await request.json()
    await processWebhook(body)
    return { success: true }
  })

// Register the app type globally for type-safe routing
declare module 'spiceflow/react' {
  interface SpiceflowRegister { app: typeof app }
}
```

| Remix                           | Spiceflow                      |
| ------------------------------- | ------------------------------ |
| `routes/dashboard.tsx`          | `.page('/dashboard', ...)`     |
| `routes/api.health.ts` (GET)    | `.get('/api/health', ...)`     |
| `routes/api.webhooks.ts` (POST) | `.post('/api/webhooks', ...)`  |
| `routes/dashboard.tsx` layout   | `.layout('/dashboard/*', ...)` |
| Dynamic `$id` segments          | `:id` segments                 |

## Loaders and `useLoaderData`

Spiceflow has first-class `.loader()` support. Data loaded on the server is available in any client component via `useLoaderData()`, fully typed.

### Before (Remix)

```tsx
// routes/dashboard.tsx
import { json, useLoaderData } from 'react-router'

export async function loader({ request }) {
  const user = await getUser(request)
  const projects = await getProjects(user.id)
  return json({ user, projects })
}

export default function Dashboard() {
  const { user, projects } = useLoaderData<typeof loader>()
  return <div>{user.name} has {projects.length} projects</div>
}
```

### After (Spiceflow)

```tsx
// src/main.tsx
import { Spiceflow } from 'spiceflow'
import { DashboardPage } from './app/dashboard-page'

export const app = new Spiceflow()
  .loader('/dashboard', async ({ request }) => {
    const user = await getUser(request)
    const projects = await getProjects(user.id)
    return { user, projects }
  })
  .page('/dashboard', async () => <DashboardPage />)

declare module 'spiceflow/react' {
  interface SpiceflowRegister { app: typeof app }
}
```

```tsx
// src/app/dashboard-page.tsx
'use client'

import { useLoaderData } from 'spiceflow/react'

export function DashboardPage() {
  const { user, projects } = useLoaderData('/dashboard')
  //      ^--- fully typed from the loader return value
  return <div>{user.name} has {projects.length} projects</div>
}
```

The type safety comes from the `declare module` register at the bottom of your app entry. TypeScript infers the loader return type from your code and maps it to the route path, so `useLoaderData('/dashboard')` knows the exact shape without generics or `typeof loader`.

### Nested loaders

Remix nests loaders by file hierarchy. Spiceflow nests loaders by path pattern. Wildcard loaders run for all child routes, and data is merged by specificity.

```tsx
export const app = new Spiceflow()
  // Runs for /dashboard and all /dashboard/* routes
  .loader('/dashboard/*', async ({ request }) => {
    const user = await getUser(request)
    return { user }
  })
  // Runs only for /dashboard/projects/:id/*
  .loader('/dashboard/projects/:id/*', async ({ params }) => {
    const project = await getProject(params.id)
    return { project }
  })
  .layout('/dashboard/*', async ({ children }) => {
    return <DashboardShell>{children}</DashboardShell>
  })
  .page('/dashboard/projects/:id', async () => {
    return <ProjectPage />
  })
```

Any client component can call `useLoaderData` at any depth. No prop drilling needed.

```tsx
'use client'
import { useLoaderData } from 'spiceflow/react'

export function ProjectHeader() {
  // Read data from two different loader levels
  const { user } = useLoaderData('/dashboard/*')
  const { project } = useLoaderData('/dashboard/projects/:id/*')
  return <h1>{project.name} — {user.name}</h1>
}
```

## Auth guards

Loaders can throw `redirect()` to protect routes, same as Remix.

```tsx
// Remix
export async function loader({ request }) {
  const user = await getUser(request)
  if (!user) return redirect('/login')
  return json({ user })
}

// Spiceflow
.loader('/dashboard/*', async ({ request, redirect }) => {
  const user = await getUser(request)
  if (!user) throw redirect('/login')
  return { user }
})
```

The key difference: Spiceflow uses `throw redirect()` (not `return`). Throwing short-circuits the entire request immediately.

## Form actions → Server actions

This is the biggest change. Remix uses `action()` exports with `<Form>` from react-router, `useActionData()`, and `useNavigation()`. Spiceflow uses React 19 server actions with `<form action>`, `useActionState`, and `useFormStatus`.

### Before (Remix)

```tsx
// routes/contact.tsx
import { Form, useActionData, useNavigation } from 'react-router'

export async function action({ request }) {
  const form = await request.formData()
  const email = form.get('email')
  const result = await subscribe(email)
  if (result.error) return json({ error: result.error }, { status: 400 })
  return redirect('/thank-you')
}

export default function ContactPage() {
  const actionData = useActionData<typeof action>()
  const nav = useNavigation()
  const isLoading = nav.state !== 'idle'

  return (
    <Form method="post">
      <input name="email" type="email" required />
      <button disabled={isLoading}>
        {isLoading ? 'Subscribing...' : 'Subscribe'}
      </button>
      {actionData?.error && <p className="text-red-500">{actionData.error}</p>}
    </Form>
  )
}
```

### After (Spiceflow)

Define the action in a `"use server"` file and import it directly in the client component. No prop drilling needed.

```tsx
// src/actions.ts
'use server'

import { z } from 'zod'
import { parseFormData, redirect } from 'spiceflow'

const subscribeSchema = z.object({ email: z.string().email() })

export async function subscribeAction(
  prev: { error: string } | null,
  formData: FormData,
) {
  const { email } = parseFormData(subscribeSchema, formData)
  const result = await subscribe(email)
  if (result.error) return { error: result.error }
  throw redirect('/thank-you')
}
```

```tsx
// src/main.tsx
export const app = new Spiceflow()
  .page('/contact', async () => <ContactForm />)
```

```tsx
// src/app/contact-form.tsx
'use client'

import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { ErrorBoundary } from 'spiceflow/react'
import { subscribeAction } from '../actions'

function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Subscribing...' : 'Subscribe'}
    </button>
  )
}

export function ContactForm() {
  const [state, formAction] = useActionState(subscribeAction, null)

  return (
    <ErrorBoundary
      below
      fallback={
        <div className="text-red-500">
          <ErrorBoundary.ErrorMessage />
          <ErrorBoundary.ResetButton>Try again</ErrorBoundary.ResetButton>
        </div>
      }
    >
      <form action={formAction}>
        <input name="email" type="email" required />
        <SubmitButton />
        {state?.error && <p className="text-red-500">{state.error}</p>}
      </form>
    </ErrorBoundary>
  )
}
```

### What changed

| Remix                                | Spiceflow                                             |
| ------------------------------------ | ----------------------------------------------------- |
| `export async function action()`     | `"use server"` file, imported directly in components  |
| `<Form method="post">`               | `<form action={formAction}>`                          |
| `useActionData()`                    | `useActionState(action, initialState)`                |
| `useNavigation().state !== 'idle'`   | `useFormStatus().pending`                             |
| `return json({ error })` in action   | `return { error }` (plain object)                     |
| `return redirect('/path')` in action | `throw redirect('/path')`                             |
| Manual error display                 | `ErrorBoundary` from `spiceflow/react` catches throws |

### Validation with `parseFormData`

Spiceflow provides `parseFormData(schema, formData)` which validates form fields against a Zod schema. It throws a `ValidationError` on failure, which `ErrorBoundary` catches automatically. No manual error handling needed for validation.

```tsx
import { z } from 'zod'
import { parseFormData } from 'spiceflow'

const schema = z.object({
  email: z.string().email(),
  name: z.string().min(1, 'Name is required'),
})
const fields = schema.keyof().enum  // type-safe input names

// In a form:
<input name={fields.email} type="email" />
<input name={fields.name} />
```

Using `schema.keyof().enum` for input `name` attributes means typos become compile errors.

## Layouts

Remix layout routes (`_layout.tsx`, pathless layouts) become `.layout()` calls.

```tsx
// Remix: routes/_app.tsx wraps all routes/_app.*.tsx children
// Spiceflow:
export const app = new Spiceflow()
  .layout('/*', async ({ children }) => {
    return (
      <html lang="en">
        <body>
          <ProgressBar />
          <nav>...</nav>
          {children}
          <footer>...</footer>
        </body>
      </html>
    )
  })
  .layout('/app/*', async ({ children, loaderData }) => {
    return <AppShell user={loaderData.user}>{children}</AppShell>
  })
```

Wildcard layouts match their base path too, so `/app/*` wraps both `/app` and `/app/settings`.

When the URL doesn't match any `.page()`, `children` is `null` in the layout. Render a custom 404:

```tsx
.layout('/*', async ({ children }) => {
  return <AppLayout>{children ?? <NotFound />}</AppLayout>
})
```

## Redirects

```tsx
// Remix
import { redirect } from 'react-router'
return redirect('/login')
return redirect('/login', { headers })

// Spiceflow (in loaders, page handlers, server actions)
throw redirect('/login')
throw redirect('/login', { headers: { 'Set-Cookie': '...' } })
```

Always **throw** redirects in Spiceflow, never return them.

## Sub-app mounting

Remix doesn't have a direct equivalent. Spiceflow lets you compose multiple apps with `.use()`:

```tsx
import { Spiceflow } from 'spiceflow'

const apiApp = new Spiceflow({ basePath: '/api' })
  .get('/users', async () => getUsers())
  .post('/users', async ({ request }) => createUser(await request.json()))

const mainApp = new Spiceflow()
  .page('/', async () => <Home />)
  .use(apiApp)  // mounts at /api/*
```

## Links and navigation

```tsx
// Remix
import { Link, useNavigate } from 'react-router'
<Link to="/dashboard">Dashboard</Link>

// Spiceflow
import { Link, router } from 'spiceflow/react'
<Link href={router.href('/dashboard')}>Dashboard</Link>

// Type-safe dynamic paths
<Link href={router.href('/users/:id', { id: '42' })}>User 42</Link>

// Programmatic navigation
router.push('/dashboard')
router.replace('/settings')
```

`router.href()` validates paths against the route table at compile time. If you rename a route, every stale `href()` call becomes a TypeScript error.

## Import replacement reference

| Remix / React Router import         | Spiceflow replacement                             |
| ----------------------------------- | ------------------------------------------------- |
| `useLoaderData` from `react-router` | `useLoaderData` from `spiceflow/react`            |
| `useActionData`                     | `useActionState` from `react`                     |
| `useNavigation`                     | `useFormStatus` from `react-dom`                  |
| `Form` from `react-router`          | `<form action={serverAction}>`                    |
| `useSearchParams`                   | Props from `.page()` handler, or `useRouterState` |
| `redirect` from `react-router`      | `redirect` from `spiceflow`                       |
| `json` / `data` from `react-router` | `json` from `spiceflow` (typed)                   |
| `Link` from `react-router`          | `Link` from `spiceflow/react`                     |
| `href` from `react-router`          | `router.href()` from `spiceflow/react`            |
| `useParams`                         | `useLoaderData` (params available in loaders)     |
| `useSubmit`                         | Server actions called directly                    |
| `useFetcher`                        | Server actions + `useTransition`                  |

## Reusable actions with `"use server"` files

For actions used by multiple pages, put them in a dedicated `"use server"` file instead of defining them inline. This keeps action logic centralized and testable.

```tsx
// src/actions.ts
'use server'

import { z } from 'zod'
import { parseFormData, redirect } from 'spiceflow'
import { getActionRequest } from 'spiceflow'
import { router } from 'spiceflow/react'

export const postSchema = z.object({ title: z.string().min(1) })

export async function createPost(formData: FormData) {
  const { signal } = getActionRequest()
  const { title } = parseFormData(postSchema, formData)
  const post = await db.posts.create({ title }, { signal })
  // router.href is type-safe in standalone action files
  throw redirect(router.href('/posts/:id', { id: post.id }))
}
```

`getActionRequest()` gives access to the request `signal`, which is aborted when the client disconnects. Pass it to downstream work so long operations cancel automatically.

On the client, `getActionAbortController()` from `spiceflow/react` lets users cancel in-flight actions.

## Query params with typed schemas

Spiceflow lets you define a `query` schema on pages using the object notation. This gives you typed query access and compile-time validation on `router.href()`.

```tsx
// Remix
const [searchParams] = useSearchParams()
const q = searchParams.get('q') || ''

// Spiceflow
.page({
  path: '/search',
  query: z.object({
    q: z.string(),
    page: z.coerce.number().optional(),
  }),
  handler: async ({ query }) => {
    // query.q is string, query.page is number | undefined
    const results = await search(query.q, query.page)
    return <SearchResults results={results} />
  },
})
```

Links to pages with query schemas are validated at compile time:

```tsx
<Link href={router.href('/search', { q: 'docs', page: 2 })}>Search</Link>
// @ts-expect-error — 'color' is not in the schema
<Link href={router.href('/search', { color: 'red' })}>Red</Link>
```

## Streaming with Suspense

Remix uses `defer()` to stream slow data. Spiceflow uses loaders with unawaited promises. The loader returns immediately, the page renders with a `<Suspense>` fallback, and the slow data streams in via the RSC flight stream.

```tsx
// src/main.tsx
app
  .loader('/dashboard', async ({ request }) => {
    const user = await getUser(request)           // fast, awaited
    const statsPromise = getExpensiveStats()      // slow, NOT awaited
    return { user, statsPromise }
  })
  .page('/dashboard', async ({ loaderData }) => {
    return (
      <div>
        <h1>Welcome {loaderData.user.name}</h1>
        <Suspense fallback={<p>Loading stats...</p>}>
          <HeavyStats statsPromise={loaderData.statsPromise} />
        </Suspense>
      </div>
    )
  })
```

```tsx
// src/app/heavy-stats.tsx
'use client'
import { use } from 'react'

export function HeavyStats({ statsPromise }: { statsPromise: Promise<Stats> }) {
  const stats = use(statsPromise)
  return <div>{stats.totalViews} views</div>
}
```

The page skeleton reaches the browser immediately. When the stats promise resolves on the server, the result is flushed into the same flight stream. No extra HTTP round-trip.

## Not found pages

Spiceflow uses `response.status` to set HTTP status codes, and `children === null` in layouts to detect unmatched routes.

```tsx
// Remix
export function loader() {
  const post = await getPost(id)
  if (!post) throw new Response('Not Found', { status: 404 })
  return json({ post })
}

// Spiceflow
.page('/posts/:id', async ({ params, response }) => {
  const post = await getPost(params.id)
  if (!post) {
    response.status = 404
    return <NotFound message={`Post ${params.id} not found`} />
  }
  return <Post post={post} />
})
```


---
title: "Compared with Next.js, Hono, and Elysia"
url: "https://getspiceflow.com/comparisons.md"
description: "How Spiceflow compares to Next.js App Router, Hono, and Elysia. Same stack for APIs and full React RSC apps, with a simpler request model."
---

# Compared with Next.js, Hono, and Elysia

Spiceflow is both a **type-safe API framework** and a **full-stack React RSC framework**. Hono and Elysia stop at HTTP. Next.js is a React app framework with a different routing and caching model. Use this page when you are choosing between them.

```diagram
┌──────────────────┐      ┌────────────────────────┐      ┌───────────────────────┐
│ Hono / Elysia    │      │ Spiceflow              │      │ Next.js App Router    │
│ HTTP APIs        │      │ APIs + React RSC       │      │ File-system React app │
└────────┬─────────┘      └────────────┬───────────┘      └───────────┬───────────┘
         │                             │                              │
         v                             v                              v
 JSON routes, middleware     .get .page .layout .loader     app/**/page.tsx
                             server actions, Link            force-dynamic, cache, PPR
                             Node, Bun, Cloudflare
```

## Next.js App Router

Next.js is the closest comparison on the React side. Spiceflow also ships **React Server Components**, **server actions**, **layouts**, and **client navigation**. The mental model is different.

**No file-based page routing.** Routes are methods on one `Spiceflow` instance. You choose the path. You can colocate an API route and a page, mount a sub-app, or generate routes in a loop. There is no `app/` directory, no `page.tsx` / `layout.tsx` filename contract, and no parallel-route or intercepting-route conventions.

```tsx
export const app = new Spiceflow()
  .get('/api/hello', () => ({ message: 'Hello' }))
  .layout('/*', async ({ children }) => (
    <html>
      <body>{children}</body>
    </html>
  ))
  .page('/', async () => <Home />)
  .page('/posts/:slug', async ({ params }) => <Post slug={params.slug} />)
```

**More flexible.** A route is just a function. Mix JSON APIs, HTML pages, SSE streams, and MCP tools on the same app. Mount another Spiceflow app with `.use()`. That is awkward in App Router, where API routes, pages, and middleware live in separate file conventions.

**More type safety.** `declare module 'spiceflow/react'` registers `typeof app`. Then `router.href()`, `<Link>`, `useLoaderData()`, and `createSpiceflowFetch()` all infer paths, params, query, and response types. Next.js does not type your route table. A renamed `page.tsx` does not fail `tsc` at every stale `<Link href>`.

**No App Router cache or render-mode maze.** There is no `dynamic`, `revalidate`, `fetch` cache, `unstable_cache`, `noStore`, `cookies()` opting a tree into dynamic rendering, or "this component is static except when a child is dynamic." You do not pick RSC payload cache vs full route cache vs data cache.

**Every request re-runs matching layouts.** A GET to `/dashboard/settings` runs the matching loaders, then the matching layouts, then the page. The next GET does the same. Client navigations refetch RSC for the new path. Server actions re-render the current page. There is no hidden static shell that later becomes dynamic.

That is simpler to reason about:

```diagram
request ──> loaders ──> layouts ──> page ──> HTML or RSC
   │
   └──> every stage runs for this request
        no static/dynamic split, no segment cache, no PPR puzzle
```

Other Next.js differences:

* **Same code on Node, Bun, and Cloudflare Workers.** Next.js is built around Node and its own bundler. Spiceflow is Vite plus web `Request` / `Response`.
* **APIs and pages share one router.** In Next you split `app/api` route handlers from `page.tsx`. In Spiceflow both are methods on the same app, so the typed client covers HTML and JSON.
* **Explicit layouts, not folder nesting.** `.layout('/app/*', ...)` wraps that prefix. You see the tree in the chain instead of inferring it from directories.
* **Loaders are first-class.** `.loader()` returns typed data for pages and `useLoaderData()`. You do not thread `fetch` through server components and hope the cache dedupes it.
* **HTTP status codes are real.** `throw redirect('/login')` is a 307 with `Location`. `response.status = 404` is a 404. App Router often returns 200 HTML for redirects and not-found UI.
* **No `'use cache'`, `'use server'` boundary puzzles beyond the usual RSC split.** `"use client"` and `"use server"` still exist. There is no extra cache directive layer.
* **Vite, not Turbopack or webpack.** HMR, plugins, and Cloudflare's Vite plugin work the way other Vite apps do.
* **OpenAPI, MCP, and SSE are built in.** Next does not generate an API spec from your route table.

Spiceflow does **not** try to copy Next's content platform: no built-in image optimizer, no `next/font`, no ISR, no PPR. If you need those, Next still fits. If you want a React RSC app you can hold in your head, Spiceflow is the simpler model.

## Hono

Hono is a small HTTP framework with great middleware and Cloudflare support. Spiceflow shares the **web `Request` / `Response`** style, then goes further.

* **Full React RSC apps**, not only APIs. Hono can render JSX in some setups. It is not a React framework with server components, client navigation, loaders, and server actions.
* **First-class OpenAPI.** Add the `openapi` plugin and the spec is derived from your routes. You do not rewrite handlers with a separate OpenAPI wrapper.
* **Simpler surface.** Handlers use native `Request` and `Response`. There is no `c.text()`, `c.json()`, or `c.req` helper object.
* **Async generators as SSE.** `yield` in a handler streams Server-Sent Events. No extra SSE helper.
* **Zod on the route**, not a `validator()` wrapper that slows TypeScript. `.route({ request, query, params })` is the schema.
* **Faster RPC client inference.** Intellisense on `createSpiceflowFetch()` shows up in milliseconds, not seconds.
* **One type for the whole app.** Pages, loaders, and API routes all feed `SpiceflowRegister`. Hono's RPC client types APIs only.
* **Layouts, `Link`, and `router.href()`.** Hono has no typed app router for HTML.
* **Vite RSC plugin.** Hono + Vite is an API or SSR adapter. Spiceflow's plugin builds rsc, ssr, and client environments from one entry.
* **Same Worker story.** Both run on Cloudflare. Spiceflow also owns the React tree on that Worker.

Use Hono when you want a tiny API or you already have a separate frontend. Use Spiceflow when the API and the React app should be the same program.

## Elysia

Spiceflow started as a **fork of Elysia**, then replaced the parts that made the framework hard to change.

* **Zod instead of TypeBox.** Schema validation uses the library most TypeScript apps already have.
* **No AOT `new Function()` compile.** Elysia generates the app with `eval` / `new Function()`, which [causes bugs](https://github.com/elysiajs/elysia/pull/773) and makes the codebase hard to contribute to. Spiceflow is plain functions over a route tree.
* **Better async generators.** Streaming uses SSE instead of a custom generator protocol.
* **Full React RSC framework.** Elysia is an API framework (Eden Treaty, OpenAPI, plugins). It does not ship pages, layouts, server actions, or a Vite RSC plugin.
* **Cloudflare and Node as first-class targets**, not Bun-first with adapters bolted on.
* **Typed HTML routing.** `router.href('/posts/:slug', { slug })` is checked against the same app that serves `/posts/:slug`.
* **Server actions and forms.** `"use server"` functions are POST endpoints that re-render the page. Elysia has no equivalent.
* **Loaders.** Request-scoped data for a path prefix, available in server and client components.
* **Federation.** One Spiceflow app can render RSC from another origin. That is a React feature, not an HTTP feature.

If you like Elysia's chain API and want React on top, Spiceflow is that chain plus RSC.

## What only Spiceflow combines

| Capability                       | Spiceflow | Next.js | Hono  | Elysia  |
| -------------------------------- | --------- | ------- | ----- | ------- |
| Type-safe HTTP API               | Yes       | Partial | Yes   | Yes     |
| OpenAPI from the route table     | Yes       | No      | Extra | Yes     |
| MCP tools from routes            | Yes       | No      | No    | No      |
| React Server Components          | Yes       | Yes     | No    | No      |
| Server actions that re-render    | Yes       | Yes     | No    | No      |
| Explicit `.page()` / `.layout()` | Yes       | Files   | No    | No      |
| Typed `router.href()` / `Link`   | Yes       | No      | No    | No      |
| Every request re-runs layouts    | Yes       | No      | n/a   | n/a     |
| No App Router cache model        | Yes       | No      | n/a   | n/a     |
| Node, Bun, Cloudflare, same code | Yes       | Partial | Yes   | Partial |

The short version: **Hono and Elysia are APIs. Next.js is a React platform with a cache-heavy App Router. Spiceflow is a full React RSC app and a type-safe API in one chain, and every request just runs the matching layouts again.**


---
title: Use shadcn/ui components with Spiceflow
url: "https://getspiceflow.com/shadcn.md"
description: "Install and structure shadcn/ui in a Spiceflow app using package.json exports instead of tsconfig paths, with a working example you can copy."
---

# Use shadcn/ui components with Spiceflow

How to use [shadcn/ui](https://ui.shadcn.com) with Spiceflow. See [`example-shadcn/`](https://github.com/remorses/spiceflow/tree/main/example-shadcn) for a working example.

## Package exports instead of tsconfig paths

The official shadcn Vite guide tells you to add `paths` to your `tsconfig.json` to map `@/*` to `./src/*`. This is a TypeScript-only hack — it doesn't work at runtime in Node.js, and other workspace packages can't resolve `@/` imports from your code.

Instead, use `package.json` `exports` to define a self-referencing alias. This is an official Node.js feature that Vite, Bun, and all modern bundlers resolve natively:

```json
{
  "name": "my-app",
  "exports": {
    "./src/*": "./src/*"
  }
}
```

Now you can import your own files using the package name:

```tsx
import { Button } from 'my-app/src/components/ui/button.tsx'
import { cn } from 'my-app/src/lib/utils.ts'
```

**Why this is better than `@/*`:**

* **Standard runtime feature** — works in Node.js, Bun, Vite, esbuild, webpack without any special config
* **Cross-workspace imports** — other packages in a pnpm monorepo can import your components using the same path
* **Self-referencing** — Node.js natively supports importing from your own package name via the `exports` field
* **No `resolve.alias` in Vite** — no path hacking in `vite.config.ts`

## tsconfig paths for extensionless imports

The shadcn CLI generates extensionless imports like `from "my-app/src/lib/utils"`. Vite resolves these fine at runtime (it adds extensions automatically), but TypeScript's bundler resolution can't self-resolve the package name without help. Add a single `paths` entry:

```json
{
  "compilerOptions": {
    "moduleResolution": "Bundler",
    "baseUrl": ".",
    "paths": {
      "my-app/src/*": ["./src/*"]
    }
  }
}
```

This gives you the best of both worlds:

* **Package exports** handle runtime resolution in Vite, Node.js, and other workspace packages
* **tsconfig paths** let TypeScript resolve extensionless imports (shadcn CLI output)
* You can write imports with `.ts`/`.tsx` extensions (recommended), and shadcn-generated extensionless imports also typecheck

## Setup

### 1. Install dependencies

```bash
pnpm add @radix-ui/react-slot class-variance-authority clsx tailwind-merge lucide-react
```

### 2. Add package exports

In your `package.json`, add the `exports` field:

```json
{
  "name": "my-app",
  "type": "module",
  "exports": {
    "./package.json": "./package.json",
    "./src/*": "./src/*"
  }
}
```

### 3. Create the cn utility

```ts
// src/lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```

### 4. Add CSS variables

Your `src/globals.css` needs tailwind v4 theme variables for shadcn's semantic color tokens. See [`example-shadcn/src/globals.css`](https://github.com/remorses/spiceflow/blob/main/example-shadcn/src/globals.css) for the full file.

```css
@import 'tailwindcss';

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  /* ... other tokens */
}

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  /* ... other values */
}
```

### 5. Configure components.json

Create a `components.json` at your project root. Aliases use the package name instead of `@/`:

```json
{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "css": "src/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "aliases": {
    "components": "my-app/src/components",
    "utils": "my-app/src/lib/utils",
    "ui": "my-app/src/components/ui",
    "lib": "my-app/src/lib",
    "hooks": "my-app/src/hooks"
  },
  "iconLibrary": "lucide"
}
```

Replace `my-app` with your actual `package.json` `name`.

Setting `rsc: true` makes the shadcn CLI automatically add `"use client"` to interactive components since Spiceflow uses React Server Components by default.

### 6. Add components

```bash
pnpm dlx shadcn@latest add button card
```

The CLI generates extensionless imports — these work thanks to the tsconfig `paths` entry. Your own code can use `.ts`/`.tsx` extensions; both styles resolve correctly.

### 7. Use in your Spiceflow app

```tsx
// src/main.tsx
import './globals.css'
import { Spiceflow } from 'spiceflow'
import { Head } from 'spiceflow/react'
import { Card, CardContent, CardHeader, CardTitle } from 'my-app/src/components/ui/card.tsx'
import { Button } from 'my-app/src/components/ui/button.tsx'

export const app = new Spiceflow()
  .layout('/*', async ({ children }) => {
    return (
      <html lang="en">
        <Head>
          <Head.Title>My App</Head.Title>
        </Head>
        <body className="min-h-screen bg-background font-sans antialiased">
          {children}
        </body>
      </html>
    )
  })
  .page('/', async () => {
    return (
      <Card>
        <CardHeader>
          <CardTitle>Hello</CardTitle>
        </CardHeader>
        <CardContent>
          <Button>Click me</Button>
        </CardContent>
      </Card>
    )
  })
```

## Full tsconfig

```json
{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "Bundler",
    "allowImportingTsExtensions": true,
    "jsx": "preserve",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "resolveJsonModule": true,
    "noImplicitAny": false,
    "baseUrl": ".",
    "paths": {
      "my-app/src/*": ["./src/*"]
    },
    "types": ["vite/client", "node"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"],
  "exclude": ["node_modules", "dist"]
}
```


---
title: "CSRF, authentication, and authorization"
url: "https://getspiceflow.com/security.md"
description: "Treat Spiceflow routes and server actions as public endpoints. Cover CSRF Origin checks, session auth, and authorization for mutating handlers."
---

# CSRF, authentication, and authorization

CSRF protection (the Origin header check on server actions) blocks cross-site form submissions. It does **not** authenticate the caller. Any HTTP client can call a server action or API route directly. Treat every `"use server"` function and every `.get()`/`.post()` route as a public endpoint.

This guide assumes better-auth for authentication and Drizzle for database queries.

## Authentication

### Server actions

`getActionRequest()` gives you the action's own request — use it to resolve the session. Extract a shared `requireSession()` helper so every action is one line:

```ts
// src/auth-helpers.ts
import { getActionRequest } from 'spiceflow'
import { auth } from './auth.js'

export async function requireSession() {
  const request = getActionRequest()
  const session = await auth.api.getSession({ headers: request.headers })
  if (!session) throw new Error('unauthorized')
  return session
}
```

```ts
// src/actions.ts
'use server'
import { ulid } from 'ulid'
import { requireSession } from './auth-helpers.js'

export async function deleteProject(orgId: string, projectId: string) {
  const session = await requireSession()
  // ... authorization check, then mutation
}
```

Call `requireSession()` at the top of every action that reads or mutates user data — before any database call.

### API routes

API routes don't use loaders. Read `state.session` directly, resolved by the session middleware:

```ts
.state('session', null as AuthSession)
.use(async ({ request, state }) => {
  state.session = await auth.api.getSession({ headers: request.headers })
})

.get('/api/projects', ({ state }) => {
  if (!state.session) return new Response('Unauthorized', { status: 401 })
  // ...
})
```

### Pages and loaders

Loaders and pages can redirect unauthenticated users to the login page. A layout guard protects all nested pages at once:

```ts
.layout('/app/*', async ({ loaderData, children }) => {
  if (!loaderData.session) throw redirect('/login')
  return <AppLayout>{children}</AppLayout>
})
```

## Authorization

Authentication proves *who* the caller is. Authorization proves *they own the resource*. Always check both.

### Owner check

Fetch the resource only if it belongs to the current user. A single query covers both lookup and ownership:

```ts
export async function deleteProject(orgId: string, projectId: string) {
  const session = await requireSession()

  // Only returns the project if the user owns the parent org
  const project = await db.query.project.findFirst({
    where: {
      id: projectId,
      orgId,
      organization: { ownerId: session.user.id },
    },
  })
  if (!project) throw new Error('not found or forbidden')

  await db.delete(schema.project).where(orm.eq(schema.project.id, projectId))
}
```

### Membership check

For multi-tenant apps where many users can belong to an org, check membership before accessing any resource in that org:

```ts
export async function createProject(orgId: string, name: string) {
  const session = await requireSession()

  // Verify user is a member of the org
  const membership = await db.query.orgMember.findFirst({
    where: { orgId, userId: session.user.id },
  })
  if (!membership) throw new Error('forbidden')

  const id = ulid()
  await db.insert(schema.project).values({ id, name, orgId, createdAt: new Date() })
  return { id, name, orgId }
}
```

Do the same check in loaders before returning org data:

```ts
.loader('/orgs/:orgId/dashboard', async ({ params, state }) => {
  if (!state.session) return { org: null, projects: [] }

  const membership = await db.query.orgMember.findFirst({
    where: { orgId: params.orgId, userId: state.session.user.id },
  })
  if (!membership) return { org: null, projects: [] }

  const projects = await db.query.project.findMany({
    where: { orgId: params.orgId },
  })
  return { org: { id: membership.orgId }, projects }
})
```

## Input Validation

Validate all user-supplied input before using it — before any database call. Use `parseFormData` for forms and Zod for JSON:

```ts
import { z } from 'zod'
import { parseFormData } from 'spiceflow'

const createProjectSchema = z.object({
  orgId: z.string().min(1),
  name: z.string().min(1).max(100),
})

export async function createProject(formData: FormData) {
  const session = await requireSession()
  const { orgId, name } = parseFormData(createProjectSchema, formData)
  // orgId and name are now validated strings — safe to use in queries
}
```

For API routes with a request body schema, spiceflow validates automatically:

```ts
.route({
  method: 'POST',
  path: '/api/projects',
  request: z.object({ orgId: z.string().min(1), name: z.string().min(1).max(100) }),
  async handler({ body, state }) {
    if (!state.session) return new Response('Unauthorized', { status: 401 })
    // body is validated and typed
  },
})
```

## Data Isolation

Never query a list without scoping it to the current user. Always filter by the user's identity or membership:

```ts
// Scoped to the current user's orgs only
const orgs = await db.query.organization.findMany({
  where: { ownerId: session.user.id },
})

// With membership table — only orgs the user belongs to
const orgs = await db.query.organization.findMany({
  where: { members: { userId: session.user.id } },
})
```

## Testing Security

Tests should explicitly verify that unauthenticated requests and cross-user access are rejected. See [Testing with Vitest](/testing) for the full testing setup, especially the "Testing with Authentication" and "Testing with better-auth" sections.

Two patterns to always cover:

**Unauthenticated access** — verify the action or route rejects requests without a session:

```ts
test('unauthenticated user cannot access protected route', async () => {
  const anon = createSpiceflowFetch(app)
  const result = await anon('/api/projects')
  expect(result).toBeInstanceOf(Error)
})
```

**Cross-user access** — verify user B cannot read or mutate user A's resources:

```ts
test('user cannot delete another users project', async () => {
  // Set up Alice with an org and project
  const aliceToken = await signupAndGetToken('alice@example.com')
  const alice = createSpiceflowFetch(app, { headers: { authorization: `Bearer ${aliceToken}` } })
  const org = await runAction(() => createOrg('Alice Org'), { request: authedRequest(aliceToken) })
  const project = await runAction(() => createProject(org.id, 'Alice Project'), { request: authedRequest(aliceToken) })

  // Bob tries to delete Alice's project
  const bobToken = await signupAndGetToken('bob@example.com')
  const error = await runAction(
    () => deleteProject(org.id, project.id),
    { request: authedRequest(bobToken) },
  ).catch((e) => e)

  expect(error).toBeInstanceOf(Error)
  expect(error.message).toMatch(/forbidden|not found/)
})
```

Use `runAction` with a custom request to pass auth tokens to actions that call `getActionRequest()`. See [example-better-auth tests](https://github.com/remorses/spiceflow/blob/main/example-better-auth/src/main.test.ts) for a complete working example covering these patterns.

## Error Responses

Unhandled API errors return a JSON body with the message and **stack**. Secrets inside them (tokens, API keys, connection string passwords) are always redacted. To hide stack traces from clients, create the root app with `noStackTraces`:

```ts
const app = new Spiceflow({ noStackTraces: true })
```

See [Stack traces and redaction](/api#stack-traces-and-redaction) for what is redacted.


---
title: Generate OpenAPI 3.2 docs from Zod
url: "https://getspiceflow.com/openapi.md"
description: "Generate an OpenAPI 3.2 document from Spiceflow Zod schemas, including response maps, SSE streams, hidden routes, reusable models, and typed fetch errors."
---

# Generate OpenAPI 3.2 docs from Zod

Spiceflow generates a full OpenAPI 3.2 document from your routes without you writing a single line of schema by hand. The `openapi` plugin walks every registered route and uses the Zod schemas you already passed to `request`, `query`, `params`, and `response` to produce the `paths`, `parameters`, `requestBody`, and `responses` sections for you.

There is nothing "magical" or decorator-based here — the same Zod schemas that validate the request at runtime and give you a typed handler context are also the input for the OpenAPI output.

## Basic Usage

Mount the plugin with `.use(openapi({ path: '/openapi.json' }))` and every other route registered on the app (before or after the plugin) is picked up automatically when a client requests that path:

```ts
import { Spiceflow } from 'spiceflow'
import { openapi } from 'spiceflow/openapi'
import { z } from 'zod'

export const app = new Spiceflow()
  .use(
    openapi({
      path: '/openapi.json',
      info: {
        title: 'My API',
        version: '1.0.0',
      },
    }),
  )
  .route({
    method: 'GET',
    path: '/users/:id',
    params: z.object({ id: z.string() }),
    response: z.object({
      id: z.string(),
      name: z.string(),
    }),
    handler({ params }) {
      return { id: params.id, name: 'Alice' }
    },
  })

const schema = await app
  .handle(new Request('http://localhost/openapi.json'))
  .then((res) => res.json())
```

The returned document is a standard `OpenAPIV3.Document` you can hand off to Swagger UI, Redoc, Fern, Stainless, or any other OpenAPI tool.

## Define Inputs With Zod

The preferred way to describe a route is to pass Zod schemas for every piece of input. This gives you **three things at once** with zero duplication:

1. **Runtime validation** — requests with a bad body, query, or params are rejected with a `400`.
2. **Type-safe handler context** — `request.json()`, `query`, and `params` are typed from the same schemas.
3. **OpenAPI output** — `requestBody`, `parameters`, and `responses` are emitted directly from the schemas.

```ts
import { Spiceflow } from 'spiceflow'
import { openapi } from 'spiceflow/openapi'
import { z } from 'zod'

export const app = new Spiceflow()
  .use(openapi({ path: '/openapi.json' }))
  .route({
    method: 'POST',
    path: '/users/:orgId',
    params: z.object({ orgId: z.string() }),
    query: z.object({
      notify: z.boolean().optional(),
    }),
    request: z.object({
      name: z.string(),
      email: z.string().email(),
    }),
    response: z.object({
      id: z.string(),
      name: z.string(),
      email: z.string(),
    }),
    async handler({ params, query, request }) {
      // params is typed as { orgId: string }
      // query is typed as { notify?: boolean }
      // body is typed as { name: string, email: string }
      const body = await request.json()
      return {
        id: 'usr_123',
        name: body.name,
        email: body.email,
      }
    },
  })
```

> **Always prefer `.route()` over `.get()` / `.post()` for public APIs.** The `.route()` form takes every schema key (`request`, `response`, `query`, `params`, `detail`) in a single object, which is easier to discover and keeps the schemas attached to the handler where they belong. Use `.get()` / `.post()` only for quick internal routes that do not need validation or OpenAPI.

## Status-Code Response Map

When a route can return different payloads depending on the status code, pass `response` as an object keyed by HTTP status codes instead of a single schema. Each entry becomes its own entry in the emitted OpenAPI `responses` object:

```ts
import { z } from 'zod'
import { Spiceflow, json } from 'spiceflow'
import { openapi } from 'spiceflow/openapi'

const ErrorShape = z.object({
  message: z.string(),
  code: z.string(),
})

export const app = new Spiceflow()
  .use(openapi({ path: '/openapi.json' }))
  .route({
    method: 'GET',
    path: '/users/:id',
    params: z.object({ id: z.string() }),
    response: {
      200: z.object({
        id: z.string(),
        name: z.string(),
      }),
      404: ErrorShape,
      500: ErrorShape,
    },
    handler({ params }) {
      const user = findUser(params.id)
      if (!user) {
        throw json(
          { message: 'not found', code: 'NOT_FOUND' },
          { status: 404 },
        )
      }
      return { id: user.id, name: user.name }
    },
  })
```

`json(body, init)` is a type-safe wrapper around `Response.json()` — it sets `content-type: application/json` automatically and carries the data type and status code through the type system. Prefer `json()` over `Response.json()` so the fetch client gets typed error responses.

**Name the human-readable field `message`, never `error`.** `SpiceflowFetchError` builds its `.message` from that key, so callers read `err.message` like on any other `Error`. With any other key the client falls back to `JSON.stringify(body)` and `err.message` becomes a raw JSON blob. Machine-readable data goes in sibling fields such as `code`.

**Use the status-code map whenever you already know which non-2xx status codes your route can return and there is a realistic possibility of errors.** Consumers of your API — and tools like Fern or Stainless that generate SDKs from the OpenAPI document — get precise types for each failure mode instead of a generic "it might fail".

The client side also benefits: `createSpiceflowFetch` reads this map to produce a discriminated error type per status code. See [Preserving Client Type Safety](#preserving-client-type-safety) below.

## Centralized Error Responses With `onError`

In most real APIs the same handful of error shapes show up on every route — usually a `500` for unexpected failures, a `401` for missing auth, maybe a `400` for validation. Spiceflow has a centralized `onError` handler that runs for every thrown error, which pairs naturally with a **shared response object you spread into each route**.

Define the error schema and the corresponding response entry in one module, then spread it into every route:

```ts
// src/api/shared-responses.ts
import { z } from 'zod'

export const ErrorResponse = z.object({
  message: z.string(),
  code: z.string(),
  requestId: z.string().optional(),
})

// Shared response entries that every route should document.
export const commonResponses = {
  500: ErrorResponse,
  401: ErrorResponse,
} as const
```

```ts
// src/api/app.ts
import { Spiceflow, json } from 'spiceflow'
import { openapi } from 'spiceflow/openapi'
import { z } from 'zod'
import { commonResponses, ErrorResponse } from './shared-responses'

export const app = new Spiceflow()
  .use(openapi({ path: '/openapi.json' }))
  .onError(({ error, request }) => {
    console.error('[api]', request.url, error)
    return json(
      {
        message: error.message || 'internal server error',
        code: 'INTERNAL',
      },
      { status: 500 },
    )
  })
  .route({
    method: 'GET',
    path: '/users/:id',
    params: z.object({ id: z.string() }),
    response: {
      ...commonResponses,
      200: z.object({ id: z.string(), name: z.string() }),
      404: ErrorResponse,
    },
    handler({ params }) {
      const user = findUser(params.id)
      if (!user) {
        throw json(
          { message: 'not found', code: 'NOT_FOUND' },
          { status: 404 },
        )
      }
      return user
    },
  })
  .route({
    method: 'POST',
    path: '/users',
    request: z.object({ name: z.string(), email: z.string().email() }),
    response: {
      ...commonResponses,
      200: z.object({ id: z.string() }),
    },
    async handler({ request }) {
      const body = await request.json()
      return { id: 'usr_' + body.name }
    },
  })
```

Every route now documents the shared `500` and `401` responses without duplicating the schema, and the actual `500` body is produced by a single `onError` handler at the app root. If you later change the error shape, you only edit `shared-responses.ts`.

> **Spread `...commonResponses` first, then list route-specific entries.** Later keys win in object spread, so putting the shared responses at the top lets an individual route override, say, the shared `500` with a more specific schema if it ever needs to — and leaves the route-specific entries like `200` and `404` visually grouped at the bottom.

## Reusing Schemas Across Routes

When the same type appears in more than one route — `User`, `Organization`, `PaginatedList<T>` — **define the Zod schema once in a dedicated file and import it**. Do not inline the same object in multiple routes; besides the duplication, you lose the ability to rename a field in one place.

```ts
// src/schemas.ts
import { z } from 'zod'

export const User = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
})

export type User = z.infer<typeof User>

export const UserList = z.object({
  items: z.array(User),
  nextCursor: z.string().nullable(),
})
```

```ts
// src/api/users.ts
import { Spiceflow } from 'spiceflow'
import { z } from 'zod'
import { User, UserList } from '../schemas'
import { commonResponses } from './shared-responses'

export const usersApi = new Spiceflow({ basePath: '/users' })
  .route({
    method: 'GET',
    path: '/',
    query: z.object({ cursor: z.string().optional() }),
    response: { ...commonResponses, 200: UserList },
    handler: () => ({ items: [], nextCursor: null }),
  })
  .route({
    method: 'GET',
    path: '/:id',
    params: z.object({ id: z.string() }),
    response: { ...commonResponses, 200: User },
    handler: ({ params }) => ({
      id: params.id,
      name: 'Alice',
      email: 'alice@example.com',
      createdAt: new Date().toISOString(),
    }),
  })
```

Any schema that appears in more than one route should live in a single `src/schemas.ts` file (or wherever you keep domain types) and be imported everywhere it is used. Keep it as one flat file until it actually grows too large — a single `schemas.ts` is easier to navigate than a `schemas/` folder with one file per type.

## Streaming Routes (SSE)

Async generator routes stream **Server-Sent Events**. Their `response` schema describes **one yielded item**, not the whole body. Spiceflow writes it as an OpenAPI 3.2 **`itemSchema`** on `text/event-stream`:

```ts
new Spiceflow().route({
  method: 'GET',
  path: '/progress',
  response: z.object({ progress: z.number() }),
  async *handler() {
    yield { progress: 0.5 }
    yield { progress: 1 }
  },
})
```

```yaml
responses:
  '200':
    content:
      text/event-stream:
        itemSchema:
          oneOf:
            - required: [event, data]
              properties:
                event: { const: message }
                data:
                  type: string
                  contentMediaType: application/json
                  contentSchema: { type: object, properties: { progress: { type: number } } }
            - required: [event, data]
              properties:
                event: { const: error }
                data: { type: string, contentMediaType: application/json }
            - required: [event]
              properties:
                event: { const: done }
```

Each `yield` is one `event: message` with the item as JSON `data`. A thrown error ends the stream with `event: error` and the error body as `data`. A generator that returns without yielding ends with `event: done`, which has no data. Other statuses in a status-code response map stay JSON responses, for errors thrown before the stream starts.

## Hiding Routes From the Document

Pass `detail: { hide: true }` on any route you do not want to appear in the generated OpenAPI document. This is useful for internal health checks, debug endpoints, and routes that exist purely for server-to-server communication:

```ts
import { Spiceflow } from 'spiceflow'
import { openapi } from 'spiceflow/openapi'

export const app = new Spiceflow()
  .use(openapi({ path: '/openapi.json' }))
  .route({
    method: 'GET',
    path: '/health',
    detail: { hide: true },
    handler: () => ({ ok: true }),
  })
  .route({
    method: 'POST',
    path: '/internal/reindex',
    detail: { hide: true },
    handler: () => ({ queued: true }),
  })
```

Hidden routes still work normally at runtime — they just do not show up in the emitted schema.

## Custom Descriptions With Markdown

The `detail` field accepts anything from the OpenAPI operation object, so you can attach `summary`, `description`, `tags`, `operationId`, and any vendor extension. For anything longer than a single line, write the description as a real multi-line string using the [`string-dedent`](https://www.npmjs.com/package/string-dedent) package so your code stays readable and the leading indentation is stripped automatically.

```ts
import { Spiceflow } from 'spiceflow'
import { openapi } from 'spiceflow/openapi'
import { z } from 'zod'
import dedent from 'string-dedent'
import { User } from '../schemas'
import { commonResponses } from './shared-responses'

export const app = new Spiceflow()
  .use(openapi({ path: '/openapi.json' }))
  .route({
    method: 'POST',
    path: '/users',
    request: z.object({
      name: z.string(),
      email: z.string().email(),
    }),
    response: { ...commonResponses, 200: User },
    detail: {
      summary: 'Create a user',
      tags: ['users'],
      description: dedent`
        Creates a new user in the current organization.

        ## Behavior

        - The \`email\` field must be unique across the organization.
          If it already exists, the endpoint returns \`409 Conflict\`.
        - The returned \`id\` is a stable prefix-encoded identifier
          (\`usr_\` prefix + 24 base32 characters).

        ## Example

        \`\`\`bash
        curl -X POST https://api.example.com/users \\
          -H 'content-type: application/json' \\
          -d '{ "name": "Alice", "email": "alice@example.com" }'
        \`\`\`
      `,
    },
    async handler({ request }) {
      const body = await request.json()
      return {
        id: 'usr_abc',
        name: body.name,
        email: body.email,
        createdAt: new Date().toISOString(),
      }
    },
  })
```

Markdown in the description is rendered by Swagger UI, Redoc, Scalar, Fern, and Stainless, so you can include headings, bullets, inline code, and fenced code blocks.

## Writing the Schema to a Local File

You often want a physical `openapi.json` file checked into the repo — for SDK generation, for docs sites, or for diffing the schema on every PR. The cleanest way to produce one is to call `app.handle()` directly instead of spinning up a real HTTP server:

```ts
// scripts/generate-openapi.ts
import { writeFile } from 'node:fs/promises'
import { app } from '../src/api/app'

const response = await app.handle(
  new Request('http://localhost/openapi.json'),
)
const schema = await response.json()

await writeFile('openapi.json', JSON.stringify(schema, null, 2))
console.log('Wrote openapi.json')
```

Run it with any TypeScript runner:

```bash
tsx scripts/generate-openapi.ts
# or
bun scripts/generate-openapi.ts
```

Wire this into your build so the file is always in sync with the code:

```json
{
  "scripts": {
    "openapi": "tsx scripts/generate-openapi.ts",
    "prebuild": "pnpm openapi"
  }
}
```

If you prefer a type-safe call, use `createSpiceflowFetch` against the app instance (no real network, no server) — see the [fetch client docs](/fetch-client#server-side-fetch) for a worked example.

## Preserving Client Type Safety

The [type-safe fetch client](/fetch-client) reads your route schemas the same way the OpenAPI plugin does, so the way you declare `response` directly affects what the client sees.

**Rule of thumb:** success responses should be **returned** from the handler, and non-2xx responses should be **thrown** using `json()`. Use `json()` from `spiceflow` instead of `Response.json()` — it preserves the data type and status code in the type system, so TypeScript validates that the status exists in the response schema and the body shape matches. `Response.json()` erases all type information.

When you throw non-successful responses, the client inference stays clean: the happy-path return type is just `Data`, and errors are delivered as a `SpiceflowFetchError` that `instanceof Error` narrows away.

```ts
// src/api/app.ts
import { Spiceflow, json } from 'spiceflow'
import { z } from 'zod'

const NotFound = z.object({ message: z.literal('not found') })
const Forbidden = z.object({ message: z.literal('forbidden'), reason: z.string() })

export const app = new Spiceflow().route({
  method: 'GET',
  path: '/users/:id',
  params: z.object({ id: z.string() }),
  response: {
    200: z.object({ id: z.string(), name: z.string() }),
    403: Forbidden,
    404: NotFound,
  },
  handler({ params }) {
    if (params.id === 'banned') {
      throw json(
        { message: 'forbidden', reason: 'account suspended' },
        { status: 403 },
      )
    }
    const user = findUser(params.id)
    if (!user) {
      throw json({ message: 'not found' }, { status: 404 })
    }
    // Returned directly — the fetch client will type this as the success case only.
    return { id: user.id, name: user.name }
  },
})

export type App = typeof app
```

On the client side, the fetch client gives you back a discriminated `Error | Data` union. The error half is typed from the status-code response map: for each non-200 status you defined, the client gets a `SpiceflowFetchError<status, body>` with the **exact body shape** that status returns.

```ts
// src/client.ts
import { createSpiceflowFetch } from 'spiceflow/client'

const api = createSpiceflowFetch('https://api.example.com')

const result = await api('/users/:id', { params: { id: 'abc' } })

if (result instanceof Error) {
  // result is typed as
  //   | SpiceflowFetchError<403, { message: 'forbidden'; reason: string }>
  //   | SpiceflowFetchError<404, { message: 'not found' }>
  switch (result.status) {
    case 403:
      // result.value is { message: 'forbidden'; reason: string }
      console.error('Forbidden:', result.value.reason)
      break
    case 404:
      // result.value is { message: 'not found' }
      console.error('User not found')
      break
  }
  return
}

// result is typed as { id: string; name: string } — no error shape leaked in
console.log('User:', result.id, result.name)
```

Three things fall out of this pattern:

1. **The success branch is clean.** `result.id` and `result.name` are available without null checks or type narrowing on error fields.
2. **Every error status is exhaustively typed.** If you add a new status to the route's `response` map, TypeScript forces you to handle it on the client.
3. **The OpenAPI document is accurate.** The same map that drives the client types also drives the `responses` object in `openapi.json`, so your SDK consumers see exactly the same contract.

If a route has no documented non-2xx responses at all, the client falls back to a generic `SpiceflowFetchError<number, any>` for the error branch — which still works, but loses per-status typing. Add status entries to `response` whenever you care about the failure shape.


---
title: Expose API routes as MCP tools
url: "https://getspiceflow.com/mcp.md"
description: "Mount the Model Context Protocol plugin so AI clients can call Spiceflow routes as tools and resources over SSE, with input validation included."
---

# Expose API routes as MCP tools

Spiceflow includes a Model Context Protocol (MCP) plugin that exposes your API routes as tools and resources that can be used by AI language models like Claude. The MCP plugin makes it easy to let AI assistants interact with your API endpoints in a controlled way.

When you mount the MCP plugin (default path is `/mcp`), it automatically:

* Exposes all your routes as callable tools with proper input validation
* Exposes GET routes without query/path parameters as `resources`
* Provides an SSE-based transport for real-time communication
* Handles serialization of requests and responses

This makes it simple to let AI models like Claude discover and call your API endpoints programmatically.

## Basic MCP Usage

Here's an example:

```tsx
// Import the MCP plugin and client
import { mcp } from 'spiceflow/mcp'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { Spiceflow } from 'spiceflow'
import {
  ListToolsResultSchema,
  CallToolResultSchema,
  ListResourcesResultSchema,
} from '@modelcontextprotocol/sdk/types.js'

// Create a new app with some example routes
export const app = new Spiceflow()
  // Mount the MCP plugin at /mcp (default path)
  .use(mcp())
  // These routes will be available as tools
  .route({
    method: 'GET',
    path: '/hello',
    handler() {
      return 'Hello World'
    },
  })
  .route({
    method: 'GET',
    path: '/users/:id',
    handler({ params }) {
      return { id: params.id }
    },
  })
  .route({
    method: 'POST',
    path: '/echo',
    async handler({ request }) {
      const body = await request.json()
      return body
    },
  })

// Start the server
app.listen(3000)

// Example client usage:
const transport = new SSEClientTransport(new URL('http://localhost:3000/mcp'))

const client = new Client(
  { name: 'example-client', version: '1.0.0' },
  { capabilities: {} },
)

await client.connect(transport)

// List available tools
const tools = await client.request(
  { method: 'tools/list' },
  ListToolsResultSchema,
)

// Call a tool
const result = await client.request(
  {
    method: 'tools/call',
    params: {
      name: 'GET /hello',
      arguments: {},
    },
  },
  CallToolResultSchema,
)

// List available resources (only GET /hello is exposed since it has no params)
const resources = await client.request(
  { method: 'resources/list' },
  ListResourcesResultSchema,
)
```

## Existing MCP Servers

If you already have an existing MCP server and want to add Spiceflow route tools to it, use the `addMcpTools` helper function:

```ts
import { addMcpTools } from 'spiceflow/mcp'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'

import { Spiceflow } from 'spiceflow'

// Your existing MCP server
const existingServer = new Server(
  { name: 'my-server', version: '1.0.0' },
  { capabilities: { tools: {}, resources: {} } },
)

// Your Spiceflow app
export const app = new Spiceflow()
  .use(mcp()) // Required for MCP configuration
  .route({
    method: 'GET',
    path: '/hello',
    handler() {
      return 'Hello from Spiceflow!'
    },
  })

// Add Spiceflow tools to your existing server
const mcpServer = await addMcpTools({
  mcpServer: existingServer,
  app,
  ignorePaths: ['/mcp', '/sse'],
})

// Now your existing server has access to all Spiceflow routes as tools
```


---
title: Typed fetch client for Spiceflow routes
url: "https://getspiceflow.com/fetch-client.md"
description: "createSpiceflowFetch infers path params, query, body, and response types from your routes. Use the same client in the browser or on the server."
---

# Typed fetch client for Spiceflow routes

`createSpiceflowFetch` provides a type-safe `fetch(path, options)` interface for calling your Spiceflow API. It gives you full type safety on **path params**, **query params**, **request body**, and **response data** — all inferred from your route definitions.

## Basic Usage

Export the app type from your server code:

```ts
// server.ts
import { Spiceflow } from 'spiceflow'
import { z } from 'zod'

export const app = new Spiceflow()
  .route({
    method: 'GET',
    path: '/hello',
    handler() {
      return 'Hello, World!'
    },
  })
  .route({
    method: 'POST',
    path: '/users',
    request: z.object({
      name: z.string(),
      email: z.string().email(),
    }),
    async handler({ request }) {
      const body = await request.json()
      return { id: '1', name: body.name, email: body.email }
    },
  })
  .route({
    method: 'GET',
    path: '/users/:id',
    handler({ params }) {
      return { id: params.id }
    },
  })
  .route({
    method: 'GET',
    path: '/search',
    query: z.object({ q: z.string(), page: z.coerce.number().optional() }),
    handler({ query }) {
      return { results: [], query: query.q, page: query.page }
    },
  })
  .route({
    method: 'GET',
    path: '/stream',
    async *handler() {
      yield 'Start'
      yield 'Middle'
      yield 'End'
    },
  })

export type App = typeof app
```

Then use `createSpiceflowFetch` on the client side — when `SpiceflowRegister` is set, the fetch client is fully typed without importing server code:

```ts
// client.ts
import { createSpiceflowFetch } from 'spiceflow/client'

const safeFetch = createSpiceflowFetch('http://localhost:3000')

// GET request — returns Error | Data, check with instanceof Error
const greeting = await safeFetch('/hello')
if (greeting instanceof Error) return greeting
console.log(greeting) // 'Hello, World!' — TypeScript knows the type

// POST with typed body — TypeScript requires { name: string, email: string }
const user = await safeFetch('/users', {
  method: 'POST',
  body: { name: 'John', email: 'john@example.com' },
})
if (user instanceof Error) return user
console.log(user.id, user.name, user.email) // fully typed

// Path params — type-safe, TypeScript requires { id: string }
const foundUser = await safeFetch('/users/:id', {
  params: { id: '123' },
})
if (foundUser instanceof Error) return foundUser
console.log(foundUser.id) // typed as string

// Query params — typed from the route's Zod schema
const searchResults = await safeFetch('/search', {
  query: { q: 'hello', page: 1 },
})
if (searchResults instanceof Error) return searchResults
console.log(searchResults.results, searchResults.query) // fully typed

// Streaming — async generator routes return an AsyncGenerator
const stream = await safeFetch('/stream')
if (stream instanceof Error) return stream
for await (const chunk of stream) {
  console.log(chunk) // 'Start', 'Middle', 'End'
}
```

The fetch client returns `Error | Data` directly following the [errore](https://errore.org) convention — use `instanceof Error` to check for errors with Go-style early returns, then the happy path continues with the narrowed data type. No `{ data, error }` destructuring, no null checks. On error, the returned `SpiceflowFetchError` has `status`, `value` (the parsed error body), and `response` (the raw Response object) properties.

For headers, hooks, retries, and server-side usage keep reading below. To support types like `Date`, `Map`, `Set`, and `BigInt` across the wire, see [Custom Serialization](/custom-serialization).

## Common Pitfalls

**Never interpolate path params into the path string.** Use `:param` syntax with a `params` object. Interpolated template literals resolve to `string`, which breaks type inference on params, body, and response.

```ts
// GOOD: typed path, typed params, typed response
const res = await safeFetch('/api/users/:id', {
  params: { id: '123' },
})

// BAD: path is just `string`, response is unknown, body unchecked
const res = await safeFetch(`/api/users/${id}`)
```

**All packages in a monorepo must use the exact same spiceflow version.** The `App` type carries private class properties from the `Spiceflow` class. If the CLI resolves spiceflow\@1.19.0-rsc.3 but the server uses spiceflow\@1.19.0-rsc.4, TypeScript sees two different class declarations and rejects the generic with `Types have separate declarations of a private property`.

**Never `return new Response(...)` in route handlers.** A raw `Response` in the return type erases the body type. The fetch client sees `Response | { id, name }` and can't infer the typed object. Use `return json(...)` instead (preserves body type and status), or `throw` anything (`throw new Response(...)`, `throw json(...)`) since thrown values don't affect the return type.

```ts
// GOOD: return json() preserves the error body type
handler() {
  if (!user) return json({ message: 'not found' }, { status: 404 })
  return { id: user.id, name: user.name }
}

// GOOD: throw anything is fine, doesn't pollute return type
handler() {
  if (!user) throw new Response('not found', { status: 404 })
  return { id: user.id, name: user.name }
}

// BAD: return new Response erases the body type
handler() {
  if (!user) return new Response('not found', { status: 404 })
  return { id: user.id, name: user.name }
}
```

**Route handlers must return objects for the response to be typed.** If a handler returns `res.json()` (which is `Promise<any>`), the typed fetch client gets `any` for the response. Return plain objects instead.

```ts
// GOOD: return type is inferred as { id: string, name: string }
handler() {
  return { id: user.id, name: user.name }
}

// BAD: return type is any, fetch client loses response type
handler() {
  return externalApi.json()
}
```

<Expandable title={<Markdown inline children="Detailed monorepo typed fetch guide" />}>
  The typed fetch client only feels good when the server app exposes a clean type boundary and the client calls it in the intended style. Most weird TypeScript failures come from one of four mistakes:

  1. importing the wrong app type
  2. interpolating params into the path string
  3. manually serializing the body
  4. ignoring the `Error | Data` return type

  ### Basic setup

  ```ts
  import { createSpiceflowFetch } from 'spiceflow/client'
  import type { App } from '@my-org/website/src/app.tsx'

  export const safeFetch = createSpiceflowFetch<App>('https://example.com', {
    headers: {
      Authorization: `Bearer ${token}`,
    },
  })
  ```

  ### Good vs bad path usage

  Use the declared route pattern and pass params separately:

  ```ts
  // GOOD
  const res = await safeFetch('/api/v0/orgs/:orgId/projects', {
    params: { orgId: 'org_123' },
  })

  // BAD
  const res = await safeFetch(`/api/v0/orgs/${orgId}/projects`)
  ```

  The bad version turns the path into a generic `string`, so TypeScript can no longer connect it to the route table.

  ### POST bodies

  Pass `body` as a plain object. The client handles JSON serialization and headers.

  ```ts
  // GOOD
  const res = await safeFetch('/api/v0/orgs/:orgId/projects', {
    method: 'POST',
    params: { orgId: 'org_123' },
    body: { slug: 'frontend' },
  })

  // BAD
  const res = await safeFetch('/api/v0/orgs/:orgId/projects', {
    method: 'POST',
    params: { orgId: 'org_123' },
    body: JSON.stringify({ slug: 'frontend' }),
    headers: { 'Content-Type': 'application/json' },
  })
  ```

  ### Handling the response correctly

  The return type is always `Error | Data`.

  ```ts
  const res = await safeFetch('/api/v0/projects/:projectId/query', {
    method: 'POST',
    params: { projectId: 'proj_123' },
    body: { sql: 'SELECT 1' },
  })

  if (res instanceof Error) {
    throw res
  }

  console.log(res.rows)
  console.log(res.data)
  ```

  Do not skip the `instanceof Error` check.

  ### Cross-workspace imports

  In a monorepo, import API types from **source files**, not built `dist/*.d.ts` files:

  ```ts
  import type { App } from '@my-org/website/src/app.tsx'
  ```

  This avoids build-order dependencies. The server doesn't need to build before the client can typecheck, which matters in CI where packages build in parallel.

  The tradeoff is that tsc walks the server's full import graph and may hit unresolvable modules like `cloudflare:workers` or CSS imports. Fix these with a small ambient `.d.ts` stub in the client package:

  ```ts
  // client/src/server-ambient.d.ts
  declare module 'cloudflare:workers' {
    const env: any
    export { env }
  }
  declare module '*.css' {}
  type ExportedHandler = any
  ```

  Keep the server package as a `devDependency` of the client package for typechecking.

  ### Server-side requirements for good fetch inference

  These server handlers infer well:

  ```ts
  import { Spiceflow, json } from 'spiceflow'

  export const app = new Spiceflow()
    .get('/api/health', () => {
      return { ok: true }
    })
    .route({
      method: 'POST',
      path: '/api/v0/projects/:projectId/query',
      async handler() {
        return { rows: 1, data: [{ value: 1 }] }
      },
    })
    .get('/api/not-found', () => {
      throw json({ message: 'not found' }, { status: 404 })
    })
  ```

  These lose useful inference:

  ```ts
  // BAD
  return Response.json({ ok: true })

  // BAD
  return new Response('ok')
  ```

  ### Monorepo checklist

  When typed fetch gets weird, check these first:

  * server and client packages use the **same Spiceflow version**
  * client imports the app type from **source files** with `import type`
  * add ambient `.d.ts` stubs for unresolvable server-only modules (e.g. `cloudflare:workers`, `*.css`)
  * client uses route patterns like `'/users/:id'` with `params`, never interpolated strings
</Expandable>

## WebMCP Tools

Use `installWebMcp` to register API routes as browser WebMCP tools. The installer loads your OpenAPI document once, converts route inputs into JSON Schema, and executes tools through the supplied fetch client.

```ts
import { createSpiceflowFetch, installWebMcp } from 'spiceflow/client'

const api = createSpiceflowFetch('https://api.example.com', {
  headers: () => ({ Authorization: `Bearer ${getToken()}` }),
})

const uninstall = await installWebMcp({
  fetch: api,
  openapiPath: '/openapi',
  include: [
    { method: 'GET', path: '/users/:id' },
    { method: 'POST', path: '/users' },
  ],
  exclude: [{ method: 'DELETE', path: '/users/:id' }],
})
```

The server must mount `openapi()` at the configured path. By default, the installer exposes only operations with a body, query, or path schema. Supplying `include` changes this to an exact whitelist, including inputless routes when selected. `exclude` runs afterward and always wins.

Route selectors use Spiceflow paths such as `/users/:id`. Leave out `method` to match every method at that exact path. Matching is exact and never treats a path as a prefix.

Tool names use `detail.operationId` when present. Otherwise, the installer derives a name from the method and path. Add distinct `operationId` values when two derived names would collide.

The installer reads the current document's tools before registration. A user-defined tool with the same name is left unchanged. The returned function unregisters only tools added by that installer call. Setup errors are logged to the console and return a no-op cleanup function, so WebMCP cannot block application startup.

Tool results must be JSON-serializable. If an `onResponse` hook returns custom values such as `Map`, `Set`, or `BigInt`, convert them to plain JSON values before exposing that route.

<Expandable title={<Markdown inline children="WebMCP security rules" />}>
  WebMCP does not authenticate or authorize requests. Every exposed API route must still check the current user and their permissions. The installer does not expose header or cookie inputs to agents; credentials come from the fetch client and normal browser cookie handling.

  WebMCP is available only in supported secure browser contexts. Unsupported browsers return a no-op cleanup function without fetching the OpenAPI document. Cross-origin OpenAPI and route requests need the appropriate CORS configuration.
</Expandable>

## Headers

Set headers globally on the client, per request, or dynamically with a function. Per-request headers are merged with global headers.

```ts
import { createSpiceflowFetch } from 'spiceflow/client'

// Static global headers — sent with every request
const safeFetch = createSpiceflowFetch('http://localhost:3000', {
  headers: {
    Authorization: 'Bearer my-token',
    'X-Custom': 'value',
  },
})

// Per-request headers — merged on top of global headers
const result = await safeFetch('/users', {
  method: 'POST',
  body: { name: 'John' },
  headers: {
    'X-Request-Id': 'abc-123',
    'X-Idempotency-Key': 'unique-key',
  },
})

// Dynamic global headers — function called on every request
const authedFetch = createSpiceflowFetch('http://localhost:3000', {
  headers: (path, options) => ({
    Authorization: `Bearer ${getAccessToken()}`,
  }),
})

// Array of header sources — all merged in order
const multiFetch = createSpiceflowFetch('http://localhost:3000', {
  headers: [
    { 'X-App': 'my-app' },
    (path) => (path.startsWith('/admin') ? { 'X-Admin': 'true' } : {}),
  ],
})
```

## Hooks

Use `onRequest` and `onResponse` hooks for logging, retries, auth refresh, or response transformation. Both accept a single function or an array of functions.

`onResponse` receives a cloned `Response`. If the hook returns a **non-undefined value**, that value becomes the final result and default response parsing is skipped. Return `undefined` (or nothing) to fall through to normal parsing. This is useful for [custom serialization](/custom-serialization) like superjson.

```ts
const safeFetch = createSpiceflowFetch('http://localhost:3000', {
  retries: 3,
  onRequest: (path, options) => {
    console.log(`→ ${options.method} ${path}`)
    // Return modified options, or void to keep original
    return options
  },
  onResponse: (response) => {
    console.log(`← ${response.status} ${response.url}`)
  },
})

// Multiple hooks run in order
const safeFetch2 = createSpiceflowFetch('http://localhost:3000', {
  onRequest: [
    (path, options) => {
      // Add timestamp header
      return {
        ...options,
        headers: { ...options.headers, 'X-Timestamp': Date.now().toString() },
      }
    },
    (path, options) => {
      console.log('Final request:', path, options.headers)
    },
  ],
})
```

## Path Matching

**Supported patterns:**

* **Named parameters**: `:param` - Captures dynamic segments like `/users/:id` or `/api/:version/users/:userId`
* **Wildcards**: `*` - Matches any remaining path segments like `/files/*` or `/proxy/*`. A wildcard route also matches the parent path without a trailing segment — `/files/*` matches both `/files/foo` and `/files`.
* **Catch-all routes**: `/*` - Matches any unmatched paths

**Unsupported patterns:**

* **Optional parameters**: `/:param?` - Use separate routes instead - IS NOT SUPPORTED
* **Named wildcards**: `/files/*name` - Use unnamed `*` only - IS NOT SUPPORTED
* **Partial parameters**: `/:param-suffix` or `/prefix-:param` - Use full segment parameters only - IS NOT SUPPORTED
* **Regex patterns**: `/users/(\\d+)` - Use string parameters with validation in handlers - IS NOT SUPPORTED
* **Multiple wildcards**: `/*/files/*` - Use single wildcard only - IS NOT SUPPORTED

## Fetch Client Errors

The fetch client returns `Error | Data` directly. When the server responds with a non-2xx status code, the client returns a `SpiceflowFetchError` instead of the data. Use `instanceof Error` to check:

* Responses with status codes 200-299 return the parsed data directly
* Responses with status codes \< 200 or ≥ 300 return a `SpiceflowFetchError`
* The error has `status`, `value` (parsed response body), and `response` (raw Response) properties

```ts
// server.ts
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow()
  .route({
    method: 'GET',
    path: '/error',
    handler() {
      throw new Error('Something went wrong')
    },
  })
  .route({
    method: 'GET',
    path: '/unauthorized',
    handler() {
      return new Response('Unauthorized access', { status: 401 })
    },
  })
  .route({
    method: 'GET',
    path: '/success',
    handler() {
      throw new Response('Success message', { status: 200 })
      return ''
    },
  })

export type App = typeof app
```

```ts
// client.ts
import { createSpiceflowFetch } from 'spiceflow/client'

const safeFetch = createSpiceflowFetch('http://localhost:3000')

async function handleErrors() {
  const errorResult = await safeFetch('/error')
  if (errorResult instanceof Error) {
    console.error('Error occurred:', errorResult.message)
  }

  const unauthorizedResult = await safeFetch('/unauthorized')
  if (unauthorizedResult instanceof Error) {
    console.error(
      'Unauthorized:',
      unauthorizedResult.message,
      'Status:',
      unauthorizedResult.status,
    )
  }

  const successResult = await safeFetch('/success')
  if (successResult instanceof Error) return
  console.log('Success:', successResult) // 'Success message'
}
```

## Server-Side Fetch

You can pass the Spiceflow app instance directly to `createSpiceflowFetch()` instead of providing a URL. This makes "virtual" requests handled directly by the app without actual network requests. Useful for testing, generating documentation, or interacting with your API programmatically without setting up a server.

```tsx
import { Spiceflow } from 'spiceflow'
import { createSpiceflowFetch } from 'spiceflow/client'
import { openapi } from 'spiceflow/openapi'
import { writeFile } from 'node:fs/promises'

export const app = new Spiceflow()
  .use(openapi({ path: '/openapi' }))
  .route({
    method: 'GET',
    path: '/users',
    handler() {
      return [
        { id: 1, name: 'John' },
        { id: 2, name: 'Jane' },
      ]
    },
  })
  .route({
    method: 'POST',
    path: '/users',
    handler({ request }) {
      return request.json()
    },
  })

// Create fetch client by passing app instance directly
const safeFetch = createSpiceflowFetch(app)

// Get OpenAPI schema and write to disk
const data = await safeFetch('/openapi')
if (data instanceof Error) throw data
await writeFile('openapi.json', JSON.stringify(data, null, 2))
console.log('OpenAPI schema saved to openapi.json')
```

<Expandable title={<Markdown inline children="Fixing ts(2742) with SpiceflowFetch" />}>
  When using `createSpiceflowFetch` and getting typescript error `The inferred type of '...' cannot be named without a reference to '...'. This is likely not portable. A type annotation is necessary. (ts 2742)`, you can resolve this issue by adding an explicit type for the client:

  ```ts
  import type { SpiceflowFetch } from 'spiceflow/client'

  export const f: SpiceflowFetch<App> = createSpiceflowFetch<App>(PUBLIC_URL)
  ```
</Expandable>

## Type-Safe RPC

To maintain type safety when using the fetch client, **throw Response objects for errors** and **return objects directly for success cases**. The fetch client returns `Error | Data` directly — use `instanceof Error` to narrow the type:

```ts
import { Spiceflow } from 'spiceflow'
import { z } from 'zod'

export const app = new Spiceflow()
  .route({
    method: 'GET',
    path: '/users/:id',
    query: z.object({
      q: z.string(),
    }),
    response: z.object({
      id: z.string(),
      name: z.string(),
      email: z.string(),
    }),
    handler({ params }) {
      const user = getUserById(params.id)

      if (!user) {
        throw new Response('User not found', { status: 404 })
      }

      return {
        id: user.id,
        name: user.name,
        email: user.email,
      }
    },
  })
  .route({
    method: 'POST',
    path: '/users',
    request: z.object({
      name: z.string(),
      email: z.string().email(),
    }),
    response: z.object({
      id: z.string(),
      name: z.string(),
      email: z.string(),
    }),
    async handler({ request }) {
      const body = await request.json()

      if (await userExists(body.email)) {
        throw new Response('User already exists', { status: 409 })
      }

      const newUser = await createUser(body)

      return {
        id: newUser.id,
        name: newUser.name,
        email: newUser.email,
      }
    },
  })

export type App = typeof app
```

```ts
// client.ts
import { createSpiceflowFetch } from 'spiceflow/client'

const safeFetch = createSpiceflowFetch('http://localhost:3000')

// Path params are type-safe — TypeScript requires { id: string }
const user = await safeFetch('/users/:id', {
  params: { id: '123' },
  query: { q: 'something' },
})
if (user instanceof Error) {
  console.error('Error:', user.message)
  return
}
// user is typed as { id: string, name: string, email: string }
console.log('User:', user.name, user.email)

// Body is type-safe — TypeScript requires { name: string, email: string }
const newUser = await safeFetch('/users', {
  method: 'POST',
  body: { name: 'John', email: 'john@example.com' },
})
if (newUser instanceof Error) return newUser
console.log('Created:', newUser.id)
```

With this pattern:

* **Success responses**: Return objects directly for automatic JSON serialization and proper type inference
* **Error responses**: Throw `Response` objects — the fetch client returns a `SpiceflowFetchError` with `status`, `value`, and `response` properties
* **Type safety**: The fetch client gives you full type safety on **path params**, **query params**, **request body**, and **response data** — all inferred from your route definitions

## Path Building

`router.href()` provides a type-safe way to build URLs with parameters. It prevents runtime errors by ensuring all required parameters are provided and properly substituted into the path. Import `router` from `spiceflow/react`:

```ts
import { router } from 'spiceflow/react'
```

Type safety comes from the **register pattern**. Add `declare module 'spiceflow/react' { interface SpiceflowRegister { app: typeof app } }` at the bottom of your app entry file. This registers your app's route types globally, so `router.href()` knows every valid path, its params, and its query schema at compile time. Without the register block, `router.href()` still works at runtime but accepts any string.

```ts
import { router } from 'spiceflow/react'

router.href('/users/:id', { id: '123' })
// Result: '/users/123'

router.href('/users/:id/posts/:postId', { id: '456', postId: 'abc' })
// Result: '/users/456/posts/abc'
```

### Query Parameters

When a route has a `query` schema, `router.href()` accepts query parameters alongside path parameters in the same flat object. Query parameters are appended as a query string, and unknown keys are rejected at the type level:

```ts
router.href('/search', { q: 'hello', page: 1 })
// Result: '/search?q=hello&page=1'

router.href('/users/:id', { id: '42', fields: 'name' })
// Result: '/users/42?fields=name'

// @ts-expect-error - 'invalid' is not a known query key
router.href('/search', { invalid: 'x' })
```

<Expandable title={<Markdown inline children="OAuth callback example" />}>
  `router.href()` is useful when building callback URLs for OAuth flows, where you need to construct URLs dynamically based on user data or session information:

  ```ts
  import { router } from 'spiceflow/react'

  const callbackUrl = new URL(
    router.href('/auth/callback/:provider/:userId', {
      provider: 'google',
      userId: '12345',
    }),
    'https://myapp.com',
  ).toString()

  const oauthUrl =
    `https://accounts.google.com/oauth/authorize?` +
    `client_id=your-client-id&` +
    `redirect_uri=${encodeURIComponent(callbackUrl)}&` +
    `response_type=code&` +
    `scope=openid%20profile%20email`
  ```
</Expandable>


---
title: OpenTelemetry tracing for every request
url: "https://getspiceflow.com/tracing.md"
description: "Pass an OpenTelemetry tracer to Spiceflow to record spans for every request, including Server-Timing, custom attributes, and Cloudflare adapters."
---

# OpenTelemetry tracing for every request

Spiceflow has built-in OpenTelemetry tracing. Pass a `tracer` to the constructor and every request gets automatic spans for middleware, handlers, loaders, layouts, pages, and RSC serialization — no monkey-patching, no plugins.

## Setup

Install the OTel SDK packages alongside spiceflow:

```bash
npm install @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http @opentelemetry/api
```

Create a tracing setup file that runs **before** your app starts. This registers the OTel SDK globally so spans are collected and exported:

```ts
// tracing.ts
import { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'

const sdk = new NodeSDK({
  serviceName: 'my-app',
  traceExporter: new OTLPTraceExporter({
    // Send traces to your collector or observability backend
    url: 'http://localhost:4318/v1/traces',
  }),
})

sdk.start()
```

Then pass a tracer to your Spiceflow app:

```ts
// main.ts
import './tracing' // must be imported first
import { trace } from '@opentelemetry/api'
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow({ tracer: trace.getTracer('my-app') }).get(
  '/api/users/:id',
  ({ params }) => {
    return { id: params.id, name: 'Alice' }
  },
)
```

Server timing is enabled by default, so spans are automatically visible in Chrome DevTools:

```ts
export const app = new Spiceflow({
  tracer: trace.getTracer('my-app'),
}).get('/api/users/:id', ({ params, tracer }) => {
  return tracer.startActiveSpan('db.query', (span) => {
    span.end()
    return { id: params.id, name: 'Alice' }
  })
})
```

## What you get

Every request produces a span tree. For API routes:

```
GET /api/users/:id [server]
├── middleware - cors
├── middleware - auth
└── handler - /api/users/:id
```

For React routes with loaders and layouts:

```
GET /dashboard [server]
├── middleware - auth
├── loader - /dashboard
├── loader - /sidebar
├── layout - /
├── page - /dashboard
└── rsc.serialize
```

Each span includes standard HTTP attributes (`http.request.method`, `http.route`, `http.response.status_code`, `url.full`) following [OTel semantic conventions](https://opentelemetry.io/docs/specs/semconv/http/http-spans/). Errors are recorded with `recordException` and set the span status to ERROR. If your errors use [errore](https://errore.org) tagged errors, the stable fingerprint is propagated as an `error.fingerprint` attribute for consistent error grouping.

## Server Timing

When a `tracer` is provided, spiceflow automatically emits a `Server-Timing` response header on every request. This makes slow work visible in Chrome DevTools without setting up a trace backend first. Set `serverTiming: false` to disable it.

Spiceflow keeps the browser format flat, because `Server-Timing` is flat, but the `desc` value preserves the nested path with `>`. Child items omit the repeated root request prefix so the list stays easy to scan:

```http
Server-Timing: get-users-id;dur=42.1;desc="GET /users/:id"
Server-Timing: handler-users-id;dur=40.3;desc="handler - /users/:id"
Server-Timing: handler-users-id-db.query;dur=31.8;desc="handler - /users/:id > db.query"
```

Built-in spans and custom spans created with `context.tracer.startActiveSpan()` are both included. This is useful for debugging slow database queries, cache lookups, or external API calls directly in the browser.

## Custom spans and attributes

Every handler receives `span` and `tracer` on its context. These work whether or not you configured a tracer — when no tracer is passed, they use no-op implementations that do nothing, so you never need conditional checks.

**Add attributes to the current span:**

```ts
.get('/api/users/:id', ({ params, span }) => {
  const user = db.findUser(params.id)
  span.setAttribute('user.plan', user.plan)
  return user
})
```

**Read the current trace id or span id:**

```ts
.get('/api/users/:id', ({ span }) => {
  const traceId = span.spanContext?.()?.traceId
  const spanId = span.spanContext?.()?.spanId
  console.log({ traceId, spanId })
  return { ok: true }
})
```

`spanContext()` is optional because Spiceflow keeps the span interface compatible with simple custom tracer test doubles. When no tracer is configured, the noop span returns `undefined`.

**Record a caught exception without re-throwing:**

```ts
.post('/api/webhook', async ({ request, span }) => {
  const body = await request.json()
  try {
    await processWebhook(body)
  } catch (err) {
    span.recordException(err)
  }
  return { ok: true }
})
```

**Create child spans for DB calls or external APIs:**

```ts
.get('/api/data', async ({ tracer, params }) => {
  return tracer.startActiveSpan('db.query', async (dbSpan) => {
    const data = await db.query(params.id)
    dbSpan.setAttribute('db.rows', data.length)
    dbSpan.end()
    return data
  })
})
```

You can also import `withSpan` as a convenience wrapper that handles errors and `span.end()` automatically:

```ts
import { withSpan } from 'spiceflow'

.get('/api/data', async ({ tracer, params }) => {
  return withSpan(tracer, 'db.query', {}, async (dbSpan) => {
    dbSpan.setAttribute('db.table', 'users')
    return db.query(params.id)
  })
})
```

## Context propagation to libraries

Libraries that use OpenTelemetry (like the [Vercel AI SDK](https://sdk.vercel.ai), database drivers, HTTP clients) automatically create **child spans** under your request span — no extra wiring needed.

This works because the OTel `NodeSDK` registers an `AsyncLocalStorageContextManager` by default. When spiceflow calls `tracer.startActiveSpan()` for a request, the root span is stored in `AsyncLocalStorage`. Any library that calls `trace.getTracer()` from `@opentelemetry/api` inside your handler sees the active span and creates children, not roots.

```diagram
GET /api/chat [server]
├── middleware - auth
├── handler - /api/chat
│   ├── ai.generateText          ← created by AI SDK
│   │   ├── ai.toolCall          ← created by AI SDK
│   │   └── ai.toolCall
│   └── db.query                 ← created by your code
```

Example with the AI SDK:

```ts
import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'

.post('/api/chat', async ({ request }) => {
  const { prompt } = await request.json()
  const result = await generateText({
    model: openai('gpt-4.1'),
    prompt,
    experimental_telemetry: { isEnabled: true },
  })
  return { text: result.text }
})
```

The `ai.generateText` and `ai.toolCall` spans appear as children of `handler - /api/chat` automatically. This applies to any OTel-instrumented library — HTTP clients, database drivers, queue publishers, etc.

## Sentry

Sentry's Node.js SDK is built on OpenTelemetry, so all spiceflow spans flow to Sentry automatically. Initialize Sentry before creating your app, then pass the tracer from the Sentry client:

```ts
import * as Sentry from '@sentry/node'
import { Spiceflow } from 'spiceflow'

Sentry.init({
  dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0',
  tracesSampleRate: 1.0,
})

export const app = new Spiceflow({
  tracer: Sentry.getClient()!.tracer,
}).get('/api/users/:id', ({ params }) => {
  return { id: params.id, name: 'Alice' }
})
```

Every request span, middleware span, handler span, and custom child span created with `context.tracer.startActiveSpan()` shows up in Sentry's Performance dashboard. Errors recorded with `span.recordException()` are captured as Sentry issues with the full trace context attached.

If you already use the generic OTel setup from the section above, Sentry picks up those spans too. The difference is that with `Sentry.getClient()!.tracer` you skip installing `@opentelemetry/api` as a direct dependency.

## Cloudflare Workers

On Cloudflare Workers, tracing is **automatic**. Spiceflow detects the Cloudflare runtime and uses the native [`tracing.enterSpan()`](https://developers.cloudflare.com/workers/observability/traces/custom-spans/) API without any setup. Just enable tracing in your `wrangler.jsonc`:

```jsonc
{
  "observability": {
    "traces": {
      "enabled": true
    }
  }
}
```

All spiceflow spans (middleware, handlers, loaders, layouts, RSC serialization) appear alongside Cloudflare's automatic platform spans (KV, D1, fetch) in the dashboard. If you pass an explicit `tracer`, it takes priority over the automatic one.

See the [Cloudflare page](/cloudflare#automatic-tracing) for details and limitations.

## Zero overhead without tracer

When no `tracer` is passed (and the runtime is not Cloudflare), every instrumentation point is skipped entirely — no strings allocated, no objects created, no extra async wrappers. The `span` and `tracer` on the handler context use no-op implementations whose empty methods V8 inlines away.


---
title: OpenTelemetry backend with Strada
url: "https://getspiceflow.com/strada.md"
description: "Send Spiceflow OpenTelemetry traces to Strada for error tracking, traces, logs, and metrics without adding a separate instrumentation stack."
---

# OpenTelemetry backend with Strada

[Strada](https://strada.sh) is an OpenTelemetry backend with error tracking, logs, traces, metrics, analytics, and a SQL CLI. Use `@strada.sh/sdk` to configure OTel once, pass its tracer to Spiceflow, then use the same SDK for logs and handled exceptions.

Create a project first, or list existing projects to find the project ID:

```bash
strada database create
strada projects create my-api

# Later, find the project ID and ingest endpoint again
strada projects list
```

Install Strada in your Spiceflow app:

```bash
npm install @strada.sh/sdk
```

Then initialize Strada before creating the Spiceflow app:

```ts
import { Spiceflow } from 'spiceflow'
import {
  SpanStatusCode,
  captureException,
  getLogger,
  initStrada,
  trace,
} from '@strada.sh/sdk'

initStrada({
  projectId: process.env.STRADA_PROJECT_ID!,
  service: 'api',
  environment: process.env.NODE_ENV ?? 'development',
  enabled: !import.meta.hot,
})

// enabled: false keeps OTel APIs local but sends nothing to ingest.
// In Vite/RSC dev servers, import.meta.hot is truthy during HMR.

const tracer = trace.getTracer('api')
const logger = getLogger('api')

export const app = new Spiceflow({ tracer, serverTiming: true })
  .get('/api/users/:id', async ({ params, span, tracer }) => {
    span.setAttribute('user.id', params.id)
    logger.info({ message: 'loading user', userId: params.id })

    return tracer.startActiveSpan('db.find-user', async (dbSpan) => {
      try {
        dbSpan.setAttribute('db.operation', 'SELECT')
        return { id: params.id, name: 'Alice' }
      } finally {
        dbSpan.end()
      }
    })
  })
  .onError(({ error, path, span }) => {
    captureException(error, { tags: { path } })
    span.recordException(error)
    span.setStatus({ code: SpanStatusCode.ERROR })

    logger.error({
      message: 'request failed',
      path,
      error: error instanceof Error ? error.message : String(error),
    })

    return new Response('An error occurred', { status: 500 })
  })
```

The Spiceflow request spans, custom child spans, logs, and `captureException()` calls all flow to the same Strada project. Use the CLI to inspect them:

```bash
strada issues list -p my-api --since 24h
strada logs -p my-api --since 1h
strada query "SELECT count() FROM otel_traces" -p my-api
```

## React error tracking

Spiceflow exposes React 19's root error handlers (`onCaughtError`, `onUncaughtError`, `onRecoverableError`) via `setReactErrorHandlers`. These fire for **every** React render error globally, even when the user has their own `ErrorBoundary`. Call it before React hydrates, typically at the top of your client entry or inside your SDK init.

```ts
import { setReactErrorHandlers } from 'spiceflow/react'
import { captureException } from '@strada.sh/sdk'

setReactErrorHandlers({
  onCaughtError(error, errorInfo) {
    // Fires when any ErrorBoundary catches an error.
    // errorInfo.componentStack is the React component tree.
    captureException(error, {
      tags: { reactHandler: 'onCaughtError' },
      extra: { componentStack: errorInfo.componentStack },
    })
  },
  onUncaughtError(error, errorInfo) {
    // Fires when no ErrorBoundary catches the error.
    // React unmounts the entire tree after this.
    captureException(error, {
      tags: { reactHandler: 'onUncaughtError' },
      extra: { componentStack: errorInfo.componentStack },
    })
  },
  onRecoverableError(error, errorInfo) {
    // Fires on hydration mismatches and other recoverable failures.
    // React re-renders from scratch after this.
    captureException(error, {
      tags: { reactHandler: 'onRecoverableError' },
      extra: { componentStack: errorInfo.componentStack },
    })
  },
})
```

| Handler              | When it fires                       | React behavior after |
| -------------------- | ----------------------------------- | -------------------- |
| `onCaughtError`      | Error caught by an `ErrorBoundary`  | Fallback UI renders  |
| `onUncaughtError`    | Error not caught by any boundary    | Tree unmounts        |
| `onRecoverableError` | Hydration mismatch or auto-recovery | Client re-render     |

The handlers are set on `globalThis` and read by Spiceflow's client entry at hydration time. If you call `setReactErrorHandlers` after hydration, the handlers won't take effect. Always call it early, before any React rendering happens.


---
title: Compose remote RSC apps at runtime
url: "https://getspiceflow.com/federation.md"
description: "Fetch and render React Server Components from another Spiceflow server, including streaming, CSS injection, and client component hydration."
---

# Compose remote RSC apps at runtime

Federation lets you compose multiple spiceflow apps at the React Server Component level. A **remote** app exposes components, and a **host** app embeds them — with full SSR, hydration, and client interactivity.

## Remote Components

Federation lets you move React Server Component payloads across app boundaries. The producer returns `encodeFederationPayload(...)`, and the consumer either renders a fetched `Response` with `RenderFederatedPayload` or decodes it imperatively with `decodeFederationPayload`.

```tsx
import { Suspense } from 'react'
import { RenderFederatedPayload } from 'spiceflow/react'

const response = await fetch('https://my-remote.com/api/chart?props=' + encodeURIComponent(JSON.stringify({
  dataSource: 'revenue',
})))

<Suspense fallback={<div>Loading...</div>}>
  <RenderFederatedPayload response={response} />
</Suspense>

const esmResponse = await fetch('https://esm.sh/some-chart-component')

<Suspense fallback={<div>Loading...</div>}>
  <RenderFederatedPayload response={esmResponse} />
</Suspense>

const framerResponse = await fetch('https://framer.com/m/IOKnob-DT0M.js@eZsKjfnRtnN8np5uwoAx')

<Suspense fallback={<div>Loading...</div>}>
  <RenderFederatedPayload response={framerResponse} />
</Suspense>
```

`RenderFederatedPayload` must be wrapped in `<Suspense>` — the fallback shows while the server responds (federation) or while the module loads (ESM).

## Setting Up Federation

**Remote app** — exposes a Flight payload via `encodeFederationPayload`:

```tsx
// remote/vite.config.ts
import spiceflow from 'spiceflow/vite'

export default defineConfig({
  base: process.env.REMOTE_ORIGIN || 'http://localhost:3001',
  plugins: [
    spiceflow({
      entry: './src/main.tsx',
      externalizeShared: true,
    }),
  ],
})
```

```tsx
// remote/src/main.tsx
import { Spiceflow } from 'spiceflow'
import { cors } from 'spiceflow/cors'
import { encodeFederationPayload } from 'spiceflow/federation'
import { Chart } from './chart'
import { Table } from './table'
import { db } from './db'

export const app = new Spiceflow()
  .use(cors({ origin: '*' }))
  // Dynamic: fetch data at request time, render the component, return the SSE response
  .get('/api/chart', async ({ request }) => {
    const props = JSON.parse(request.parsedUrl.searchParams.get('props') || '{}')
    const rows = await db.query('SELECT month, revenue FROM sales WHERE year = 2025')
    return await encodeFederationPayload(<Chart data={rows} {...props} />)
  })
  // Static: pre-rendered at build time and written to disk.
  // Serve it from S3, a CDN, or any static host — no server needed at runtime.
  .staticGet('/api/table', async () => {
    const rows = await db.query('SELECT name, role, department FROM employees')
    return await encodeFederationPayload(<Table rows={rows} />)
  })
```

The `.staticGet` route runs at build time and writes the response to disk. You can upload the output to S3 or any static host — the host app fetches it like any other URL, and `RenderFederatedPayload` renders it with full SSR and hydration. No server running for the remote at runtime.

**Host app** — fetches the response and renders it:

```tsx
// host/src/main.tsx
import { Suspense } from 'react'
import { Spiceflow } from 'spiceflow'
import { RenderFederatedPayload } from 'spiceflow/react'

const REMOTE = process.env.REMOTE_ORIGIN || 'http://localhost:3001'

export const app = new Spiceflow()
  .page('/', async () => {
    const chart = await fetch(`${REMOTE}/api/chart`)
    const table = await fetch(`${REMOTE}/api/table`)

    return (
      <div>
        <Suspense fallback={<div>Loading chart...</div>}>
          <RenderFederatedPayload response={chart} />
        </Suspense>
        <Suspense fallback={<div>Loading table...</div>}>
          <RenderFederatedPayload response={table} />
        </Suspense>
      </div>
    )
  })
```

The remote components are SSR-rendered in the host's HTML stream, then hydrated on the client with full interactivity. CSS from the remote is automatically injected.

## Static Markup

Use `renderToStaticMarkup(...)` from `spiceflow/federation` when you need an **HTML string** from JSX inside the RSC environment. This is useful for email HTML, static snippets, and other server-only markup that should not be hydrated.

```tsx
import { renderToStaticMarkup } from 'spiceflow/federation'

app.get('/api/email-preview', async () => {
  const html = await renderToStaticMarkup(
    <section>
      <h1>Welcome, Ada</h1>
      <p>Your invite code is 1234.</p>
    </section>,
  )

  return new Response(html, {
    headers: { 'content-type': 'text/html;charset=utf-8' },
  })
})
```

Spiceflow exports this helper because React's `renderToStaticMarkup` from `react-dom/server` does **not** work inside the React Server Components environment. RSC first renders JSX to a Flight stream, then the SSR environment decodes that stream into HTML. This helper uses the same Flight-to-HTML bridge that federation uses internally.

## Imperative Decode

Use `decodeFederationPayload(response)` when you want to fetch a route manually in a client event handler and use the decoded value yourself. This works for plain objects, JSX, or objects containing JSX. Async iterables are supported when they are fields on an object payload, for example `{ stream }`.

```tsx
'use client'

import { useState } from 'react'
import { decodeFederationPayload } from 'spiceflow/react'

export function ChatButton() {
  const [parts, setParts] = useState<React.ReactNode[]>([])

  async function handleClick() {
    const response = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ prompt: 'hello' }),
    })

    const decoded = await decodeFederationPayload<{
      message: string
      content: React.ReactNode
    }>(response)

    setParts((prev) => [...prev, <div key={prev.length}>{decoded.content}</div>])
  }

  return <button onClick={handleClick}>Load</button>
}
```

<Expandable title={<Markdown inline children="How federation works under the hood" />}>
  `encodeFederationPayload` returns a `Response` in SSE (`text/event-stream`) format with these events:

  * **metadata** — remoteId, client module chunk URLs, stylesheet URLs
  * **ssr** — pre-rendered HTML for instant display when the top-level payload is a React element
  * **modules** (zero or more) — client modules discovered while streaming; always emitted before the flight chunk that references them, so consumers can load the chunks just in time
  * **flight** (one per row) — RSC Flight stream rows (serialized React tree)
  * **done** — signals end of payload

  Streaming payloads (async generators) discover client references lazily while rendering. The incremental `modules` events mean streamed JSX can freely mix server-rendered content and interactive client components — the consumer loads each newly announced module before feeding the next flight chunk to the decoder.

  The host fetches this SSE response, SSR-renders the HTML via `dangerouslySetInnerHTML`, then hydrates using `hydrateRoot` to patch the existing DOM in-place (no flash).

  **Import map and module deduplication.** Spiceflow automatically injects a `<script type="importmap">` into the HTML with entries for shared modules:

  ```
  react, react-dom, react-dom/client, react/jsx-runtime, spiceflow/react
  ```

  Each entry points to a hashed chunk built from the host app's own dependencies. When a remote component's client code does `import React from 'react'`, the browser resolves it through the import map to the **host's React chunk** — not a separate copy. This is how federation avoids duplicate React instances (which would break hooks and context). The same deduplication works for any module you add via the `importMap` plugin option: if a Framer component does `import { motion } from 'framer-motion'`, and you've mapped `framer-motion` to a local re-export file, the browser loads the host's bundled copy.

  This means remote client components can use `useRouterState` from the host and read host-provided React contexts (via `useContextBridge` from [its-fine](https://github.com/pmndrs/its-fine)). External ESM components from esm.sh or Framer also benefit — as long as they externalize `react` (e.g. `https://esm.sh/some-lib?external=react`), the import map resolves the bare specifier to the host's instance and everything just works.
</Expandable>

## Streaming and Suspense

`encodeFederationPayload` handles React elements and plain objects differently. When the top-level value is a **React element**, the entire flight stream is buffered before any SSE events are sent. This gives the consumer SSR HTML for instant display, but it means **Suspense boundaries inside the element do not stream incrementally**. The client waits until every promise resolves before seeing anything.

When the top-level value is a **plain object**, flight rows stream incrementally as they become available. Suspense fallbacks arrive immediately, and resolved content streams in later.

If streaming matters for your federation payload (slow data fetches, AI responses, anything behind a `<Suspense>` boundary), wrap your JSX in an object and render it on the consumer side:

```tsx
// ❌ Top-level JSX — entire payload is buffered, no Suspense streaming
app.get('/api/dashboard', async () => {
  return await encodeFederationPayload(
    <Suspense fallback={<div>Loading...</div>}>
      <SlowChart />
    </Suspense>
  )
})

// ✅ Object with JSX field — flight rows stream incrementally
app.get('/api/dashboard', async () => {
  return await encodeFederationPayload({
    chart: (
      <Suspense fallback={<div>Loading...</div>}>
        <SlowChart />
      </Suspense>
    ),
  })
})
```

The consumer decodes the object and renders the JSX field:

```tsx
'use client'

import { useEffect, useState } from 'react'
import { decodeFederationPayload } from 'spiceflow/react'

export function Dashboard() {
  const [chart, setChart] = useState<React.ReactNode>(null)

  useEffect(() => {
    fetch('/api/dashboard')
      .then((res) => decodeFederationPayload<{ chart: React.ReactNode }>(res))
      .then((decoded) => setChart(decoded.chart))
  }, [])

  return <div>{chart}</div>
}
```

The same applies to async iterables. Wrapping a generator in an object field streams items as they yield:

```tsx
app.get('/api/feed', async () => {
  async function* items() {
    yield { id: '1', text: 'first' }
    await sleep(100)
    yield { id: '2', text: 'second' }
  }
  return await encodeFederationPayload({ stream: items() })
})
```

**Why the difference?** Top-level React elements get SSR-rendered into an HTML string before sending, which requires the complete flight payload. Objects skip SSR and stream rows as they arrive. This is a tradeoff: top-level elements give instant visible HTML (no flash of empty content), while object payloads give incremental streaming at the cost of no SSR preview.

## External ESM Components

`RenderFederatedPayload` also works with plain JavaScript modules — any URL that returns `content-type: text/javascript`. The module is dynamically imported in the browser, and its default export (or first function export) is rendered as a React component.

This is useful for loading components from Framer, esm.sh, or any CDN that serves ES modules. ESM components are **client-only** — they render `null` during SSR and load after hydration.

Framer components import bare specifiers like `framer` and `framer-motion`. These need to be in the browser's import map so the dynamic `import()` can resolve them. Use the `importMap` option in your Vite config to point these specifiers to local re-export files — this way the browser uses the same bundled instance as your host app (deduplication):

```ts
// vite.config.ts
import spiceflow from 'spiceflow/vite'

export default defineConfig({
  plugins: [
    spiceflow({
      entry: './src/main.tsx',
      importMap: {
        'framer-motion': './src/shared/framer-motion.ts',
        'framer': './src/shared/framer.ts',
      },
    }),
  ],
})
```

```ts
// src/shared/framer-motion.ts
export * from 'framer-motion'
```

```ts
// src/shared/framer.ts
export * from 'framer'
```

Each local file is built into a hashed chunk — the same pattern spiceflow uses internally for React and `spiceflow/react`. If you prefer loading from a CDN instead, pass a URL:

```ts
importMap: {
  'framer-motion': 'https://esm.sh/framer-motion?external=react',
  'framer': 'https://esm.sh/unframer@latest/esm/framer.js?external=react',
}
```

These entries are merged into the auto-generated import map that spiceflow already injects for `react`, `react-dom`, `react/jsx-runtime`, and `spiceflow/react`.


---
title: Type-safe routing with SpiceflowRegister
url: "https://getspiceflow.com/type-safety.md"
description: "Register your app with SpiceflowRegister so router, Link, loaders, and the fetch client infer routes at compile time without passing generics."
---

# Type-safe routing with SpiceflowRegister

Spiceflow provides **compile-time route validation** for paths, params, query schemas, and loader data. This document explains how the type registry works, when to use it, and how to handle workspaces with multiple apps.

## The register pattern

Add a `declare module` block at the bottom of your app entry file:

```tsx
// src/main.tsx
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow()
  .page('/login', async () => 'login')
  .page('/users/:id', async ({ params }) => <div>{params.id}</div>)
  .listen(3000)

declare module 'spiceflow/react' {
  interface SpiceflowRegister {
    app: typeof app
  }
}
```

This registers your app's route types **globally** within the TypeScript project. Every typed API picks them up automatically:

```tsx
import { router, useLoaderData } from 'spiceflow/react'
import { createSpiceflowFetch } from 'spiceflow/client'

router.href('/users/:id', { id: '42' }) // params validated
router.href('/nonexistent')              // compile error

const data = useLoaderData('/login')     // typed loader data
const f = createSpiceflowFetch('http://localhost:3000') // typed fetch
```

Without the `declare module` block, all APIs still work at runtime. They just accept any string without compile-time validation.

## Extra paths with `knownPaths`

Some apps have valid routes that are not part of `typeof app` — mounted sub-apps, docs generators, or external route tables. Declare them with the optional `knownPaths` property, a string union of extra paths that flows into `router.href()`, `<Link>`, `router.push()`, and `router.replace()`. Param patterns (`:slug`) and wildcards (`*`) work like regular routes:

```tsx
declare module 'spiceflow/react' {
  interface SpiceflowRegister {
    app: typeof app
    knownPaths: '/docs' | '/docs/:slug' | '/files/*'
  }
}

router.href('/docs')                          // ✅ valid
router.href('/docs/:slug', { slug: 'intro' }) // ✅ params validated
router.href('/files/*', { '*': 'a/b.txt' })   // ✅ wildcard param
router.href('/still-invalid')                 // ❌ compile error
```

## Why not generics?

Before the register pattern, every call needed an explicit generic:

```tsx
import { createSpiceflowFetch } from 'spiceflow/client'
import type { app } from './main'

// old way: pass the app type everywhere
const f = createSpiceflowFetch<typeof app>('http://localhost:3000')
useLoaderData<typeof app>('/login')
```

Problems with the generic approach:

* **Repetitive.** Every file that uses typed routing must import `typeof app` and pass it as a generic.
* **Viral imports.** Client-side code ends up importing the server app type, which can pull server-only modules into the client bundle if not carefully managed.
* **Easy to forget.** Missing the generic gives you `any` types silently, so you lose safety without a warning.

The register pattern solves all three: register once, typed everywhere, no imports needed.

## One app per TypeScript project

The register pattern uses TypeScript's **interface declaration merging**. When you write `interface SpiceflowRegister { app: typeof app }` in a `declare module` block, TypeScript adds the `app` property to the global `SpiceflowRegister` interface for that entire compilation unit.

This means **only one app can be registered per `tsconfig.json` project**. If two files in the same project both declare `SpiceflowRegister` with different app types, TypeScript raises a compile error:

```
error TS2717: Subsequent property declarations must have the same type.
  Property 'app' must be of type 'typeof appA',
  but here has type 'typeof appB'.
```

This is not a silent corruption. It's a hard error that tells you exactly what's wrong.

## Multiple apps in a workspace

In a monorepo with multiple spiceflow apps, the register pattern works fine **as long as each app is a separate TypeScript project** (its own `tsconfig.json`) and they don't import each other's entry files.

```
workspace/
  packages/
    admin/           # tsconfig.json, declares SpiceflowRegister for adminApp
    customer/        # tsconfig.json, declares SpiceflowRegister for customerApp
    shared/          # shared utils, no SpiceflowRegister
```

Each app is compiled independently. No collision.

### When apps import each other

The problem appears when one workspace package **imports** from another that also has a `declare module` block. TypeScript follows import chains and pulls in all module augmentations it finds.

If `admin` imports anything from `customer` (even a utility function that lives in the same package as `customer/src/main.tsx`), TypeScript sees both `SpiceflowRegister` declarations and raises the compile error.

This also happens transitively. If `admin` imports `shared`, and `shared` imports `customer`, the augmentation from `customer` leaks into `admin`'s compilation.

### Solutions for cross-importing apps

**Option 1: Only one app registers globally.** The "main" app uses the `declare module` pattern. Other apps that get imported into it use explicit generics instead:

```tsx
// packages/admin/src/main.tsx (the main app, registers globally)
export const adminApp = new Spiceflow()
  .use(customerApp)
  .page('/admin', async () => <Admin />)
  .listen(3000)

declare module 'spiceflow/react' {
  interface SpiceflowRegister { app: typeof adminApp }
}

// packages/customer/src/main.tsx (imported by admin, no register)
export const customerApp = new Spiceflow()
  .page('/shop', async () => <Shop />)

// packages/customer/src/some-client.ts (uses generics)
import { createSpiceflowFetch } from 'spiceflow/client'
import type { customerApp } from './main'

const f = createSpiceflowFetch<typeof customerApp>('http://localhost:3001')
```

The customer app loses the ergonomic register pattern but gains the ability to be imported by admin without type conflicts.

**Option 2: Use the parent app's type.** If a sub-app is mounted into a larger app via `.use()`, it can use the parent's registered type. Since the parent app includes all routes from the sub-app, the parent's type is a superset:

```tsx
// packages/customer/src/nav.tsx
// The admin app mounts customerApp, so adminApp's routes include /shop
// The global register has adminApp, so router.href('/shop') just works
import { router } from 'spiceflow/react'

router.href('/shop')  // valid because adminApp includes customerApp's routes
```

**Option 3: No type safety for the imported app.** If a sub-app doesn't need typed routing in its own package (it just defines routes and exports the app), skip both the register and the generics. The routes are still type-safe in the parent app that mounts them.

### Summary

| Setup                                               | Register pattern  | Generics                               | Notes                               |
| --------------------------------------------------- | ----------------- | -------------------------------------- | ----------------------------------- |
| Single app                                          | Yes               | Not needed                             | Best DX                             |
| Multiple apps, separate tsconfigs, no cross-imports | Yes (each app)    | Not needed                             | Each project is isolated            |
| Multiple apps, one imports the other                | Only the main app | Other apps use generics                | Avoids declaration conflict         |
| Sub-app mounted via `.use()`                        | Only the parent   | Sub-app uses parent's type or generics | Parent's type covers sub-app routes |

## Type safety inside inline handlers

A common pattern in spiceflow is calling `router.href()` or `throw redirect(router.href('/path'))` inside `.page()` handlers:

```tsx
import { Spiceflow, redirect } from 'spiceflow'
import { router } from 'spiceflow/react'

export const app = new Spiceflow()
  .page('/login', async () => 'login')
  .page('/dashboard', async () => {
    throw redirect(router.href('/login'))  // fully type-safe
  })
  .page('/users/:id', async ({ params }) => {
    return <a href={router.href('/dashboard')}>Back</a>
  })

declare module 'spiceflow/react' {
  interface SpiceflowRegister { app: typeof app }
}
```

Always use `throw redirect(...)`, never `return redirect(...)`. `throw` prevents the redirect from contributing to the handler's return type, which avoids circular TypeScript errors (TS7022) when the app uses `SpiceflowRegister`.

The `router` import reads from `SpiceflowRegister`, which is resolved by TypeScript **independently** from the `const app = ...` expression. The handler body references `router` (a module-level import), not `typeof app`, so there's no circular dependency.

Without the register pattern, using `getRouter<typeof app>()` inside handlers creates a circular type reference. The handler's body is part of the expression that defines `app`, and it also references `typeof app` — TypeScript can't resolve both simultaneously and widens the path type to `string`, losing all validation. The register pattern breaks this cycle.

## Autocomplete limitation

TypeScript's **compiler** (tsc) resolves the full registered type correctly — invalid paths, missing params, and wrong param keys all produce compile errors as expected. But the **language server** (IDE autocomplete/IntelliSense) resolves types incrementally for performance.

When you're typing inside a `.page()` handler that's part of the `const app = ...` chain, autocomplete may only show paths defined **above** the current handler position, not the full route table. This is because:

1. The language server needs to resolve `RegisteredApp` → `SpiceflowRegister['app']` → `typeof app`
2. `typeof app` depends on the full builder chain, which is still being typed
3. The language server falls back to whatever it has resolved so far

This is the same limitation TanStack Router has. Their workaround is code generation (a Vite plugin pre-generates the route tree). In spiceflow, this only affects autocomplete **inside the app chain** — it does not affect type errors or autocomplete in separate component files.

| Location                              | Compile errors (tsc)  | Autocomplete               |
| ------------------------------------- | --------------------- | -------------------------- |
| Inside `.page()` handler in the chain | ✅ All paths validated | Partial (only paths above) |
| Separate component file               | ✅ All paths validated | ✅ Full autocomplete        |
| Separate server component             | ✅ All paths validated | ✅ Full autocomplete        |

For the best DX, use `router.href()` in component files where autocomplete is fully functional. Inside the chain, the compiler still catches all errors even if autocomplete is incomplete.

## How it works internally

The `SpiceflowRegister` interface starts empty:

```ts
export interface SpiceflowRegister {}
```

The `RegisteredApp` type checks if the interface has been augmented:

```ts
export type RegisteredApp = SpiceflowRegister extends {
  app: infer App extends AnySpiceflow
}
  ? App
  : AnySpiceflow
```

If `SpiceflowRegister` has an `app` property that extends `AnySpiceflow`, the type resolves to that specific app type. Otherwise it falls back to `AnySpiceflow`, which gives `any`-like behavior (all strings accepted, no validation).

Every typed API defaults its generic parameter to `RegisteredApp`:

```ts
export const router: RouterBase<RegisteredApp> = { ... }
export function useLoaderData<App extends AnySpiceflow = RegisteredApp>() { ... }
export function createSpiceflowFetch(domain: string): SpiceflowFetch<RegisteredApp>
```

This is why the register pattern is "register once, typed everywhere". The conditional type `RegisteredApp` threads the app type through every API automatically.


---
title: "Serialize Date, Map, Set, and BigInt"
url: "https://getspiceflow.com/custom-serialization.md"
description: "Send Date, Map, Set, and BigInt through Spiceflow fetch and RSC payloads with custom serializers so non-JSON types survive the network round trip."
---

# Serialize Date, Map, Set, and BigInt

Spiceflow uses plain `JSON.stringify` for API route responses. Types like `Date`, `Map`, `Set`, and `BigInt` are **not** preserved across the wire by default. `Date` becomes an ISO string, `Map` and `Set` are dropped, and `BigInt` throws.

For the **RSC framework** (`.page()`, `.layout()`, `.loader()`), this is not a problem. React's flight protocol already handles `Date`, `Map`, `Set`, `BigInt`, typed arrays, `Promise`, and JSX elements natively.

For **API routes** (`.get()`, `.post()`, `.route()`), you can add custom serialization using a response helper on the server and an `onResponse` hook on the fetch client. This page shows how to do it with [superjson](https://github.com/flightcontrolhq/superjson).

## Server: response helper

Create a helper that serializes data with superjson and sets a custom content type so the client can detect it:

```ts
// src/superjson.ts
import superjson from 'superjson'

export function superjsonResponse(data: any): Response {
  const { json, meta } = superjson.serialize(data)
  if (meta) (json as any).__superjsonMeta = meta
  return new Response(JSON.stringify(json), {
    headers: { 'content-type': 'application/superjson' },
  })
}
```

Use it in your route handlers instead of returning a plain object:

```ts
import { Spiceflow } from 'spiceflow'
import { superjsonResponse } from './superjson'

const app = new Spiceflow()
  .get('/api/event', () =>
    superjsonResponse({
      name: 'Launch party',
      date: new Date('2025-07-01T19:00:00Z'),
      attendees: new Set(['alice', 'bob']),
    }),
  )
```

The response body contains standard JSON with an extra `__superjsonMeta` field that records the original types.

## Client: onResponse hook

On the client, configure `createSpiceflowFetch` with an `onResponse` hook that checks for the custom content type and deserializes:

```ts
import superjson from 'superjson'
import { createSpiceflowFetch } from 'spiceflow/client'

const safeFetch = createSpiceflowFetch('http://localhost:3000', {
  onResponse: async (response) => {
    const ct = response.headers.get('content-type')
    if (!ct?.includes('application/superjson')) return undefined

    const data = await response.json()
    if (!data?.__superjsonMeta) return data
    const { __superjsonMeta, ...rest } = data
    return superjson.deserialize({ json: rest, meta: __superjsonMeta })
  },
})

const event = await safeFetch('/api/event')
if (event instanceof Error) throw event

console.log(event.date)      // Date object, not a string
console.log(event.attendees) // Set {'alice', 'bob'}
```

When `onResponse` returns a non-undefined value, it replaces the default response parsing entirely. Return `undefined` (or don't return) to fall through to the normal JSON parsing.

## How it works

```diagram
┌─────────────────────────────────────────────────────────────────────────────┐
│  Route handler                                                              │
│                                                                             │
│  superjsonResponse({ date: new Date(), ... })                               │
│       │                                                                     │
│       v                                                                     │
│  superjson.serialize() ──> JSON with __superjsonMeta                        │
│       │                                                                     │
│       v                                                                     │
│  Response { content-type: application/superjson }                           │
└─────────────────────────────────────────────────────────────────────────────┘
                                       │
                                   HTTP wire
                                       │
                                       v
┌─────────────────────────────────────────────────────────────────────────────┐
│  onResponse hook                                                            │
│                                                                             │
│  detect content-type: application/superjson                                 │
│       │                                                                     │
│       v                                                                     │
│  superjson.deserialize() ──> Date, Set, Map, BigInt restored                │
│       │                                                                     │
│       v                                                                     │
│  return deserialized data (skips default JSON.parse)                        │
└─────────────────────────────────────────────────────────────────────────────┘
```

The key constraint is that serialization must happen **before** `JSON.stringify`, not after. That's why this uses a response helper in the handler rather than middleware. Middleware only sees the `Response` after the body has been stringified, so type information (Date vs string) is already lost.

## When you don't need this

If you only need ISO date strings and can parse them on the client, plain JSON is fine:

```ts
app.get('/api/event', () => ({
  name: 'Launch party',
  date: new Date().toISOString(), // string on both sides
}))
```

Use custom serialization only when you need the client to receive actual `Date` objects, `Map`, `Set`, `BigInt`, or other types that `JSON.stringify` doesn't preserve.


---
title: Cloudflare Workers setup and bindings
url: "https://getspiceflow.com/cloudflare.md"
description: "Deploy Spiceflow to Cloudflare Workers with wrangler, KV, waitUntil, observability, page caching, and the same RSC app you run on Node or Bun."
---

# Cloudflare Workers setup and bindings

Cloudflare Workers setup, observability, background tasks, and KV page caching.

## Cloudflare RSC Setup

For Cloudflare Workers, keep the worker-specific SSR output and child environment wiring in Vite, then let your Worker default export delegate to `app.handle(request)`.

**Every Cloudflare Worker entry file must have a `default export` with a `fetch` handler.** Spiceflow does not generate this implicitly. Without it, the Worker has no entry point and requests will fail.

```jsonc
// wrangler.jsonc
{
  "main": "./src/main.tsx",
}
```

```ts
// vite.config.ts
import { cloudflare } from '@cloudflare/vite-plugin'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import spiceflow from 'spiceflow/vite'

export default defineConfig({
  plugins: [
    react(),
    spiceflow({ entry: './src/main.tsx' }),
    cloudflare({
      viteEnvironment: {
        name: 'rsc',
        childEnvironments: ['ssr'],
      },
    }),
  ],
})
```

```tsx
// src/main.tsx
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow().page('/', async () => {
  return <div>Hello from Cloudflare RSC</div>
})

export type App = typeof app

export default {
  fetch(request: Request) {
    return app.handle(request)
  },
}
```

See [`example-cloudflare/`](https://github.com/remorses/spiceflow/tree/main/example-cloudflare) for a complete working example.

### Terminal colors

The Cloudflare Vite plugin runs your worker code inside workerd, which doesn't expose a TTY. Color libraries like `picocolors` and `chalk` disable colors when they detect no TTY, so terminal output loses all formatting. Set `FORCE_COLOR=1` in your dev and build scripts to restore colors:

```json
{
  "scripts": {
    "dev": "FORCE_COLOR=1 vite dev",
    "build": "FORCE_COLOR=1 vite build"
  }
}
```

When you add or change bindings in `wrangler.jsonc`, run `wrangler types`. Wrangler regenerates `worker-configuration.d.ts`, which provides the global `Env` type and the typed `env` export from `cloudflare:workers`.

### Wrangler Environments

The `@cloudflare/vite-plugin` resolves and flattens your `wrangler.json` config at **build time** and writes it into `dist/rsc/wrangler.json`. When `wrangler deploy` runs, it reads this generated config — not your top-level `wrangler.json`. This means `wrangler deploy --env preview` alone is not enough if the build was done without specifying the environment.

Set the `CLOUDFLARE_ENV` env var during `vite build` so the plugin resolves the correct environment section:

```bash
# Build for preview environment
CLOUDFLARE_ENV=preview vite build && wrangler deploy --env preview

# Build for production (default, no env var needed)
vite build && wrangler deploy
```

Without `CLOUDFLARE_ENV=preview`, the generated `dist/rsc/wrangler.json` will contain the top-level config (production name, routes, KV namespaces, etc.) and `--env preview` will be ignored at deploy time.

## Bindings

The simplest way to read bindings is to import `env` directly from `cloudflare:workers`. Run `wrangler types` after changing `wrangler.jsonc` so Wrangler regenerates `worker-configuration.d.ts` — that gives `env` a type-safe `Env` shape automatically.

```tsx
import { Spiceflow } from 'spiceflow'
import { env } from 'cloudflare:workers'

export const app = new Spiceflow()
  .route({
    method: 'GET',
    path: '/kv/:key',
    async handler({ params }) {
      const value = await env.KV.get(params.key)
      return { key: params.key, value }
    },
  })
  .route({
    method: 'POST',
    path: '/queue',
    async handler({ request }) {
      const body = await request.json()
      await env.QUEUE.send(body)
      return { success: true, message: 'Added to queue' }
    },
  })

export default {
  fetch(request: Request) {
    return app.handle(request)
  },
}
```

## Automatic Tracing

On Cloudflare Workers, spiceflow automatically instruments every request with [custom spans](https://developers.cloudflare.com/workers/observability/traces/custom-spans/) using the native `tracing.enterSpan()` API. No `tracer` option needed; just enable tracing in `wrangler.jsonc`:

```jsonc
// wrangler.jsonc
{
  "observability": {
    "traces": {
      "enabled": true
    }
  }
}
```

Every request produces spans for middleware, handlers, loaders, layouts, pages, and RSC serialization. These appear alongside Cloudflare's automatic platform spans (KV reads, D1 queries, fetch calls) in the Cloudflare dashboard and OpenTelemetry exports.

```
GET /dashboard [server]
├── middleware - auth
├── loader - /dashboard        ← spiceflow span
├── page - /dashboard          ← spiceflow span
├── env.MY_KV.get("key")      ← automatic CF span
└── rsc.serialize              ← spiceflow span
```

Custom spans created with `context.tracer.startActiveSpan()` in your handlers also appear in the trace tree. The `span` and `tracer` on the handler context work the same as with an OTel tracer.

If you pass an explicit `tracer` to the Spiceflow constructor, it takes priority over the automatic Cloudflare tracer.

<Expandable title={<Markdown inline children="Cloudflare span limitations" />}>
  The Cloudflare tracing API is newer than OTel and doesn't support all `SpiceflowSpan` methods natively yet. Spiceflow bridges the gap where possible:

  * `span.setStatus()` — error statuses are mapped to `otel.status_code` and `otel.status_description` attributes
  * `span.recordException()` — mapped to `exception.type`, `exception.message`, and `exception.stacktrace` attributes
  * `span.updateName()` — no-op
  * `span.spanContext()` — returns `undefined` (CF planned for future)
  * `span.end()` — no-op (CF auto-ends spans when the callback returns)

  `span.setAttribute()` works fully. Error details from `recordException` and `setStatus` are visible as span attributes in the Cloudflare dashboard and any OTel export destination.
</Expandable>

## Observability

Every Cloudflare Workers project should enable observability to get logs, traces, and error visibility. Add this to your `wrangler.jsonc`:

```jsonc
// wrangler.jsonc
{
  "observability": {
    "enabled": true,
    "traces": {
      "enabled": true
    }
  }
}
```

`observability.enabled` turns on **logs** (console output, uncaught exceptions, request metadata). `observability.traces.enabled` turns on **traces** (span trees for every request).

### Streaming logs with wrangler tail

Stream live logs from your deployed worker in the terminal:

```bash
wrangler tail                    # all logs
wrangler tail --status error     # errors only
wrangler tail --search "TypeError"  # filter by text
wrangler tail --format json      # JSON output for piping to jq
```

Traces and historical logs are available in the [Cloudflare dashboard](https://dash.cloudflare.com) under Workers & Pages → your worker → **Observability**. Spiceflow sets `error.type`, `otel.status_code`, `exception.message`, and `exception.stacktrace` as span attributes on errors, so they are queryable in the dashboard.

## Background Tasks (`waitUntil`)

Spiceflow provides a `waitUntil` function in the handler context that allows you to schedule tasks in the background in a cross platform way. It will use the Cloudflare Workers `waitUntil` if present. It's currently a no-op in Node.js.

### Basic Usage

```ts
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow().route({
  method: 'POST',
  path: '/process',
  async handler({ request, waitUntil }) {
    const data = await request.json()

    // Schedule background task
    waitUntil(
      fetch('https://analytics.example.com/track', {
        method: 'POST',
        body: JSON.stringify({ event: 'data_processed', data }),
      }),
    )

    // Return response immediately
    return { success: true, id: Math.random().toString(36) }
  },
})
```

### Cloudflare Workers Integration

In Cloudflare Workers, `waitUntil` is automatically detected from the global context:

```ts
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow().route({
  method: 'POST',
  path: '/webhook',
  async handler({ request, waitUntil }) {
    const payload = await request.json()

    // Process webhook data in background
    waitUntil(
      processWebhookData(payload)
        .then(() => console.log('Webhook processed'))
        .catch((err) => console.error('Webhook processing failed:', err)),
    )

    // Respond immediately to webhook sender
    return new Response('OK', { status: 200 })
  },
})

async function processWebhookData(payload: any) {
  // Simulate time-consuming processing
  await new Promise((resolve) => setTimeout(resolve, 1000))
  // Save to database, send notifications, etc.
}

export default {
  fetch(request: Request) {
    return app.handle(request)
  },
}
```

### Custom `waitUntil` Function

You can also provide your own `waitUntil` implementation:

```ts
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow({
  waitUntil: (promise) => {
    // Custom implementation for non-Cloudflare environments
    promise.catch((err) => console.error('Background task failed:', err))
  },
}).route({
  method: 'GET',
  path: '/analytics',
  async handler({ waitUntil }) {
    // Schedule analytics tracking
    waitUntil(trackPageView('/analytics'))

    return { message: 'Analytics page loaded' }
  },
})

async function trackPageView(path: string) {
  // Track page view in analytics system
  console.log(`Page view tracked: ${path}`)
}
```

**Note:** In non-Cloudflare environments, if no custom `waitUntil` function is provided, the default implementation is a no-op that doesn't wait for the promises to complete.

## KV Page Caching

Use middleware to cache full-page HTML in Cloudflare KV. The deployment ID is included in the cache key so each deploy gets its own cache namespace — this prevents serving stale HTML that references old CSS/JS filenames with different content hashes.

This example uses `import { env } from 'cloudflare:workers'` to access KV bindings directly from anywhere in your code, without threading env through `.state()`. Run `wrangler types` whenever the bindings change so `env.PAGE_CACHE` stays type-safe.

```tsx
import { Spiceflow, getDeploymentId } from 'spiceflow'
import { env } from 'cloudflare:workers'

export const app = new Spiceflow()
  .use(async ({ request, waitUntil }, next) => {
    if (request.method !== 'GET') {
      return next()
    }

    const { pathname, search } = request.parsedUrl
    const deploymentId = await getDeploymentId()
    const cacheKey = `${deploymentId}:${pathname}${search}` // IMPORTANT. cache key must always include search to distinguish html and rsc responses

    const cached = await env.PAGE_CACHE.get(cacheKey)
    if (cached) {
      return new Response(cached, {
        headers: {
          'content-type': 'text/html; charset=utf-8',
          'x-cache': 'HIT',
        },
      })
    }

    const response = await next()
    if (!response || response.status !== 200) {
      return response
    }

    const html = await response.text()
    // Write to KV in the background so the response is not delayed
    waitUntil(
      env.PAGE_CACHE.put(cacheKey, html, {
        expirationTtl: 60 * 60 * 24 * 7, // 7 days
      }),
    )

    return new Response(html, {
      status: 200,
      headers: {
        'content-type': 'text/html; charset=utf-8',
        'x-cache': 'MISS',
      },
    })
  })
  .page('/', async () => {
    return (
      <div>
        <h1>Home</h1>
      </div>
    )
  })

export default {
  fetch(request: Request) {
    return app.handle(request)
  },
}
```

When a new version is deployed the build timestamp changes, so `getDeploymentId()` returns a different value and all cache keys are effectively new. Old entries expire naturally after 7 days.

## CORS for Static Assets

On Cloudflare Workers, static assets (JS, CSS, images, fonts) are served by Cloudflare's CDN **before** the Worker code runs. This means CORS headers set in your Worker code don't apply to static files. Without CORS, images loaded cross-origin can't be drawn to a `<canvas>`, fonts won't load from other origins, and `fetch()` from another domain can't read the response.

To enable CORS on all static assets, create a `public/_headers` file in your project:

```
/*
  Access-Control-Allow-Origin: *
```

Vite copies `public/` contents into the client build output, which becomes the Cloudflare `assets.directory`. Cloudflare reads `_headers` from there and applies the rules to all static asset responses. The `_headers` file itself is not served as an asset.

See the [Cloudflare Workers headers docs](https://developers.cloudflare.com/workers/static-assets/headers/) for path patterns, placeholders, and how to restrict CORS to specific origins.

## Prerendered Pages (`staticPage`)

`staticPage()` and `staticGet()` routes are rendered **once at build time**. The Worker serves the stored response on every request, after your `.use()` middleware, so redirects, auth checks, and header changes work on static pages too:

```tsx
export const app = new Spiceflow()
  .use(({ request, redirect }) => {
    if (new URL(request.url).pathname === '/about-us') throw redirect('/about')
  })
  .staticPage('/about', async () => <About />) // prerendered by `vite build`
  .page('/dashboard', async () => <Dashboard />) // rendered by the Worker per request
```

The Worker reads the files through the **assets binding**, so add one named `ASSETS`:

```jsonc
// wrangler.jsonc
{
  "assets": { "binding": "ASSETS" }
}
```

```
vite build ──► vite preview (built Worker in workerd) ──► GET /about, /about?__rsc
                                                            │
dist/client/cdn-cgi/_spiceflow/prerender/<deployment-id>/ ◄─┘   1-html, 1-rsc, manifest.json

GET /about ──► Worker ──► .use() middleware ──► env.ASSETS: 1-html (x-spiceflow-prerendered: 1)
                               │
                         redirect / 401
```

The files live under **`/cdn-cgi/`**, a path Cloudflare keeps for itself: visitors get a 404 there, only the Worker can read it. So a prerendered page is never served without your middleware, and the page is never rendered again, so the Worker only pays for the middleware. Without the `ASSETS` binding the build warns and pages render on every request.

The Cloudflare SSR entry imports `cloudflare:*` modules that Node cannot load, so the build starts the built Worker with `vite preview` and fetches each static route from it.

Absolute URLs in prerendered pages (`og:url`, canonical) use the single host in your Worker `routes`. With several hosts, or none (`workers.dev`), pass the public origin:

```ts
spiceflow({ entry: './src/main.tsx', prerenderOrigin: 'https://example.com' })
```

<Expandable title={<Markdown inline children="Rules for static pages on Cloudflare" />}>
  * A prerendered page cannot read cookies, headers, or bindings that differ per request; use `.page()` plus [Workers Cache](#edge-caching-workers-cache) for those.
  * Only the `content-type` of the prerendered response is stored. Set other headers like `cache-control` in middleware, which runs on every request.
  * Middleware also runs when `vite build` prerenders the page. That request has no cookies or user headers. If a middleware redirects or rejects it, the build fails.
  * `vite preview` does not block `/cdn-cgi/` like production does, so locally the files are also reachable directly.
</Expandable>

## Edge Caching (Workers Cache)

Cloudflare [Workers Cache](https://developers.cloudflare.com/workers/cache/) puts a **regionally tiered cache in front of your Worker**. On a cache hit, your Worker code never runs and you pay zero CPU time. Enable it with one line in `wrangler.jsonc` and control it with standard `Cache-Control` headers on your responses.

```jsonc
// wrangler.jsonc
{
  "cache": { "enabled": true }
}
```

That's it. No middleware needed. Any cacheable GET response with `Cache-Control` headers is automatically cached and served from Cloudflare's edge on subsequent requests.

### Setting Cache-Control on pages

Use the `response` context object to set headers from page handlers or layouts:

```tsx
import { Spiceflow } from 'spiceflow'

export const app = new Spiceflow()
  .page('/', async ({ response }) => {
    // Fresh for 5 min; serve stale for up to 1 hour while refreshing
    response.headers.set(
      'Cache-Control',
      'public, max-age=300, stale-while-revalidate=3600',
    )
    return <div>Cached at the edge</div>
  })
```

### Setting Cache-Control on API routes

```ts
export const app = new Spiceflow()
  .route({
    method: 'GET',
    path: '/api/data',
    handler() {
      return new Response(JSON.stringify({ ok: true }), {
        headers: {
          'Content-Type': 'application/json',
          'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600',
        },
      })
    },
  })
```

### Vary

Set the `Vary` header on your response to cache different variants per request header (e.g. content type, language). Workers Cache stores a separate cached variant per distinct combination of those header values.

```ts
response.headers.set('Vary', 'Accept, Accept-Language')
```

### Cache behavior

Workers Cache is **regionally tiered by default** with two layers: a lower tier in the data center closest to the user and an upper tier that aggregates across the network. The first request anywhere populates the upper tier; all subsequent requests from any data center can be served without running your Worker.

Key features:

* **`stale-while-revalidate`** serves the stale response immediately while refreshing in the background, so users never wait for a re-render
* **`Cache-Tag`** header lets you purge specific content programmatically via `ctx.cache.purge({ tags: ["product:123"] })`
* **Per-entrypoint caching** via the `exports` config lets you cache some entrypoints and not others (e.g. skip cache on a gateway that authenticates, cache the expensive backend)
* Cache belongs to the Worker, not the zone. Works on `workers.dev`, preview URLs, and Workers for Platforms

See the [Workers Cache docs](https://developers.cloudflare.com/workers/cache/) for the full feature surface including cache keys, purging, and composition patterns.

### Legacy `headersCache` middleware

Before Workers Cache existed (July 2026), the `headersCache` middleware from `spiceflow/cloudflare` was the only way to cache Worker responses at the edge. It uses the Cache API (`caches.default`) directly inside your Worker code. **This middleware is now deprecated for most use cases.** Prefer the native Workers Cache config above.

The middleware is still available for advanced scenarios like custom cache key logic or custom `shouldCache` predicates:

```ts
import { headersCache } from 'spiceflow/cloudflare'

app.use(headersCache({
  // Custom cache eligibility check
  shouldCache: (request, response) => response.status === 200,

  // Custom cache key (must be an absolute URL string or Request)
  cacheKey: (request) => {
    const url = new URL(request.url)
    url.search = '' // ignore query params
    return url.toString()
  },
}))
```

Key differences from native Workers Cache:

* The Worker still runs on every request (middleware checks cache internally)
* No tiered caching, per-colo only
* No `stale-while-revalidate` support
* No `Cache-Tag` purging

### Edge Cache vs KV Cache

|                 | Workers Cache (wrangler config)                  | KV Cache (above)                       |
| --------------- | ------------------------------------------------ | -------------------------------------- |
| **Storage**     | CDN edge, regionally tiered                      | KV, globally replicated                |
| **Durability**  | Ephemeral, can be evicted                        | Persistent until TTL                   |
| **Latency**     | Fastest (Worker doesn't run on hit)              | \~10-50ms                              |
| **Consistency** | Tiered, upper tier shared globally               | Eventually consistent (\~60s)          |
| **Best for**    | High-traffic pages, API responses                | Pages that must survive cache eviction |
| **Setup**       | `"cache": { "enabled": true }` in wrangler.jsonc | Requires KV binding in wrangler.jsonc  |


---
title: Split Workers with service bindings
url: "https://getspiceflow.com/service-bindings.md"
description: Split a large Cloudflare Worker into smaller Spiceflow services and call them with service bindings while keeping types and routing intact.
---

# Split Workers with service bindings

When your Worker bundle exceeds the **10 MiB free tier** (or 25 MiB paid) limit, usually because of WASM modules, image libraries, or other heavy dependencies, you can extract the expensive code into a **separate dedicated Worker** and call it from the main Worker via a [Cloudflare service binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/). Service binding calls execute on the same thread with near-zero latency; there is no network hop.

Service bindings connect **two independently deployed Workers**, each with its own bundle size budget.

```diagram
┌─────────────────────────────────────────┐        ┌────────────────────────────────────────┐
│  Main Worker (Spiceflow + Vite)         │        │  Dedicated Worker (Spiceflow only)     │
│                                         │        │                                        │
│  /           ──> React pages            │        │  /api/og ──> renderOgImage()           │
│  /dashboard  ──> React pages            │        │              (WASM, heavy deps)        │
│                                         │        │                                        │
│  /api/og ──> env.OG_WORKER.fetch(req) ──┼───────>│  No Vite plugin needed                 │
│                                         │        │                                        │
└─────────────────────────────────────────┘        └────────────────────────────────────────┘
```

## Service binding config

Add a `services` entry in the main Worker's `wrangler.jsonc`. Each environment needs its own binding pointing to the correct worker name for that environment.

```jsonc
// main worker wrangler.jsonc
{
  "services": [
    { "binding": "OG_WORKER", "service": "my-og-worker" }
  ],
  "env": {
    "preview": {
      "services": [
        { "binding": "OG_WORKER", "service": "my-og-worker-preview" }
      ]
    }
  }
}
```

Run `wrangler types` after adding the binding so `env.OG_WORKER` is typed.

## Proxy route in the main Worker

The main Worker adds a single route that forwards the request to the dedicated Worker. The response comes back directly; no serialization or network overhead.

```tsx
// main worker src/main.tsx
import { Spiceflow } from 'spiceflow'
import { env } from 'cloudflare:workers'

export const app = new Spiceflow()
  .page('/', async () => <div>Home</div>)
  .get('/api/og', ({ request }) => env.OG_WORKER.fetch(request))

export default {
  fetch(request: Request) {
    return app.handle(request)
  },
}
```

## Dedicated Worker

The dedicated Worker is a standalone Spiceflow app. Since it has no React pages, it does not need the Vite plugin or any RSC setup. Use a dynamic `import()` inside the handler to keep the heavy dependency out of the cold-start path; the module is only loaded when the route is actually called.

```tsx
// dedicated worker src/main.tsx
import { Spiceflow } from 'spiceflow'
import { z } from 'zod'

const querySchema = z.object({
  title: z.string(),
  description: z.string().optional(),
})

export const app = new Spiceflow().route({
  method: 'GET',
  path: '/api/og',
  query: querySchema,
  async handler({ query }) {
    // Dynamic import keeps WASM out of the cold-start path
    const { renderOgImage } = await import('./og.tsx')
    return renderOgImage(query)
  },
})

export default {
  fetch(request: Request) {
    return app.handle(request)
  },
}
```

## Deployment order

Deploy the dedicated Worker first (or alongside the main Worker). Both Workers need their own `wrangler.jsonc` with preview and production environment configs. The main Worker's service binding resolves at request time, so the dedicated Worker must be live before the main Worker can call it.

```bash
# Deploy dedicated worker first
cd my-og-worker && wrangler deploy

# Then deploy main worker
cd my-main-worker && wrangler deploy
```


---
title: Self-contained Docker builds with tracing
url: "https://getspiceflow.com/docker.md"
description: Build a self-contained Spiceflow image with traced dependencies so Docker and other container platforms can run the app without extra install steps.
---

# Self-contained Docker builds with tracing

The build output is self-contained — `dist/` includes all traced runtime dependencies in `dist/node_modules/`, so you can copy it directly into a Docker image without installing packages at deploy time. The dependency tracing uses `@vercel/nft` to find exactly which files from `node_modules/` are needed at runtime, copying only those into `dist/node_modules/`. This keeps the image small — typically 5-50MB of dependencies instead of hundreds of megabytes. On Vercel and Cloudflare, this step is skipped since those platforms have their own bundling.

The traced `dist/node_modules/` comes from whatever is currently installed in your local `node_modules/` at build time. NFT copies those files directly — no `npm install` runs during the Docker build.

<Expandable title={<Markdown inline children="Cross-platform native modules" />}>
  Package managers only install native modules for your current OS and CPU by default. If you develop on macOS and deploy to Linux (Docker), native packages like `esbuild`, `@swc/core`, or `lightningcss` will be macOS binaries and won't work in the container. You must install dependencies for all platforms **before** running `build`.

  Install the Linux native modules before building. Both pnpm and bun `--os`/`--cpu` flags are additive — they keep your current platform and add the target:

  ```bash
  # pnpm
  pnpm install --os linux --cpu x64

  # bun
  bun install --os linux --cpu x64
  ```

  Then run the build:

  ```bash
  pnpm build
  ```

  You can add a convenience script in `package.json` so you don't forget this step:

  ```jsonc
  {
    "scripts": {
      // installs linux native modules alongside current platform, then builds
      "build:docker": "pnpm install --os linux --cpu x64 && pnpm build"
    }
  }
  ```
</Expandable>

Example Dockerfile using `node:24-slim`:

```dockerfile
FROM --platform=linux/amd64 node:24-slim

WORKDIR /app

# IMPORTANT: Before building, install Linux native modules (both flags are
# additive — they keep your current platform and add the target):
#   pnpm install --os linux --cpu x64
#   bun install --os linux --cpu x64

COPY dist/ ./dist/
COPY public/ ./public/

EXPOSE 3000
CMD ["node", "dist/rsc/index.js"]
```

```bash
docker build --platform linux/amd64 -t my-app .
docker run -p 3000:3000 my-app
```

## Fixing wrong dependency versions in `dist/node_modules`

NFT traces transitive dependencies from whatever is installed in your local `node_modules/`. If a transitive dependency resolves to an old version (pulled in by a different package), that old version gets copied into `dist/node_modules/` and can break at runtime.

For example, if `undici@5` is installed as a transitive dependency of some other package, NFT will copy that old CJS-only version into `dist/node_modules/undici/`. At runtime, ESM code that does `import { interceptors } from "undici"` will fail because the old version doesn't support named ESM exports.

To fix this, install the correct version as a **direct dependency** in your project:

```bash
pnpm add undici@latest
```

This forces the package manager to hoist the latest version, so NFT traces that one instead of the old transitive copy. This pattern applies to any dependency where the traced version differs from what your code expects.


---
title: Deployment skew between clients and servers
url: "https://getspiceflow.com/deployment-skew.md"
description: How Spiceflow stamps a deployment id on Flight responses so stale browser clients detect a new deploy and hard-reload instead of breaking hydration.
---

# Deployment skew between clients and servers

How Spiceflow handles the case where the client is running code from an old deployment while the server has already been updated to a new one.

## Flight Data and Client References

When a server component renders JSX containing `"use client"` components, React serializes the tree into a **flight payload** — a streaming format that references client components by ID instead of embedding their code.

Each client component reference in the flight payload looks like:

```json
{ "id": "abc123", "name": "default", "chunks": [], "async": true }
```

The `id` is a **referenceKey** computed as `hash(relativeFilePath)` during the production build. The `name` is the export name (`default`, `MyComponent`, etc.). No chunk URLs appear in the flight payload — only this stable ID.

## How Client Reference IDs Are Calculated

`@vitejs/plugin-rsc` normalizes client reference IDs differently per environment:

* **Production build**: `referenceKey = hashString(relativeFilePath)` — a deterministic hash of the file's path relative to the project root. Same source tree → same hash, every time.
* **Dev mode**: a normalized URL from Vite's import analysis — changes between restarts, but that's fine since dev doesn't have deployments.

Because the hash is based on the file path (not file content or chunk hashes), the referenceKey is **stable across deployments** as long as you don't rename or move the file.

## Why the Client Loads Old URLs, Not New Ones

This is the key insight: the flight payload only contains the referenceKey (`abc123`), not a chunk URL. The chunk URL resolution happens entirely on the client side.

During the client build, `@vitejs/plugin-rsc` generates a **client references map** that gets compiled into the client bundle:

```ts
// Baked into the client JS bundle at build time
export default {
  "abc123": async () => {
    const m = await import("./user-components-f8a2c1.js")
    return m.export_abc123
  },
}
```

When the old client receives a flight payload from the new server:

```
New server renders JSX → flight payload contains ref "abc123"
    ↓
Old client's React runtime calls __vite_rsc_require__("abc123")
    ↓
Old client looks up "abc123" in its OWN baked-in references map
    ↓
Finds it → import("./user-components-f8a2c1.js")  ← old chunk URL
    ↓
CDN still serves the old chunk → component loads ✅
```

The old client never sees or uses the new server's chunk URLs. It always resolves through its own compiled map, loading its own version of every component — through the same React instance already in memory.

## No Duplicate React Instances

If the flight payload contained chunk URLs directly and the client loaded them, the new chunks would bring in a second copy of React — breaking hooks, context, and everything else. But because the client always loads chunks from its own build (via its baked-in references map), there's only ever one React instance. The new server's chunk layout is irrelevant to the old client.

## CDN Asset Persistence

This works because CDNs keep old hashed assets around:

* **Cloudflare Workers Assets** uses content-hash-based deduplication. On each deploy it compares file hashes with previously deployed assets and only uploads changed files. Old unchanged files remain accessible.
* **Cloudflare Pages** caches assets for up to a week after deployment.
* **Any CDN with immutable caching** for content-hashed files (`user-components-f8a2c1.js`) will continue serving old chunks.

As long as old chunk files aren't actively purged, the old client can load them indefinitely.

## Spiceflow's Approach

Spiceflow does **not** use cookies or 409 responses for deployment skew protection. Both client navigations and server actions execute normally against the new server, as long as referenced client components remain backward-compatible.

This works because:

1. The referenceKey (`hash(filePath)#exportName`) is stable — same file path, same ID across deploys
2. The flight payload contains only referenceKeys, not chunk URLs — the old client resolves from its own map
3. The old client loads its own chunks from CDN, using its own React instance
4. No duplicate modules, no hydration mismatches

Cross-deployment requests can fail in two cases:

* **New client component** — the new server renders JSX containing a `"use client"` component that didn't exist in the old build. The old client's references map won't have that ID.
* **Changed props interface** — a client component keeps the same file path but its props shape changes between deploys. The old client loads old component code that receives incompatible props from the new server. This is the same as any API contract change and is a code compatibility concern rather than a framework issue.

Each production build still stamps a unique **deployment ID** (build timestamp) into the server bundle, available via `getDeploymentId()`. This is useful for analytics, logging, and cache keys — but it's not used to block requests.

## Encryption of Bound Arguments

`@vitejs/plugin-rsc` encrypts **bound arguments** (closure-captured values) in server actions using AES-256-GCM. This protects server-side data that gets serialized into the RSC payload when an inline `"use server"` function captures variables from a server component.

By default the encryption key is randomly generated per build — meaning bound args encrypted by build N cannot be decrypted by build N+1. To make bound args survive across deployments, set the `RSC_ENCRYPTION_KEY` environment variable to a stable base64-encoded 32-byte key.

This only matters for inline server actions that capture variables. Top-level exported actions (in dedicated `"use server"` files) have no closures, so there's nothing to encrypt.

## Summary

```diagram
┌──────────────────────────────────────────────────────────────────────┐
│                What happens across deployments?                      │
├──────────────────────┬───────────────────────────────────────────────┤
│ Client navigation    │ Executes normally on new server               │
│ Server action call   │ Executes normally on new server               │
│ Action returns JSX   │ Old client resolves refs from own map ✅       │
│ Action bound args    │ Needs stable RSC_ENCRYPTION_KEY if used       │
│ New component in JSX │ Fails if component didn't exist in old build  │
│ Client ref IDs       │ Stable (hash of file path)                    │
│ Client chunk URLs    │ Old chunks served by CDN                      │
│ React instance       │ Always one — no duplicates                    │
└──────────────────────┴───────────────────────────────────────────────┘
```
