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.

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 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 tracing with zero overhead when disabled

Installation

npm install spiceflow@rsc

[!IMPORTANT] Spiceflow is still in pre-release. Install with spiceflow@rsc, not spiceflow@latest.

AI Agents

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

npx -y skills add remorses/spiceflow

Basic Usage

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

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)

Use .route() instead of .get()/.post() when you want to pass Zod schemas for validation — it accepts request, response, query, and params schemas.

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:

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:

// 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' })],
})

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.

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.

// 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
})

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:

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: '[email protected]' }
  })
  .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:

import { Spiceflow, json } from 'spiceflow'

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

// Avoid — Response.json() erases the type, fetch client sees unknown
throw Response.json({ error: '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

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}`
  },
})

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

Response Schema

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.

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({ error: z.string() }),
  },
  handler({ params }) {
    const user = findUser(params.id)
    if (!user) {
      // TypeScript validates: 404 is in the response map, and { error: string } matches the 404 schema
      throw json({ error: 'not found' }, { status: 404 })
    }
    return { id: user.id, name: user.name }
  },
})

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-expect-error — 500 is not in the response schema
throw json({ error: 'server error' }, { status: 500 })

// @ts-expect-error — number doesn't match { error: 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 for the full client-side pattern.

Middleware

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

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.

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:

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:

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:

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.

  • 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:

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.

You can stack multiple static roots:

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:

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.

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:

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:

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:

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

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().

// 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.

// 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.

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:

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

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). More specific routes always take precedence regardless of registration order:

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

[!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.

Mounting Sub-Apps

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:

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:

// 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' })],
})

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.

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

Fetch Client

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.

Export the app type from your server code:

// 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:

// 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: '[email protected]' },
})
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 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.

You can set headers both globally (on the client) and per request:

// Global headers — sent with every request
const safeFetch = createSpiceflowFetch('http://localhost:3000', {
  headers: {
    Authorization: 'Bearer my-token',
  },
})

// Per-request headers — merged with global headers
const result = await safeFetch('/users', {
  headers: { 'X-Request-Id': '123' },
})

// Dynamic global headers with a function
const safeFetch2 = createSpiceflowFetch('http://localhost:3000', {
  headers: (path, options) => ({
    Authorization: `Bearer ${getToken()}`,
  }),
})

The client also supports onRequest/onResponse hooks, retries, and a custom fetch function. If onResponse returns a non-undefined value, it replaces the default response parsing (useful for custom serialization):

const safeFetch = createSpiceflowFetch('http://localhost:3000', {
  retries: 3,
  onRequest: (path, options) => {
    console.log(`→ ${options.method} ${path}`)
    return options
  },
  onResponse: (response) => {
    console.log(`← ${response.status}`)
  },
})

You can also pass a Spiceflow app instance directly for server-side usage without network requests:

const safeFetch = createSpiceflowFetch(app)
const greeting = await safeFetch('/hello')
if (greeting instanceof Error) throw greeting

For path matching patterns, error handling, server-side fetch, type-safe RPC, and path building, see Fetch Client (Advanced). To support types like Date, Map, Set, and BigInt across the wire, see Custom Serialization.

OpenAPI

Spiceflow can generate a full OpenAPI 3.1 document from your routes without any extra configuration. Mount the openapi plugin and every route you registered on the app is picked up automatically — the same Zod schemas that validate the request and type the handler context are also the source of parameters, requestBody, and responses in the emitted document.

import { openapi } from 'spiceflow/openapi'
import { Spiceflow } from 'spiceflow'
import { z } from 'zod'

export const app = new Spiceflow()
  .use(openapi({ path: '/openapi.json' }))
  .route({
    method: 'GET',
    path: '/hello',
    query: z.object({
      name: z.string(),
      age: z.number(),
    }),
    response: z.string(),
    handler({ query }) {
      return `Hello, ${query.name}!`
    },
  })
  .route({
    method: 'POST',
    path: '/user',
    request: z.object({
      name: z.string(),
      email: z.string().email(),
    }),
    response: z.object({ id: z.string() }),
    async handler({ request }) {
      const body = await request.json()
      return { id: 'usr_' + body.name }
    },
  })

const openapiSchema = await (
  await app.handle(new Request('http://localhost:3000/openapi.json'))
).json()

Every route accepts a detail field that is spread as-is into the OpenAPI operation object. Use it to add tags, summaries, descriptions, or any other OpenAPI Operation field:

app.route({
  method: 'POST',
  path: '/users',
  request: z.object({ name: z.string() }),
  response: z.object({ id: z.string() }),
  detail: {
    tags: ['users'],
    summary: 'Create a user',
    description: 'Creates a new user in the current organization.',
    operationId: 'createUser',
  },
  handler({ request }) {
    return { id: 'usr_123' }
  },
})

For status-code response maps, centralized error responses with onError, shared Zod schemas across routes, hiding internal routes from the document, writing markdown descriptions with string-dedent, generating a local openapi.json file from a script, and preserving fetch client type safety with thrown error responses, see OpenAPI docs.

Adding CORS Headers

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!'
  },
})

Server Lifecycle

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

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 for usage.

Tracing (OpenTelemetry)

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. Server timing is enabled by default when a tracer is provided, exposing those spans as a Server-Timing response header in Chrome DevTools with nested descriptions like handler - /users/:id > db.query. Set serverTiming: false to disable it. Handlers can also read traceId and spanId from span.spanContext?.() when the tracer supports it. See Tracing docs for setup, span trees, custom spans, and examples. If you use Strada as your OTel backend, see Observability with Strada.

Testing

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

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')
expect(res.loaderData).toEqual({ ... })

The spiceflow Vite plugin auto-detects vitest and configures everything. Server actions become plain callable functions, middleware runs before pages, and .state() lets you inject test doubles for databases and services without mocking modules. See the full Testing guide for authentication patterns, stateful workflows, and dependency injection.

React Framework (RSC)

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.

Setup

Install the dependencies and create a Vite config:

npm install spiceflow@rsc react react-dom
// 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. See example-cloudflare/ for a complete working example.

Tailwind CSS

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

npm install @tailwindcss/vite tailwindcss
// 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:

/* 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:

// 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 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 for the full setup guide and example-shadcn/ for a working example.

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.

// 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.

Do not import or use router inside .loader(), .get(), .post(), or .route() handlers in the same file that initializes export const app = new Spiceflow(). The router type is derived from typeof app, while those handlers feed return types back into typeof app through loader data or typed API responses, so TypeScript can report recursive circular errors like TS7022.

Using router.href() for links inside .page() and .layout() JSX is okay in simple app entries because their rendered JSX does not feed app route metadata the same way. If a loader-heavy app still hits a circular typeof app error, move the link UI into a component module until the router type is split from loader data.

Context redirect() intentionally accepts a plain string. Do not pass router.href() into redirects inside app-entry handlers (.page(), .layout(), etc.) — redirect return values participate in handler return inference and can reintroduce the circular type path in loader-heavy apps. Standalone "use server" action files (separate from the app entry) are safe to use router.href() since they do not feed return types back into typeof app.

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.

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>Getti