Skip to content

File and Export Conventions

Special filenames and exports that Juice recognizes in app/routes/. Every convention is optional. Use what you need, skip what you do not.

File Conventions

These filenames have special meaning when placed inside app/routes/. The compiler discovers them at build time and wires them into the manifest.

FilenamePurposeReceivesWhen to Use
layout.tsxWraps all routes in its directory and subdirectories. Chains root to leaf.{ children, request }Always for root (HTML shell). Use nested layouts for shared UI (admin nav, dashboard sidebar).
middleware.tsRuns before all routes in its directory. Onion model with next().(req, next)Cross-cutting concerns: auth, logging, CORS. Skip if the logic is route-specific.
loading.tsxSuspense fallback for routes in its directory. Compiler wraps routes in <Suspense>.No propsWhen you want a loading skeleton while async data resolves. Only useful with streaming enabled.
error.tsxError boundary fallback for routes in its directory.{ error }Graceful error UI. If you prefer programmatic error handling (try/catch, thrown Responses), skip this.
home.tsxIndex route for the root directory (/).Standard PagePropsThe landing page. Equivalent to index.tsx but more descriptive.
index.tsxIndex route for any directory (/blog for blog/index.tsx).Standard PagePropsDirectory listing pages. Use in subdirectories. home.tsx or index.tsx for root, not both.
[param].tsxDynamic route segment. Brackets become URL params via URLPattern.Standard PageProps with typed paramsAny route with variable segments: product IDs, blog slugs, user profiles.
[...slug].tsxCatch-all route. Matches any number of path segments.Standard PagePropsCMS pages, documentation with nested paths, fallback routes.

When File Conventions Are Overkill

loading.tsx and error.tsx are discovered by the compiler and automatically wired in. If you only need a loading state in one specific place, use <Suspense fallback={...}> directly in your component. The file convention is for team-wide consistency, not a requirement.

Export Conventions

These named exports are recognized on route files (.tsx and .ts). The compiler reads them at build time and the runtime uses them to configure the HTTP response.

ExportTypeWhen to UseWhen It Is Overkill
defaultReact component (sync or async)Every page route. This is the page itself.API-only routes. Use named HTTP method exports instead.
response{ head?, headers?, boundary? }Any route that needs metadata, custom headers, or streaming config.Routes with no SEO needs and default headers.
response.head{ title?, description? }Setting page title and meta description for SEO.API routes, non-public pages.
response.headersRecord<string, string> | (req: Request) => Record<string, string>Custom Cache-Control, X-Robots-Tag, etc.Pages where default headers are sufficient.
response.boundaryboolean | 'shell'Per-route streaming mode override.When the global streaming mode works for all routes.
headersRecord<string, string>Legacy. Use response.headers instead.--
metadata{ title?, description? }Legacy. Use response.head instead.--
prerenderbooleanStatic pages. Sets immutable cache headers.Dynamic pages that depend on request data.
GET(req: Request) => ResponseAPI endpoints. JSON responses. Content negotiation.Pages that only serve HTML.
POST(req: Request) => ResponseWebhook handlers, API mutations without server actions.Forms using server actions (which handle POST automatically).
PUT, DELETE, PATCH(req: Request) => ResponseRESTful API endpoints.Most web apps. These are for APIs, not pages.

Directory Structure Example

app/
  routes/
    layout.tsx           # Root layout (html, head, body)
    global.css           # Global styles (imported by layout)
    home.tsx             # / route
    middleware.ts        # Root middleware (logging, DB pool)
    about.tsx            # /about route
    blog/
      layout.tsx         # Blog layout (nested)
      loading.tsx        # Blog suspense fallback
      index.tsx          # /blog route (listing)
      [slug].tsx         # /blog/:slug route (individual post)
    admin/
      middleware.ts      # Auth middleware (redirect if not logged in)
      layout.tsx         # Admin layout (sidebar nav)
      dashboard.tsx      # /admin/dashboard route
      error.tsx          # Admin error boundary
      users/
        index.tsx        # /admin/users route
        [id].tsx         # /admin/users/:id route
    api/
      middleware.ts      # CORS middleware
      health.ts          # /api/health (GET handler)
      users.ts           # /api/users (GET, POST handlers)
  components/
    counter.tsx          # 'use client' component
    login-form.tsx       # 'use client' form component

