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.
| Filename | Purpose | Receives | When to Use |
|---|---|---|---|
layout.tsx | Wraps 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.ts | Runs 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.tsx | Suspense fallback for routes in its directory. Compiler wraps routes in <Suspense>. | No props | When you want a loading skeleton while async data resolves. Only useful with streaming enabled. |
error.tsx | Error boundary fallback for routes in its directory. | { error } | Graceful error UI. If you prefer programmatic error handling (try/catch, thrown Responses), skip this. |
home.tsx | Index route for the root directory (/). | Standard PageProps | The landing page. Equivalent to index.tsx but more descriptive. |
index.tsx | Index route for any directory (/blog for blog/index.tsx). | Standard PageProps | Directory listing pages. Use in subdirectories. home.tsx or index.tsx for root, not both. |
[param].tsx | Dynamic route segment. Brackets become URL params via URLPattern. | Standard PageProps with typed params | Any route with variable segments: product IDs, blog slugs, user profiles. |
[...slug].tsx | Catch-all route. Matches any number of path segments. | Standard PageProps | CMS 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.
| Export | Type | When to Use | When It Is Overkill |
|---|---|---|---|
default | React 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.headers | Record<string, string> | (req: Request) => Record<string, string> | Custom Cache-Control, X-Robots-Tag, etc. | Pages where default headers are sufficient. |
response.boundary | boolean | 'shell' | Per-route streaming mode override. | When the global streaming mode works for all routes. |
headers | Record<string, string> | Legacy. Use response.headers instead. | -- |
metadata | { title?, description? } | Legacy. Use response.head instead. | -- |
prerender | boolean | Static pages. Sets immutable cache headers. | Dynamic pages that depend on request data. |
GET | (req: Request) => Response | API endpoints. JSON responses. Content negotiation. | Pages that only serve HTML. |
POST | (req: Request) => Response | Webhook handlers, API mutations without server actions. | Forms using server actions (which handle POST automatically). |
PUT, DELETE, PATCH | (req: Request) => Response | RESTful 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 componentResponse 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
| Field | HTML Element | Example |
|---|---|---|
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-Typeis locked totext/html; charset=utf-8and cannot be overridden.export const prerender = truesetsCache-Control: public, max-age=31536000, immutablebut only if no customCache-Controlis already set.- If you set both
prerender = trueandheaders = { '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 likeauthororrobotsare not injected. Content-Typeheader cannot be overridden.- Async metadata is not cached in dev mode — the function is called on every request.
response.boundaryonly 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.