Type-Safe Routing
EreoJS provides end-to-end type safety for routing that exceeds what other frameworks like TanStack Start offer. This includes compile-time path parameter inference, typed search/hash params, and stable type inference that never breaks when adding features.
Overview
The type-safe routing system consists of several integrated parts:
| Feature | Description |
|---|---|
| Path Parameter Inference | Automatically infers { id: string } from /users/[id] |
| Search Params | Type-safe query parameters per route |
| Hash Params | Type-safe hash parameters (unique to Ereo) |
| Context Inheritance | Typed context accumulated from parent layouts |
| Stable Inference | Adding head/meta never breaks loader types |
Quick Start
// app/routes/users/[id].tsx
import { defineRoute } from '@ereo/data'
export const route = defineRoute('/users/[id]')
.loader(async ({ params }) => {
// params is typed as { id: string }
const user = await db.user.findUnique({ where: { id: params.id } })
return { user }
})
.head(({ data }) => ({
// data.user is fully typed - NEVER breaks!
title: data.user.name,
description: data.user.bio,
}))
.build()
export const { loader } = route// In any component
import { TypedLink } from '@ereo/client'
// TypeScript validates this at compile time
<TypedLink to="/users/[id]" params={{ id: "123" }}>
View User
</TypedLink>
// Error: missing required params
<TypedLink to="/users/[id]">View User</TypedLink>
// Error: route doesn't exist
<TypedLink to="/invalid">Invalid</TypedLink>Import
// Core types
import type {
InferParams,
RouteTypes,
RouteParamsFor,
SearchParamsFor,
HashParamsFor,
ContextFor,
LoaderDataFor,
ActionDataFor,
} from '@ereo/core'
// Route definition builder
import { defineRoute } from '@ereo/data'
// Type-safe components
import { TypedLink, TypedNavLink } from '@ereo/client'
// Type-safe navigation
import { typedNavigate, typedRedirect } from '@ereo/client'Path Parameter Inference
EreoJS infers parameter types at compile time from route path patterns using template literal types.
Supported Patterns
| Pattern | Example | Inferred Type |
|---|---|---|
| Dynamic | [id] | { id: string } |
| Optional | [[page]] | { page?: string } |
| Catch-all | [...path] | { path: string[] } |
InferParams Type
import type { InferParams } from '@ereo/core'
// Single dynamic param
type Params1 = InferParams<'/users/[id]'>
// { id: string }
// Multiple params
type Params2 = InferParams<'/users/[id]/posts/[postId]'>
// { id: string; postId: string }
// Optional param
type Params3 = InferParams<'/blog/[[page]]'>
// { page?: string }
// Catch-all
type Params4 = InferParams<'/docs/[...path]'>
// { path: string[] }
// Combined
type Params5 = InferParams<'/[lang]/docs/[version]/[...path]'>
// { lang: string; version: string; path: string[] }Helper Types
import type {
HasParams,
ParamNames,
IsOptionalParam,
IsCatchAllParam,
StaticPrefix,
IsStaticPath,
} from '@ereo/core'
// Check if route has params
type Has = HasParams<'/users/[id]'> // true
type NoParams = HasParams<'/about'> // false
// Get param names
type Names = ParamNames<'/users/[id]/posts/[postId]'>
// 'id' | 'postId'
// Check if param is optional
type IsOpt = IsOptionalParam<'/blog/[[page]]', 'page'> // true
// Check if param is catch-all
type IsCatch = IsCatchAllParam<'/docs/[...path]', 'path'> // true
// Get static prefix
type Prefix = StaticPrefix<'/api/users/[id]'> // '/api/users'
// Check if path is fully static
type Static = IsStaticPath<'/about'> // trueRouteTypes Registry
The RouteTypes interface is automatically generated by the bundler and contains type information for all routes.
Structure
// Auto-generated in .ereo/routes.d.ts
declare module '@ereo/core' {
interface RouteTypes {
'/users/[id]': {
params: { id: string };
search: { tab?: string; sort?: 'asc' | 'desc' };
hash: { section?: string };
loader: { user: User };
action: { success: boolean };
context: { auth: AuthContext };
meta: true;
handle: { breadcrumb: string };
};
}
}Extracting Types
import type {
RouteParamsFor,
SearchParamsFor,
HashParamsFor,
LoaderDataFor,
ActionDataFor,
ContextFor,
} from '@ereo/core'
// Get params type
type Params = RouteParamsFor<'/users/[id]'>
// { id: string }
// Get search params type
type Search = SearchParamsFor<'/users/[id]'>
// { tab?: string; sort?: 'asc' | 'desc' }
// Get hash params type (unique to Ereo!)
type Hash = HashParamsFor<'/users/[id]'>
// { section?: string }
// Get loader data type
type LoaderData = LoaderDataFor<'/users/[id]'>
// { user: User }
// Get action data type
type ActionData = ActionDataFor<'/users/[id]'>
// { success: boolean }
// Get inherited context type
type Context = ContextFor<'/users/[id]'>
// { auth: AuthContext }Search Params
Define typed search parameters per route.
Defining Search Params
// app/routes/posts.tsx
import { z } from 'zod'
import { ereoSchema } from '@ereo/data'
// Export searchParams schema for type generation
export const searchParams = ereoSchema(z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(10),
sort: z.enum(['newest', 'oldest', 'popular']).default('newest'),
tag: z.string().optional(),
}))
export const route = defineRoute('/posts')
.searchParams(searchParams)
.loader(async ({ searchParams }) => {
// searchParams is typed!
const posts = await db.posts.findMany({
skip: (searchParams.page - 1) * searchParams.limit,
take: searchParams.limit,
orderBy: getOrderBy(searchParams.sort),
where: searchParams.tag ? { tags: { has: searchParams.tag } } : {},
})
return { posts }
})
.build()Using Search Params in Links
import { TypedLink } from '@ereo/client'
// Type-safe search params
<TypedLink
to="/posts"
search={{ page: 2, sort: 'popular', tag: 'react' }}
>
Popular React Posts
</TypedLink>Hash Params (Unique to Ereo)
EreoJS is the only framework that provides type-safe hash parameters. This is useful for client-side state that should be bookmarkable.
Defining Hash Params
// app/routes/docs/[topic].tsx
import { z } from 'zod'
import { ereoSchema } from '@ereo/data'
export const hashParams = ereoSchema(z.object({
section: z.string().optional(),
highlight: z.string().optional(),
}))
export const route = defineRoute('/docs/[topic]')
.hashParams(hashParams)
.loader(async ({ params, hashParams }) => {
const doc = await getDoc(params.topic)
return {
doc,
initialSection: hashParams?.section,
}
})
.build()Using Hash Params in Links
<TypedLink
to="/docs/[topic]"
params={{ topic: 'getting-started' }}
hash={{ section: 'installation', highlight: 'npm' }}
>
Installation Guide
</TypedLink>
// Generates: /docs/getting-started#section=installation&highlight=npmContext Inheritance
Context types are accumulated from parent layouts, providing type safety for shared data.
Layout Context
// app/routes/_layout.tsx
export const route = defineRoute('/')
.loader(async ({ request }) => {
const user = await getUser(request)
return { user } // This becomes part of child context
})
.build()
// app/routes/dashboard/_layout.tsx
export const route = defineRoute('/dashboard')
.loader(async ({ context }) => {
// context.user is typed from parent!
const settings = await getSettings(context.user.id)
return { settings }
})
.build()
// app/routes/dashboard/settings.tsx
export const route = defineRoute('/dashboard/settings')
.loader(async ({ context }) => {
// Both user and settings are typed!
return {
user: context.user,
settings: context.settings,
}
})
.build()Performance Optimizations
The type generation system includes several optimizations for large codebases:
Lazy Evaluation
Types use lazy evaluation to prevent TypeScript compiler slowdowns:
// Generated types use LazyEval wrapper
type LazyEval<T> = T extends infer U ? U : never;
export interface RouteTypes {
'/posts': {
loader: LazyEval<typeof import('./routes/posts')['loader'] extends (
...args: any[]
) => infer R
? Awaited<R>
: never>;
};
}Object Maps
Route paths use object maps instead of union types for better performance:
// Better performance than: type RoutePath = '/a' | '/b' | '/c' | ...
type RoutePathMap = {
'/users': true;
'/users/[id]': true;
'/posts': true;
// ...
};
type RoutePath = keyof RoutePathMap;Split Files
For projects with 100+ routes, types are split into multiple files:
.ereo/
routes_users.d.ts
routes_posts.d.ts
routes_api.d.ts
index.d.tsTanStack Start Comparison
| Feature | TanStack Start | EreoJS |
|---|---|---|
| Path param inference | Yes (breaks with loader) | Yes (stable) |
| Search params per route | Limited | Full support |
| Hash params | No | Full support |
Adding head breaks types | Yes | No |
Adding meta breaks types | Yes | No |
| Context inheritance types | Manual | Automatic |
| Type generation config | Required | Zero-config |
Example: Complete Route
// app/routes/posts/[slug].tsx
import { defineRoute } from '@ereo/data'
import { z } from 'zod'
import { ereoSchema } from '@ereo/data'
// Search params schema
export const searchParams = ereoSchema(z.object({
showComments: z.coerce.boolean().default(true),
}))
// Hash params schema (unique to Ereo!)
export const hashParams = ereoSchema(z.object({
comment: z.string().optional(),
}))
// Route definition with stable type inference
export const route = defineRoute('/posts/[slug]')
.searchParams(searchParams)
.hashParams(hashParams)
.loader(async ({ params, searchParams }) => {
const post = await db.posts.findUnique({
where: { slug: params.slug },
include: { comments: searchParams.showComments },
})
if (!post) throw new Response('Not Found', { status: 404 })
return { post }
})
.action(async ({ params, body }) => {
await db.comments.create({
data: { postSlug: params.slug, ...body },
})
return { success: true }
})
.head(({ data }) => ({
title: data.post.title,
description: data.post.excerpt,
openGraph: {
title: data.post.title,
image: data.post.coverImage,
},
}))
.meta(({ data }) => [
{ title: data.post.title },
{ name: 'description', content: data.post.excerpt },
{ property: 'og:image', content: data.post.coverImage },
])
.build()
// Export for route module
export const { loader, action } = route