Response Configuration In Depth

The response export is the primary way to configure HTTP responses for your routes. This section covers every aspect of how it works.

Three Export Forms

There are three equivalent ways to configure your response. All produce the same result at runtime.

1. Unified response export:

export const response = {
  head: {
    title: 'About Us',
    description: 'Learn about our team.',
  },
  headers: {
    'Cache-Control': 'public, max-age=3600',
  },
  boundary: {
    pending: LoadingSkeleton,
    error: ErrorFallback,
  },
};

2. Individual metadata export:

export const metadata = {
  title: 'About Us',
  description: 'Learn about our team.',
};

3. Individual headers export:

export const headers = {
  'Cache-Control': 'public, max-age=3600',
};

If both export response and export metadata exist, response.head takes priority.

Static vs Dynamic

Each export form supports both static objects and dynamic functions.

Static object:

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

Dynamic function:

export function response({ params, searchParams, request }) {
  return {
    head: { title: `Product ${params.id}` },
  };
}

Async function:

export async function response({ params }) {
  const post = await getPost(params.id);
  return {
    head: { title: post.title },
  };
}

Dynamic headers only:

export function headers(req: Request) {
  return { 'X-Custom': req.url };
}

Note: The metadata function receives { params, searchParams } (no request). The headers function receives Request.

Supported Head Fields

FieldHTML ElementExample
title<title>'About Us'
description<meta name="description">'Company info'
keywords<meta name="keywords">'react, rsc'
Any og:* key<meta property="og:*">'og:image': '/og.png'
Any twitter:* key<meta property="twitter:*">'twitter:card': 'summary'

Custom meta names (e.g. author, robots) are not yet supported. Only title, description, keywords, and og:/twitter: prefixed keys are recognized.

Header Merge Behavior

  • Custom headers override defaults.
  • Content-Type is locked to text/html; charset=utf-8 and cannot be overridden.
  • export const prerender = true sets Cache-Control: public, max-age=31536000, immutable but only if no custom Cache-Control is already set.
  • If you set both prerender = true and headers = { 'Cache-Control': 'max-age=60' }, your custom header wins.

Boundary Config

export const response = {
  boundary: {
    pending: LoadingSkeleton,  // Suspense fallback
    error: ErrorFallback,      // Error boundary
  },
};

These are component references, not JSX. Pass the function itself, not <LoadingSkeleton />.

Full Example

A complete real-world route using all response fields:

import { LoadingSkeleton } from '../components/loading';
import { ErrorFallback } from '../components/error';

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);
  if (!post) throw new Response('Not Found', { status: 404 });
  return <article>{post.content}</article>;
}

export async function response({ params }) {
  const post = await getPost(params.slug);
  return {
    head: {
      title: `${post?.title ?? 'Not Found'} — My Blog`,
      description: post?.excerpt,
      'og:image': post?.coverImage,
      'og:type': 'article',
      'twitter:card': 'summary_large_image',
    },
    headers: {
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
    },
    boundary: {
      pending: LoadingSkeleton,
      error: ErrorFallback,
    },
  };
}

Known Limitations

  • Only direct exports are detected: export const response = ... works, const r = ...; export { r as response } does not.
  • Head fields limited to: title, description, keywords, og:*, twitter:*. Custom meta names like author or robots are not injected.
  • Content-Type header cannot be overridden.
  • Async metadata is not cached in dev mode — the function is called on every request.
  • response.boundary only works for page routes, not API routes.

When NOT to Follow Conventions

Conventions are defaults, not requirements. If your team finds loading.tsxfiles scattered across directories harder to maintain than explicit <Suspense> boundaries in code, skip the file convention. If you prefer React 19 head hoisting (<title> in your component) over the response.head export, that works too. The conventions exist for discoverability in teams. Solo developers may prefer explicit code over implicit file magic.