Error Handling
Every framework has a happy path. This page documents what happens when things go wrong: rendering failures, server action errors, streaming interruptions, and how to observe them.
The onError Hook
The onError option on createRouter is the single place where all unhandled errors surface. If you do not set it, Juice logs to console.error and returns a generic 500 Internal Server Errorwith no details.
createRouter(manifest, {
onError: (error, req) => {
// Log to your monitoring service
logger.error({
url: req.url,
method: req.method,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
// Return a generic response (never leak stack traces)
return new Response('Internal Server Error', {
status: 500,
headers: { 'Content-Type': 'text/plain' },
});
},
});The hook receives the raw error (whatever was thrown) and the original Request. Return a Response to send to the client. This is the place to integrate with Sentry, Datadog, OpenTelemetry, or any error tracking.
Thrown Responses
React 19 supports throwing Response objects from async server components. Juice catches these and returns them directly as the HTTP response. This is how 404s and redirects work:
// 404
export default async function Product({ params }) {
const product = await db.find(params.id);
if (!product) throw new Response('Not Found', { status: 404 });
return <h1>{product.name}</h1>;
}
// Redirect
export default async function OldPage() {
throw new Response(null, {
status: 303,
headers: { Location: '/new-page' },
});
}Thrown Response objects do not trigger onError. They are intentional control flow, not errors.
Rendering Failures
Before streaming starts
If a component throws during rendering and no bytes have been sent yet (the default streaming: false mode), Juice catches the error, calls onError, and returns the error response with the correct status code.
In dev mode (mode: 'development'), the error response is a rich HTML overlay with syntax-highlighted source code, stack trace, and the request URL. In production, it is the generic response from your onError hook.
After streaming starts
When streaming: true or 'shell' is enabled and an error occurs inside a Suspense boundary after HTTP headers have been committed (status 200 already sent), Juice cannot change the status code. Instead, it injects an inline recovery script that replaces the failed Suspense boundary with an error message in the DOM:
- Dev mode: the recovery script shows the error message (HTML-escaped)
- Production: the recovery script shows "Something went wrong"
The error is still logged to console.error on the server. If you need the error to reach your monitoring service in streaming mode, add logging to onError — it is called for streaming errors too.
Module load failures
If a route module cannot be imported (missing file, syntax error, broken dependency), Juice throws a ModuleLoadError with the module ID, the URL that matched, and the underlying cause. This error reaches onError with enough context to diagnose the problem:
// The error message includes:
// - The module ID that failed to load
// - The URL pattern that matched
// - The root option (if set)
// - The underlying import() error
[juice/runtime] Failed to load module "./app/routes/settings.tsx"
for route "/settings" (resolved from root "file:///app/server.ts")
Cause: SyntaxError: Unexpected tokenServer Action Errors
When a server action throws, the behavior depends on whether the action was invoked via a form submission or via callServer.
Form submissions (POST)
If the action throws, onError is called and the response is returned to the browser. In practice, this means:
- With JavaScript enabled: React catches the error and the nearest error boundary renders
- Without JavaScript: the browser receives the error response directly (500 page)
callServer (RSC protocol)
When callServer invokes an action and it throws, the error is serialized into the RSC wire format and sent back to the client. React deserializes it and throws it in the client component that called the action. Use useActionState to catch it:
'use client';
import { useActionState } from 'react';
function AddTask({ action }) {
const [state, formAction, isPending] = useActionState(action, null);
return (
<form action={formAction}>
<input name="title" />
<button disabled={isPending}>Add</button>
{state?.error && <p className="error">{state.error}</p>}
</form>
);
}Error Boundaries
Juice supports file-based error boundaries via catch.tsx. Place a catch.tsx file next to routes in a directory and the compiler wraps those routes in a React error boundary:
// app/routes/dashboard/catch.tsx
export default function DashboardError({ error }: { error: Error }) {
return (
<div className="error-page">
<h1>Something went wrong</h1>
<p>The dashboard encountered an error. Please try refreshing.</p>
</div>
);
}Error boundaries catch rendering errors in their subtree. They do notcatch errors in event handlers, async code outside React, or server action failures (use useActionState for those).
Observability Patterns
Juice does not include a logging or tracing library. Here are patterns for integrating with your observability stack.
Structured logging
createRouter(manifest, {
onError: (error, req) => {
const url = new URL(req.url);
console.error(JSON.stringify({
level: 'error',
service: 'juice-app',
url: url.pathname,
method: req.method,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
timestamp: new Date().toISOString(),
}));
return new Response('Internal Server Error', { status: 500 });
},
});Request tracing via middleware
// app/routes/middleware.ts
import { setContext, createContextKey } from '@cmj/juice/runtime';
export const requestIdKey = createContextKey<string>('requestId');
export default async function tracing(req: Request, next: () => Promise<Response>) {
const requestId = crypto.randomUUID();
setContext(req, requestIdKey, requestId);
const start = performance.now();
const res = await next();
const duration = performance.now() - start;
console.log(JSON.stringify({
requestId,
method: req.method,
url: new URL(req.url).pathname,
status: res.status,
duration: Math.round(duration),
}));
// Add the request ID to the response for client-side correlation
const headers = new Headers(res.headers);
headers.set('X-Request-Id', requestId);
return new Response(res.body, { status: res.status, headers });
}Common Pitfalls
- Leaking stack traces in production. The default
onErrorreturns a generic message. If you override it, make sure you do not senderror.stackin the response body. - Streaming + strict CSP. Recovery scripts break
script-src 'self'. Use thenonceoption oncreateRouterto make them CSP-compliant. - Thrown Response vs thrown Error. A thrown
Responseis intentional (404, redirect). A thrownErroris unexpected. Only the latter triggersonError. - Error boundaries do not catch server errors. Error boundaries catch client-side rendering errors. If a server component throws during SSR (before hydration), the error is handled by the streaming pipeline, not by a client-side error boundary.