Skip to content

Routing ​

EreoJS uses file-based routing where your file structure in the routes/ directory maps directly to URL paths. This convention eliminates manual route configuration while providing powerful features like dynamic segments, layouts, and route groups.

Basic Routes ​

Files in routes/ become URL paths:

FileURL
routes/index.tsx/
routes/about.tsx/about
routes/contact.tsx/contact
routes/blog/index.tsx/blog
routes/blog/archive.tsx/blog/archive
tsx
// routes/about.tsx
export default function About() {
  return <h1>About Us</h1>
}

Dynamic Routes ​

Use square brackets for dynamic segments:

FileURLparams
routes/posts/[id].tsx/posts/123{ id: '123' }
routes/users/[userId]/posts/[postId].tsx/users/5/posts/42{ userId: '5', postId: '42' }
tsx
// routes/posts/[id].tsx
import { createLoader } from '@ereo/data'

export const loader = createLoader(async ({ params }) => {
  // params.id contains the dynamic value
  const post = await getPost(params.id)
  return { post }
})

export default function Post({ loaderData }) {
  return <h1>{loaderData.post.title}</h1>
}

Optional Segments ​

Use double brackets for optional segments:

FileURLs
routes/posts/[[page]].tsx/posts, /posts/2
tsx
// routes/posts/[[page]].tsx
export const loader = createLoader(async ({ params }) => {
  const page = params.page ? parseInt(params.page) : 1
  const posts = await getPosts({ page })
  return { posts, page }
})

Catch-All Routes ​

Use [...slug] to capture all remaining segments:

FileURLparams.slug
routes/docs/[...slug].tsx/docs/intro['intro']
/docs/api/core['api', 'core']
/docs/a/b/c/d['a', 'b', 'c', 'd']
tsx
// routes/docs/[...slug].tsx
export const loader = createLoader(async ({ params }) => {
  const path = params.slug.join('/')
  const doc = await getDoc(path)
  return { doc }
})

Layouts ​

Create _layout.tsx files to wrap routes with shared UI:

routes/
├── _layout.tsx          # Root layout (wraps everything)
├── index.tsx
├── about.tsx
└── dashboard/
    ├── _layout.tsx      # Dashboard layout
    ├── index.tsx
    └── settings.tsx
tsx
// routes/_layout.tsx
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head>
        <title>My App</title>
      </head>
      <body>
        <nav>
          <a href="/">Home</a>
          <a href="/dashboard">Dashboard</a>
        </nav>
        {children}
      </body>
    </html>
  )
}
tsx
// routes/dashboard/_layout.tsx
export default function DashboardLayout({ children }) {
  return (
    <div className="dashboard">
      <aside>
        <nav>
          <a href="/dashboard">Overview</a>
          <a href="/dashboard/settings">Settings</a>
        </nav>
      </aside>
      <main>{children}</main>
    </div>
  )
}

Layouts can have their own loaders:

tsx
// routes/dashboard/_layout.tsx
export const loader = createLoader(async ({ request }) => {
  const user = await getUser(request)
  if (!user) throw redirect('/login')
  return { user }
})

export default function DashboardLayout({ children, loaderData }) {
  return (
    <div>
      <header>Welcome, {loaderData.user.name}</header>
      {children}
    </div>
  )
}

Route Groups ​

Parentheses create groups that don't affect the URL:

routes/
├── (marketing)/
│   ├── _layout.tsx      # Marketing layout
│   ├── index.tsx        # /
│   ├── about.tsx        # /about
│   └── pricing.tsx      # /pricing
└── (app)/
    ├── _layout.tsx      # App layout
    ├── dashboard.tsx    # /dashboard
    └── settings.tsx     # /settings

Use groups to:

  • Apply different layouts to different sections
  • Organize routes without changing URLs
  • Share middleware across related routes
tsx
// routes/(marketing)/_layout.tsx
export default function MarketingLayout({ children }) {
  return (
    <div className="marketing">
      <Header showSignUp />
      {children}
      <Footer />
    </div>
  )
}

