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.