Skip to content

Core Concepts

Every feature in Juice is a transform on the HTTP Response. Understand this and the rest of the framework follows.

What Does a Web Server Do?

A web server receives a Request and returns a Response. That is the entire job. Frameworks add layers on top: routing, templating, middleware, data fetching. But underneath, it is always the same: request in, response out.

Most React frameworks hide this. Next.js gives you NextRequest and NextResponse with extra methods. Remix gives you loader and action conventions that return data objects. Juice does neither. Your app is one function:

import { createRouter } from '@cmj/juice/runtime';
import manifest from './flight-manifest.json';

// This is your entire app. One function.
const handler = createRouter(manifest);

// Every runtime knows how to call it:
Bun.serve({ fetch: handler });
// or: export default { fetch: handler };
// or: Deno.serve(handler);

createRouter takes a manifest (which maps URLs to components) and returns a function that accepts a standard Request and returns a standard Response. No proprietary types. No framework-specific abstractions.

The Manifest Bridge

React gives you components. The web gives you URLs. The manifest is the bridge: a JSON file that maps URL patterns to component modules, client chunks, CSS files, and server actions.

Build time (Vite)                     Runtime (your server)
 ─────────────────                     ─────────────────────
 Scan app/routes/                      Read manifest
 Discover patterns                     Match URL to pattern
 Bundle client chunks     ──────►      Import component module
 Extract server actions   manifest     Render RSC to HTML
 Track CSS imports                     Return Response

The manifest is JSON. You can read it. You can diff it across deploys. You can write a script that validates it. There is no magic runtime discovery, no hidden filesystem scanning, no webpack-specific module federation. The manifest is the contract.

Why JSON? Because JSON is inspectable. If your route is not showing up, open flight-manifest.json and search for the pattern. If it is not there, the Vite plugin did not discover it. This is intentionally debuggable. In Next.js, the equivalent data is buried in internal build artifacts that you are not supposed to read.

What happens if the manifest is stale? The runtime serves stale routes. You will see the old page, or a 404 for new routes. Run juice build to regenerate. In dev mode, the Vite plugin regenerates the manifest on every change, so staleness is a production concern only.

Two Render Pipelines

Juice uses the same component tree for two different rendering paths. The path depends on how the browser requests the page.

Initial Page Load: HTML

When a user types your URL into the address bar, clicks a link from an external site, or refreshes the page, the browser sends a normal HTTP request. The server renders your component tree (layouts + page) via react-dom/server's renderToReadableStream. The result is a full HTML document. The browser paints it immediately, then hydrates any 'use client' components to make them interactive.

Browser ──► GET /products (Accept: text/html)
Server  ──► renderToReadableStream (react-dom/server)
        ──► Full HTML: <html>...<RootLayout><ProductsPage/></RootLayout>...</html>
Browser ──► Paint HTML, hydrate 'use client' components

This path optimizes for first paint. Search engines get complete HTML. Users see content before any JavaScript executes.

SPA Navigation: RSC

After the initial load, all subsequent navigations within your app (clicking a Link, calling router.push()) take a different path. The client sends a fetch request with Accept: text/x-component and an X-Juice-Segment: page header. The server sees these headers and renders only the page component (skipping layouts) via react-server-dom-webpack's renderToReadableStream. The result is an RSC payload -- a compact serialization of your component tree that React can reconcile without touching the DOM nodes owned by layouts.

Browser ──► GET /products (Accept: text/x-component, X-Juice-Segment: page)
Server  ──► renderToReadableStream (react-server-dom-webpack)
        ──► RSC payload: page component only (layouts skipped)
Browser ──► React reconciles: layouts stay mounted, page swaps in

This path optimizes for transitions. Layouts stay mounted, so sidebar scroll position, modal state, form inputs in the shell -- all preserved. Only the page segment updates.

Why Two Pipelines?

HTML for first paint: fast FCP, SEO-friendly, works without JavaScript. RSC for subsequent navigations: state preservation, no full-page reload, smaller payloads (just the page, not the entire document). This is automatic. You do not opt in. Every Juice app gets both pipelines from the moment you call createRouter().

WinterCG: Standard APIs Only

Juice uses zero Node.js-specific APIs. The runtime is built on the primitives that every modern JavaScript runtime already implements:

  • Request / Response -- the HTTP exchange
  • URL -- parsing and constructing URLs
  • URLPattern -- matching URL patterns (route matching)
  • ReadableStream -- streaming HTML to the client
  • crypto.randomUUID() -- generating CSP nonces

This is not ideological purity. It is a practical choice. Cloudflare Workers does not have node:fs or node:http. Deno has them behind compatibility flags. By using only web-standard APIs, your Juice app runs on Workers without polyfills, on Bun at native speed, on Deno without flags, and on Node.js with the minimal adapter that bridges http.IncomingMessage to Request.

Coming from Express: you are used to req.params, req.query, res.json(), res.redirect(). In Juice, these are standard web APIs: new URL(req.url).searchParams, Response.json(), Response.redirect(). The knowledge transfers everywhere, not just to Juice.

Server Components by Default

Every .tsx file in app/routes/ is a React Server Component. Server components are async, can fetch data directly, and most importantly: they ship zero JavaScript to the browser. The HTML is rendered on the server and sent as a string.

This matters for performance. Every client component ('use client') adds to the JavaScript bundle the browser must download, parse, and execute. A page with 10 server components and 1 client counter ships only the counter's JavaScript. The rest is just HTML.

Coming from Next.js App Router: same mental model. The difference is that Juice enforces the boundary at build time. If a server component accidentally imports node:fs or a client component imports a server-only module, the compiler catches it before the code ships. Next.js catches some of these at runtime.

To make a component interactive, add 'use client' at the top of the file. Juice code-splits it into a separate chunk automatically. Keep client components small and push them to the leaves of your component tree.

The response Export

Every route can export a response object that transforms the HTTP Response before it leaves the server. This is not metadata in the Next.js sense -- it is a direct description of what the Response should contain.

export const response = {
  head: {
    title: 'Product Details',
    description: 'View product information',
  },
  headers: {
    'Cache-Control': 'public, max-age=3600',
  },
  boundary: 'shell',
};

The head object injects elements into <head>. The headers object (or function) sets HTTP response headers directly. The boundary controls streaming behavior for this route. Everything that configures the Response lives in one place.

Dynamic headers. When you need the request to compute headers, use a function:

export const response = {
  head: { title: 'Dynamic Page' },
  headers: (req: Request) => ({
    'X-Request-Id': req.headers.get('x-request-id') ?? crypto.randomUUID(),
  }),
};

Legacy separate exports. These still work but scatter configuration across multiple exports:

export const headers = { 'Cache-Control': 'no-store' };
export const metadata = { title: 'Page Title' };
export const prerender = true;

Use the unified response export for new code. The separate exports exist for backward compatibility.

Juice and SPAs

Juice is a full SSR-to-SPA framework. After the initial server render, your app behaves like a single-page application: navigations fetch RSC payloads instead of full HTML, layouts stay mounted, and client component state is preserved. Drag-and-drop, modals, forms, real-time UI — anything you build with 'use client' components works exactly as it would in a pure SPA.

The difference from a pure SPA: every page has a server-rendered initial load. This gives you SEO, fast first paint, and progressive enhancement for free. Subsequent navigations are client-side with no full page reloads.

When to Consider Alternatives

If your entire app is an API with no HTML rendering, you do not need a React framework. Use Hono, or just Bun.serve() directly.

If you need a large plugin ecosystem with hundreds of community integrations (auth, CMS, analytics), Next.js has that today. Juice is early — you will build integrations yourself or use platform APIs directly.