Skip to content

Routing

Juice uses file-system routing because configuration is a bug factory. The filesystem IS the router config. There is nothing to keep in sync.

Why File-System Routing?

In Express, you write app.get('/product/:id', handler) and hope it matches the file that contains handler. In Next.js, you create app/product/[id]/page.tsx. In Juice, you create app/routes/product/[id].tsx. Same idea as Next.js, but without the mandatory page.tsx naming convention -- the filename IS the page.

The Vite plugin scans app/routes/ at build time, discovers every file, and writes the URL patterns into the manifest. The runtime never touches the filesystem. It reads the manifest, matches URLs with URLPattern, and imports the matching module.

Convention Table

FileURL PatternWhy This Convention
home.tsx/Named index for the root. More descriptive than index.tsx.
about.tsx/aboutFilename becomes the URL segment.
blog/index.tsx/blogDirectory index. Use when the directory has child routes.
blog/[slug].tsx/blog/:slugDynamic segment. Brackets become URL params.
api/health.ts/api/healthAPI route. .ts (not .tsx) signals no React rendering.
layout.tsxn/aWraps all routes in its directory. The compiler needs this at build time to generate the component tree.
middleware.tsn/aRuns before all routes in its directory. Chains root to leaf.
loading.tsxn/aSuspense fallback for routes in its directory.
error.tsxn/aError boundary fallback for routes in its directory.

home.tsx vs index.tsx

Both work for the / route. home.tsx is the convention in scaffolded projects. index.tsx works inside directories (blog/index.tsx = /blog). Pick one per directory and be consistent. Having both is an error.

Dynamic Routes

Brackets in filenames become URL parameters. At build time, the compiler sees[id] and emits the URLPattern :id. At runtime,URLPattern.exec() extracts the parameter value from the URL.

// app/routes/product/[id].tsx
import { redirect } from '@cmj/juice/runtime';
import type { PageProps } from '@cmj/juice/runtime';

export default async function Product(props: PageProps['/product/:id']) {
  const product = await db.find(props.params.id);

  if (!product) {
    throw new Response('Not Found', { status: 404 });
  }

  if (product.discontinued) {
    redirect('/products'); // throws a 302 Response
  }

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <span>Price: ${product.price}</span>
    </article>
  );
}

Typed PageProps

Juice generates a juice-env.d.ts file at build time that augments the PageProps interface via module augmentation. The actual type gives you three properties:

// What PageProps['/product/:id'] expands to:
{
  params: { id: string };
  searchParams: Record<string, string>;
  request: Request;
}
  • params -- the dynamic segments extracted from the URL./product/42 gives { id: "42" }.
  • searchParams -- the query string parsed into key-value pairs./products?page=2&sort=name gives { page: "2", sort: "name" }.
  • request -- the raw Request object. Use this for headers, cookies, or anything the other props do not cover.

Coming from Next.js: searchParams works the same way as the App Router's searchParams prop, but it is always a synchronous plain object, not a promise.

// app/routes/products.tsx
import type { PageProps } from '@cmj/juice/runtime';

export default async function Products(props: PageProps['/products']) {
  const page = Number(props.searchParams.page) || 1;
  const sort = props.searchParams.sort || 'created';

  const products = await db.products.list({ page, sort, limit: 20 });

  return (
    <div>
      <h1>Products (page {page})</h1>
      {products.map(p => <div key={p.id}>{p.name}</div>)}
    </div>
  );
}

Type-Safe Link

The Link component from @cmj/juice/client renders a standard <a> tag. It type-checks the href against your route patterns at compile time via the same module augmentation in juice-env.d.ts.

import { Link } from '@cmj/juice/client';

// TypeScript catches typos at compile time:
<Link href="/prodcut/42">View</Link>
//          ^^^^^^^ Error: '/prodcut/42' is not a valid route

// Correct usage:
<Link href="/product/42">View Product</Link>
<Link href="/about" prefetch="viewport">About</Link>
<Link href="/login" replace>Log In</Link>

After the initial HTML page load, all Link clicks fetch RSC payloads instead of HTML. The client sends Accept: text/x-component to the server, which responds with a compact RSC payload containing only the page component. Layouts stay mounted in the browser -- they are never re-fetched or re-rendered during SPA navigation.

