Skip to content

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:

FeatureDescription
Path Parameter InferenceAutomatically infers { id: string } from /users/[id]
Search ParamsType-safe query parameters per route
Hash ParamsType-safe hash parameters (unique to Ereo)
Context InheritanceTyped context accumulated from parent layouts
Stable InferenceAdding head/meta never breaks loader types

Quick Start ​

ts
// 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
tsx
// 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 ​

ts
// 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 ​

PatternExampleInferred Type
Dynamic[id]{ id: string }
Optional[[page]]{ page?: string }
Catch-all[...path]{ path: string[] }

InferParams Type ​

ts
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 ​

ts
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'>  // true

RouteTypes Registry ​

The RouteTypes interface is automatically generated by the bundler and contains type information for all routes.

Structure ​

ts
// 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 ​

ts
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 ​

ts
// 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()
tsx
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 ​

ts
// 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()
tsx
<TypedLink
  to="/docs/[topic]"
  params={{ topic: 'getting-started' }}
  hash={{ section: 'installation', highlight: 'npm' }}
>
  Installation Guide
</TypedLink>
// Generates: /docs/getting-started#section=installation&highlight=npm

Context Inheritance ​

Context types are accumulated from parent layouts, providing type safety for shared data.

Layout Context ​

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

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

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

TanStack Start Comparison ​

FeatureTanStack StartEreoJS
Path param inferenceYes (breaks with loader)Yes (stable)
Search params per routeLimitedFull support
Hash paramsNoFull support
Adding head breaks typesYesNo
Adding meta breaks typesYesNo
Context inheritance typesManualAutomatic
Type generation configRequiredZero-config

Example: Complete Route ​

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

Released under the MIT License.