// routes/(app)/_layout.tsx
export default function AppLayout({ children }) {
  return (
    <div className="app">
      <Sidebar />
      {children}
    </div>
  )
}

API Routes ​

For API-only routes, export functions named after HTTP methods (GET, POST, PUT, DELETE). These take priority over loader/action exports:

ts
// routes/api/posts.ts
export async function GET({ request, params, context }) {
  const posts = await db.posts.findMany()
  return Response.json({ posts })
}

export async function POST({ request }) {
  const body = await request.json()
  const post = await db.posts.create(body)
  return Response.json(post, { status: 201 })
}

export async function DELETE({ request }) {
  const { id } = await request.json()
  await db.posts.delete(id)
  return Response.json({ success: true })
}

You can also use loader (for GET) and action (for non-GET) as an alternative. This is more convenient when you don't need per-method control:

ts
// routes/api/posts.ts
import type { LoaderArgs, ActionArgs } from '@ereo/core'

export async function loader({ request }: LoaderArgs) {
  const posts = await db.posts.findMany()
  return posts // Automatically serialized to JSON
}

export async function action({ request }: ActionArgs) {
  const body = await request.json()
  const post = await db.posts.create(body)
  return post
}

Which approach to use? Use HTTP method exports (GET, POST, etc.) for REST APIs where you need fine-grained control per method. Use loader/action for page routes with components. Both approaches are valid — see Data Loading for all three approaches and when to pick each one.

Route Priority ​

When multiple routes could match, EreoJS uses this priority:

  1. Static routes - Exact matches (/about)
  2. Dynamic routes - Parameter segments (/posts/[id])
  3. Catch-all routes - Rest parameters (/docs/[...slug])

For routes at the same level, more specific patterns win:

routes/
├── posts/
│   ├── new.tsx           # /posts/new (highest priority)
│   ├── [id].tsx          # /posts/:id
│   └── [...slug].tsx     # /posts/* (lowest priority)

Route Configuration ​

Export a config object to configure route behavior:

tsx
// routes/posts/[id].tsx
import type { RouteConfig } from '@ereo/core'

export const config: RouteConfig = {
  // Rendering mode
  render: {
    mode: 'ssr',  // 'ssg' | 'ssr' | 'csr' | 'json' | 'xml' | 'rsc'
  },

  // Caching (edge, browser, and data layers)
  cache: {
    edge: {
      maxAge: 3600,
      staleWhileRevalidate: 86400,
    },
    data: {
      tags: ['posts'],
    },
  },

  // Islands configuration
  islands: {
    defaultStrategy: 'idle',  // 'load' | 'idle' | 'visible' | 'media' | 'none'
  },

  // Middleware chain (named or inline)
  middleware: ['auth', 'rateLimit'],

  // Progressive enhancement
  progressive: {
    forms: { fallback: 'server' },
    prefetch: { trigger: 'intent', data: true },
  },
}

Error Handling ​

EreoJS provides two ways to handle errors in routes.

Create a _error.tsx file in a route directory. It catches errors for that route segment and all nested routes. The error is passed as a prop:

tsx
// routes/_error.tsx (global error boundary)
export default function ErrorPage({ error }: { error: Error }) {
  return (
    <div>
      <h1>Something went wrong</h1>
      <p>{error?.message || 'An unexpected error occurred.'}</p>
      <a href="/">Go Home</a>
    </div>
  )
}

This is the pattern used in the create-ereo starter templates.

Option B: useRouteError Hook (Advanced) ​

For more control — such as distinguishing HTTP error responses from runtime errors — use the useRouteError hook:

tsx
// routes/posts/_error.tsx
import { useRouteError, isRouteErrorResponse } from '@ereo/client'

export default function PostsError() {
  const error = useRouteError()

  if (isRouteErrorResponse(error)) {
    if (error.status === 404) {
      return <h1>Post not found</h1>
    }
    return <h1>Error: {error.statusText}</h1>
  }

  return <h1>Something went wrong</h1>
}

Option C: Inline ErrorBoundary Export ​

