@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.1npm install spiceflow@rsc react react-dom
12345678910111213// 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', }), ], })
example-cloudflare/ for a complete working example.@tailwindcss/vite and tailwindcss, then add the Vite plugin:1npm install @tailwindcss/vite tailwindcss
12345678910111213// 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(), ], })
globals.css file with Tailwind and any CSS variables you need:12345678/* src/globals.css */ @import 'tailwindcss'; :root { --radius: 0.625rem; --background: var(--color-white); --foreground: var(--color-neutral-800); }
123456789101112131415161718192021// 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> ) })
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.Only available when using the Vite plugin.
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:123456789import { 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> |
.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.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071// 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.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.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..page(), .layout() returning JSX)onClick, etc.)throw expressions: throw redirect(router.href(...)) in any handler.loader() return value (e.g. return { url: router.href(...) }).get() or .post() return value (e.g. return { result: await fetchClient(...) })return redirect(router.href(...)) inside .page() on paths with loadersRegisteredApp-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..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.1234567891011121314151617181920212223242526export 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> })
<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.<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.<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.<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.<Head> in the .page() or .layout() handler, then render the client component next to it:123456789101112// 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 /> </> ) })
1234567// 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)} /> }
document.title in an effect. <Head> is for the server-rendered document.Page Name | Site Name.<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.1234567891011.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> ) })
123456789101112131415161718function 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 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:12345678910111213141516171819202122232425import { 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> ) }, })
query is Record<string, string | undefined> — you lose autocomplete, typos go unnoticed, and there's no documentation of what the page accepts.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.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:12345678910111213141516171819'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> ) }
.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:1234567891011121314export 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) }, })
?tag=a&tag=b (not comma-separated). Single values are automatically wrapped into arrays when the schema expects z.array():1234567891011121314// 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) }, })
"use client" at the top of the file. These are hydrated in the browser and can use hooks like useState.1234567891011121314// 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> ) }
'use client' files'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.1234567891011121314151617// 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 }
'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.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./about uses <Map /> and route /dashboard uses <Chart />, visiting /about will never download the Chart component's JavaScript."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:123456// BAD — one big chunk for everything // src/components/index.tsx 'use client' export { Chart } from './chart' export { Map } from './map' export { Table } from './table'
1234567891011121314151617// 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'
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.12345678910111213141516171819202122import { 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.::view-transition-old(...) and ::view-transition-new(...). See the React <ViewTransition> docs.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.1234567891011import { 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 dependenciesnode_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 for symptoms, diagnosis, and fixes.<ProgressBar /> once in the root layout. For manual client-side async work, wrap the call in ProgressBar.start() / ProgressBar.end():1234567891011121314151617181920212223242526272829303132333435363738// 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> ) }
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.ProgressBar.start() / ProgressBar.end() over standalone startProgressBar() or endProgressBar() exports.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."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:123456789101112131415161718// 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, })) }
.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):1234567891011121314151617181920212223242526272829import { 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.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.1234567891011121314// 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 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 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.href() for links so route and query changes are caught by TypeScript.12345678910111213141516// 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> ) }
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:12345678910111213// 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> ) }
app through props or imports — every import is still fully type-checked against the root app's route table./orgs/:orgId/* accept template literals with interpolated values. TypeScript template literal types ensure only strings matching a registered route pattern are accepted:12345678910111213// 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"
/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:123456789101112// 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> ) }
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.router import is the same typed singleton everywhere outside loaders and API route handlers.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:12345678910111213141516171819202122232425262728293031323334// 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).router.replace to update without adding a history entry:123456789101112131415161718import { 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') }
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.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.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..page('/\*') next to API routes/api/v2/*) and aliases like /signup. Those requests then 404 as HTML instead of reaching the real handler.response.status = 404 when children == null.redirect and response.status inside .page() and .layout() handlers to control navigation and HTTP status codes:1234567891011121314151617181920212223242526272829303132333435363738394041424344454647import { 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
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:1234567891011// 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' }, }) })
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.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.<Link> that navigates to a page throwing context redirect(), the router performs the redirect client-side without a full page reload.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.123456789101112131415161718192021222324252627282930313233// 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' })