declare module block at the bottom of your app entry file:12345678910111213// 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 } }
12345678import { 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
declare module block, all APIs still work at runtime. They just accept any string without compile-time validation.123456import { 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')
typeof app and pass it as a generic.any types silently, so you lose safety without a warning.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.tsconfig.json project. If two files in the same project both declare SpiceflowRegister with different app types, TypeScript raises a compile error:123error TS2717: Subsequent property declarations must have the same type. Property 'app' must be of type 'typeof appA', but here has type 'typeof appB'.
tsconfig.json) and they don't import each other's entry files.12345workspace/ packages/ admin/ # tsconfig.json, declares SpiceflowRegister for adminApp customer/ # tsconfig.json, declares SpiceflowRegister for customerApp shared/ # shared utils, no SpiceflowRegister
declare module block. TypeScript follows import chains and pulls in all module augmentations it finds.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.admin imports shared, and shared imports customer, the augmentation from customer leaks into admin's compilation.declare module pattern. Other apps that get imported into it use explicit generics instead:12345678910111213141516171819// 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')
.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:123456// 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
| 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 |
router.href() or throw redirect(router.href('/path')) inside .page() handlers:123456789101112131415import { 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 } }
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.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.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..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:RegisteredApp → SpiceflowRegister['app'] → typeof apptypeof app depends on the full builder chain, which is still being typed| 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 |
router.href() in component files where autocomplete is fully functional. Inside the chain, the compiler still catches all errors even if autocomplete is incomplete.SpiceflowRegister interface starts empty:1export interface SpiceflowRegister {}
RegisteredApp type checks if the interface has been augmented:12345export type RegisteredApp = SpiceflowRegister extends { app: infer App extends AnySpiceflow } ? App : AnySpiceflow
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).RegisteredApp:123export const router: RouterBase<RegisteredApp> = { ... } export function useLoaderData<App extends AnySpiceflow = RegisteredApp>() { ... } export function createSpiceflowFetch(domain: string): SpiceflowFetch<RegisteredApp>
RegisteredApp threads the app type through every API automatically.