Prefetching strategies: "hover" (default) prefetches the RSC payload when the user hovers over the link. "viewport" prefetches when the link enters the viewport. "none" disables prefetching.

SPA Navigation Behavior

After the browser loads the initial HTML page, Juice switches to SPA mode. All subsequent navigations -- Link clicks, useRouter().push()calls, and browser back/forward -- fetch RSC payloads instead of full HTML documents.

// What happens when you click a Link:
//
// 1. Client intercepts the click via the Navigation API
// 2. Fetches: GET /target-url
//    Headers: Accept: text/x-component, X-Juice-Segment: page
// 3. Server renders ONLY the target page component (layouts skipped)
// 4. Client receives RSC payload
// 5. React reconciles: page swaps in, layouts stay mounted
// 6. View Transitions API animates the swap (when available)

Because layouts stay mounted, all layout state is preserved across navigations: sidebar scroll position, open dropdowns, form inputs in the shell, active WebSocket connections managed by layout components. Only the page segment updates.

When the View Transitions API is available, page swaps are wrapped in document.startViewTransition() for smooth animations. In browsers without the API, the swap happens instantly. You can disable this via initNavigation(setPage, { viewTransitions: false }).

Route-as-API: Content Negotiation

This is the key insight that separates Juice from most React frameworks. The same URL can serve HTML to browsers and JSON to API clients. The decision is based on the Accept header.

// app/routes/api/users.ts
// Pure API route -- no React component, no HTML
export function GET(req: Request) {
  return Response.json({ users: [] });
}

export async function POST(req: Request) {
  const body = await req.json();
  const user = await db.users.create(body);
  return Response.json(user, { status: 201 });
}

When a route exports both a default component AND named HTTP method handlers, the method handlers take priority for matching methods. A GET export means the React component only renders when the client accepts HTML (browser navigation). An API client sending Accept: application/json gets the GET handler.

// app/routes/product/[id].tsx
// This route serves both HTML and JSON from the same URL
import type { PageProps } from '@cmj/juice/runtime';

export function GET(req: Request) {
  // API clients get JSON
  const url = new URL(req.url);
  const id = url.pathname.split('/').pop();
  const product = await db.find(id);
  return Response.json(product);
}

export default async function Product(props: PageProps['/product/:id']) {
  // Browsers get HTML
  const product = await db.find(props.params.id);
  return <h1>{product.name}</h1>;
}

Layouts

Layout files wrap all routes in their directory and subdirectories. They chain from root to leaf. Every layout receives children as a prop. Unlike importing a wrapper component manually, layouts are discovered at build time so the compiler can optimize the component tree and the manifest knows about the nesting structure.

// app/routes/layout.tsx -- Root layout
import React from 'react';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head><meta charSet="utf-8" /></head>
      <body>{children}</body>
    </html>
  );
}

// app/routes/admin/layout.tsx -- Nested layout
export default function AdminLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="admin-shell">
      <nav>Admin Nav</nav>
      <main>{children}</main>
    </div>
  );
}

Adding Routes with the CLI

juice add route about           # creates app/routes/about.tsx
juice add route blog/[slug]     # creates app/routes/blog/[slug].tsx
juice add route api/users       # creates app/routes/api/users.ts

The CLI generates the right boilerplate (React component for .tsx, handler exports for .ts) and includes the response export.

When NOT to Use File Routing

File routing breaks down when your routes are data-driven. If you have 200 product categories stored in a database, you do not want 200 files. Use a catch-all [...slug].tsx or a single dynamic route [category].tsx and resolve the category from the database.

File routing also does not help for complex URL patterns like/[lang]/[region]/product/[id]. You can nest directories ([lang]/[region]/product/[id].tsx), but if the nesting feels forced, consider whether a simpler route structure with query parameters would serve your users better.

Response Configuration

Every route can export a response object or function to configure metadata, headers, and boundary behavior.

Response Config on Routes

Page route with SEO:

export default function About() { return <h1>About</h1>; }

export const response = {
  head: { title: 'About', description: 'About us' },
};

Dynamic route with per-param metadata:

export default function Product({ params }) { ... }

export async function response({ params }) {
  const p = await getProduct(params.id);
  return { head: { title: p.name } };
}

API route with custom headers:

export function GET() { return Response.json({ ok: true }); }

export const headers = { 'Cache-Control': 'no-cache', 'X-Api-Version': '1' };