You can also export an ErrorBoundary component directly from a route file. This catches errors for that specific route without creating a separate file:

tsx
// routes/blog/[slug].tsx
export async function loader({ params }) {
  const post = getPostBySlug(params.slug)
  if (!post) throw new Response('Not found', { status: 404 })
  return { post }
}

export default function BlogPost({ loaderData }) {
  return <article>{loaderData.post.title}</article>
}

// Inline error boundary — catches errors from this route's loader/component
export function ErrorBoundary({ error }: { error: Error }) {
  return <h1>Post Not Found</h1>
}

Loading States ​

Create _loading.tsx for route-level loading UI:

tsx
// routes/posts/_loading.tsx
export default function PostsLoading() {
  return (
    <div className="loading">
      <div className="skeleton" />
      <div className="skeleton" />
      <div className="skeleton" />
    </div>
  )
}

Middleware ​

Create _middleware.ts to run code before route handlers:

ts
// routes/dashboard/_middleware.ts
import type { MiddlewareHandler } from '@ereo/core'

export const middleware: MiddlewareHandler = async (request, context, next) => {
  const user = await getUser(request)

  if (!user) {
    return Response.redirect('/login')
  }

  context.set('user', user)
  return next()
}

Custom 404 Page ​

Create a _404.tsx file to display a custom page when no route matches:

tsx
// routes/_404.tsx
export default function NotFound() {
  return (
    <div>
      <h1>404 - Page Not Found</h1>
      <p>The page you're looking for doesn't exist.</p>
      <a href="/">Go Home</a>
    </div>
  )
}

Place _404.tsx at the root of routes/ for a global 404 page. It works alongside _error.tsx — the 404 page handles unmatched routes, while the error boundary handles runtime errors.

Revalidation Control ​

Export a shouldRevalidate function to control when a route's loader re-runs after navigation. Without it, loaders re-run on every navigation:

tsx
// routes/dashboard/stats.tsx
import type { ShouldRevalidateArgs } from '@ereo/core'

export function shouldRevalidate({ currentUrl, nextUrl }: ShouldRevalidateArgs) {
  // Only re-run the loader when search params change
  return currentUrl.search !== nextUrl.search
}

The function receives:

ts
interface ShouldRevalidateArgs {
  currentUrl: URL              // URL navigating away from
  nextUrl: URL                 // URL navigating to
  currentParams: RouteParams   // Current route params
  nextParams: RouteParams      // Next route params
  formMethod?: string          // Form method if triggered by an action
  formAction?: string          // Form action URL
  formData?: FormData          // Form data
  actionResult?: unknown       // Action result
  defaultShouldRevalidate: boolean  // Framework default (usually true)
}

Return true to re-run the loader, false to skip. Common patterns:

ts
// Skip revalidation for non-mutating navigations
export function shouldRevalidate({ formMethod, defaultShouldRevalidate }) {
  if (!formMethod) return false
  return defaultShouldRevalidate
}

// Only revalidate when params change
export function shouldRevalidate({ currentParams, nextParams }) {
  return currentParams.id !== nextParams.id
}

Route Metadata ​

Export a meta function for page metadata. It receives the loader data, route params, and current location:

tsx
// routes/posts/[id].tsx
import type { MetaArgs } from '@ereo/core'

export function meta({ data, params, location }: MetaArgs<{ post: Post }, { id: string }>) {
  return [
    { title: data.post.title },
    { name: 'description', content: data.post.excerpt },
    { property: 'og:title', content: data.post.title },
    { property: 'og:image', content: data.post.image }
  ]
}

Programmatic Navigation ​

Use the navigation API for client-side transitions:

tsx
import { navigate, goBack, goForward } from '@ereo/client'

// Navigate to a route
await navigate('/posts/123')

// With options
await navigate('/posts', {
  replace: true,           // Replace history entry instead of pushing
  state: { from: 'search' },  // Pass state to the target route
  viewTransition: true,    // Animate the transition (View Transitions API)
})

// History navigation
goBack()
goForward()

