Streaming SSR
Streaming is a spectrum of correctness vs performance. Juice makes you choose explicitly instead of hiding the tradeoff behind defaults.
The Problem
Your page fetches data from an API. The fetch takes 500ms. Without streaming, the user stares at a blank screen for 500ms before seeing anything. With streaming, they see the page layout immediately and the data appears when it is ready.
But streaming has a cost: once you start sending bytes, you have committed to a status code (usually 200). If a component deeper in the tree throws a 404 or needs a redirect, it is too late to change the HTTP status. You have already told the browser "200 OK."
Juice gives you three modes to navigate this tradeoff. This is a decision, not a feature.
The Three Modes
Decision Guide
Ask yourself: "Do my components throw Response objects for 404s and redirects?" and "How important is time-to-first-byte?"
| Mode | Value | TTFB | Status Codes | Best For |
|---|---|---|---|---|
| Wait | false | Highest | Always correct | Pages with thrown Responses (404, redirect) |
| Shell | 'shell' | Medium | Correct for shell, 200 for streamed content | Most apps (recommended) |
| Full Stream | true | Lowest | Always 200 | Performance-critical pages where status codes do not matter |
Wait (false) -- the default
The runtime renders the entire component tree, collects all thrown Response objects, and only then sends the HTTP response. TTFB suffers because nothing is sent until everything is ready. But status codes are always correct: a 404 is a real 404, a redirect is a real redirect.
// "I need correct status codes. My components throw Response
// objects for 404/redirect. I'll accept slower TTFB."
createRouter(manifest, {
streaming: false, // default
});Coming from Express: this is what you are used to. Render the template, check for errors, then send the response. Same model.
Shell ('shell') -- recommended
The runtime waits for the synchronous shell (layouts, synchronous components) to render. If anything in the shell throws a Response, the status code is correct. Once the shell is ready, it starts streaming. Remaining Suspense boundaries resolve progressively: their content streams in as each one completes.
// "I need fast TTFB AND correct status codes for the initial
// shell. Suspense boundaries stream progressively."
createRouter(manifest, {
streaming: 'shell',
});This is the best balance for most apps. Your layout renders immediately (fast TTFB). Auth checks in the layout can still throw redirects (correct status codes). Data-heavy child components wrapped in Suspense stream in as they resolve.
Full Stream (true)
The runtime sends bytes as soon as possible. Status code is committed immediately (always 200). Thrown Response objects after the initial bytes are caught and handled via inline recovery scripts.
// "I need the fastest possible TTFB. I accept that status
// code is always 200 and errors get recovery scripts."
createRouter(manifest, {
streaming: true,
});Use this for marketing pages, landing pages, or any page where first-paint speed matters more than HTTP semantics.
Same Page, Three Behaviors
Consider a product page that fetches from a database:
export default async function Product({ params }: { params: { id: string } }) {
const product = await db.find(params.id);
if (!product) throw new Response('Not Found', { status: 404 });
return <h1>{product.name}</h1>;
}- Wait: Full render completes. If product is not found, HTTP response is
404 Not Found. If found,200 OKwith the full HTML. TTFB = database query time + render time. - Shell: Layout streams immediately. If the product component is inside a Suspense boundary, the layout arrives first (fast TTFB). If the product is not found, the error is handled inside the Suspense fallback (status is still 200 because headers were already sent with the shell). If the component is NOT in Suspense, behavior matches Wait mode.
- Full Stream: Headers sent immediately with
200. If product is not found, an inline recovery script replaces the component with error content. The status code is already 200. SEO bots see 200 for a missing page.
Partial Segment Rendering
During SPA navigation, Juice does not re-render or re-send your layouts. Only the page component is fetched and updated. This is the default behavior -- there is nothing to opt into.
When you click a Link or call router.push(), the client automatically sends an X-Juice-Segment: page header along with Accept: text/x-component. The server sees this header and skips layout wrapping entirely. It renders only the page component, serializes it as an RSC payload, and sends it back. The client swaps in the new page while layouts stay mounted.
// What happens during SPA navigation:
//
// 1. User clicks <Link href="/settings">
// 2. Client sends: GET /settings
// Accept: text/x-component
// X-Juice-Segment: page
// 3. Server renders ONLY the Settings page component
// (RootLayout, DashboardLayout are NOT re-rendered)
// 4. Client receives RSC payload, React reconciles
// 5. Layouts stay mounted. Page content swaps.The practical result: sidebar scroll position is preserved. Open modals stay open. Form inputs in the layout keep their values. Draft text in a shell-level chat widget is not lost. Only the page segment changes.
This is different from a full HTML navigation where the browser tears down the entire document and rebuilds it. It is also different from client-side routing frameworks that re-render the entire component tree on every navigation -- Juice skips the layouts at the server level, so layouts are never even re-executed for SPA navigations.
Error Recovery in Streaming
When streaming is enabled and an error occurs inside a Suspense boundary after headers have been committed, the runtime injects an inline <script> that replaces the failed boundary with the error fallback content. This avoids a broken page while maintaining the stream.
Be honest about the cost: these inline scripts break strict Content Security Policy. If you have script-src 'self' in your CSP headers, the recovery scripts are blocked and the user sees a broken page. Use the nonceoption to make them CSP-compliant:
createRouter(manifest, {
streaming: 'shell',
nonce: crypto.randomUUID(), // Added to all inline scripts
});
// Your CSP header:
// Content-Security-Policy: script-src 'nonce-<value>'loading.tsx vs response.boundary
Two ways to control loading states. Use whichever matches your team's style.
File convention (loading.tsx): place a file next to routes in a directory. The compiler wraps those routes in a Suspense boundary with this component as the fallback. Good for teams who want discoverability -- open the directory, see the loading state.
// app/routes/blog/loading.tsx
export default function BlogLoading() {
return <div className="skeleton">Loading post...</div>;
}
// app/routes/blog/error.tsx
export default function BlogError({ error }: { error: Error }) {
return <div className="error">Failed to load post.</div>;
}Export convention (response.boundary): set the streaming mode per route. Good for developers who want explicit control without extra files.
export const response = {
boundary: 'shell', // Override the global streaming mode for this route
};For shared loading states across multiple routes, use a layout with a Suspense boundary:
// app/routes/dashboard/layout.tsx
import { Suspense } from 'react';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="dashboard">
<nav>Dashboard Nav</nav>
<Suspense fallback={<div className="skeleton">Loading...</div>}>
{children}
</Suspense>
</div>
);
}When NOT to Stream
If your page is fully static and cached at the CDN, streaming adds complexity with no benefit. The CDN serves the full HTML from cache in one shot. Streaming only helps when the server is doing work (data fetching, computation) that takes time.
// Static page -- streaming adds nothing
export const prerender = true;
export const response = {
headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
};
export default function About() {
return <h1>About Us</h1>;
}Also skip streaming for API routes that return JSON. Streaming is for HTML responses with React components, not for Response.json().