You can use standard HTML <a> tags or the <Link> component from @ereo/client. Both work — choose based on whether you need client-side navigation features.

Standard <a> tags work everywhere and trigger a full page navigation (server round-trip). The create-ereo starter templates use <a> tags for simplicity:

tsx
<a href="/posts">Posts</a>
<a href="/about">About</a>

<Link> and <NavLink> enable client-side navigation (no full page reload) and prefetching. Use these when you want faster transitions and preloading:

tsx
import { Link, NavLink } from '@ereo/client'

// Client-side navigation with prefetch on hover
<Link href="/posts/123" prefetch="intent">Read More</Link>

// NavLink highlights the active route
<NavLink
  href="/posts"
  className={({ isActive }) => isActive ? 'active' : ''}
>
  Posts
</NavLink>

Prefetch strategies for <Link>:

  • "none" - No prefetching
  • "intent" - Prefetch on hover/focus
  • "render" - Prefetch when link renders
  • "viewport" - Prefetch when visible

Tip: Start with <a> tags. Switch to <Link> when you want faster in-app navigation without full page reloads.

Type-Safe Routes ​

EreoJS provides built-in type utilities for route parameters and loader data. These types are available from @ereo/core:

tsx
import type { RouteParamsFor, LoaderDataFor } from '@ereo/core'

type PostParams = RouteParamsFor<'/posts/[id]'>
// { id: string }

type PostData = LoaderDataFor<'/posts/[id]'>
// Inferred from the route's loader return type (requires generated types)

For automatic type generation during build, use the types plugin in your ereo.config.ts:

ts
import { defineConfig } from '@ereo/core'
import { createTypesPlugin } from '@ereo/bundler'

export default defineConfig({
  plugins: [createTypesPlugin()],
})

The types plugin scans your routes directory and generates TypeScript declarations for route parameters, loader data, and action data. Types are regenerated automatically during development and on each build.

Anti-Patterns ​

Over-nesting routes ​

routes/app/dashboard/admin/settings/profile/index.tsx

Deep nesting creates hard-to-manage URLs like /app/dashboard/admin/settings/profile. Use route groups and flatter structures instead:

routes/(admin)/settings.tsx          # /settings
routes/(admin)/settings/profile.tsx  # /settings/profile

Catch-all as default ​

Don't use [...slug].tsx when you actually want a few specific routes. Catch-alls are harder to type, debug, and don't benefit from route-level configuration.

tsx
// Bad: catch-all when you know the routes
// routes/docs/[...slug].tsx
// Then manually matching slug[0] === 'intro' || slug[0] === 'api'

// Good: explicit routes
// routes/docs/intro.tsx
// routes/docs/api.tsx

Putting business logic in routes ​

Routes should delegate to service functions, not contain business logic:

tsx
// Bad: database queries and business rules inline
export const loader = createLoader(async ({ params }) => {
  const post = await db.query('SELECT * FROM posts WHERE id = ?', [params.id])
  if (post.status === 'draft' && post.author_id !== currentUser.id) {
    throw new Response('Forbidden', { status: 403 })
  }
  return { post }
})

// Good: delegate to a service
export const loader = createLoader(async ({ params, context }) => {
  const post = await postService.getVisiblePost(params.id, context.get('user'))
  return { post }
})

Edge Cases & Gotchas ​

Trailing slashes ​

EreoJS normalizes URLs by default — /about/ and /about resolve to the same route. If you need strict trailing slash behavior, configure it in ereo.config.ts.

URL encoding ​

Dynamic params are automatically decoded. params.id for /posts/hello%20world is "hello world", not "hello%20world". Be careful when using params in database queries or URLs.

Route group conflicts ​

Two route groups with the same file name create ambiguity:

routes/(marketing)/about.tsx    # /about
routes/(company)/about.tsx      # /about — conflict!

EreoJS resolves this by alphabetical group order, but it's better to avoid the conflict entirely.

Middleware and layout ordering ​

Middleware runs before the layout loader. If your middleware redirects, the layout loader never runs. Keep this in mind when middleware depends on layout data.

Released under the MIT License.