Skip to content

Deployment

Your entire Juice app is one function: (req: Request) => Promise<Response>. Every runtime knows how to call that.

The Build Step

juice build runs two Vite builds:

  1. Client build -- produces browser-ready JavaScript chunks, CSS files, and the flight manifest. These are the static assets your CDN serves.
  2. SSR build -- produces the server entry (dist/server.js). This is the code that renders React Server Components on the server.

The manifest connects them: it tells the runtime which client chunks to include for each route and where to find the server component modules.

juice build

Preview Locally

juice preview runs the production build locally. This catches issues that dev mode hides: minified code behaving differently, different module resolution, missing assets, environment-specific bugs. Always preview before deploying.

juice preview

Coming from Next.js: this is equivalent to next build && next start. The difference is that Juice's preview uses the same fetch handler as your production deployment, so what you test is exactly what you deploy.

Cloudflare Workers

Cloudflare Workers is the simplest deployment target because Workers already expects a fetch handler. No adapter needed. The production build is 10.7KB gzipped.

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

export default {
  fetch: createRouter(manifest, {
    root: import.meta.url,
  }),
};
# wrangler.toml
name = "my-app"
main = "dist/server.js"
compatibility_date = "2024-01-01"

[site]
bucket = "./dist/client"
wrangler deploy

The [site] section tells Wrangler to upload your client assets to Workers KV, where they are served at the edge. Server-rendered HTML comes from the Worker itself.

Bun

Bun is the default target. Bun.serve() takes a fetch handler directly.

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

const handler = createRouter(manifest, {
  root: import.meta.url,
});

Bun.serve({
  port: 3000,
  fetch: handler,
});

console.log('Listening on http://localhost:3000');
bun dist/server.js

Deno

Same pattern. Deno.serve() takes a fetch handler.

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

const handler = createRouter(manifest, {
  root: import.meta.url,
});

Deno.serve({ port: 3000 }, handler);
deno run --allow-net --allow-read dist/server.js

Node.js

Node.js is the only runtime that needs an adapter. Node's HTTP server uses http.IncomingMessage and http.ServerResponse, not the standard Request/Response APIs. The adapter bridges the gap.

// server.ts
import { createServer } from 'node:http';
import { createRouter } from '@cmj/juice/runtime';
import manifest from './flight-manifest.json';

const handler = createRouter(manifest, {
  root: import.meta.url,
});

const server = createServer(async (req, res) => {
  const url = new URL(req.url!, `http://${req.headers.host}`);
  const headers = new Headers();
  for (const [key, value] of Object.entries(req.headers)) {
    if (value) headers.set(key, Array.isArray(value) ? value.join(', ') : value);
  }

  const request = new Request(url, {
    method: req.method,
    headers,
    body: req.method !== 'GET' && req.method !== 'HEAD' ? req : undefined,
    // @ts-expect-error Node.js duplex option
    duplex: 'half',
  });

  const response = await handler(request);

  res.writeHead(response.status, Object.fromEntries(response.headers));
  if (response.body) {
    const reader = response.body.getReader();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      res.write(value);
    }
  }
  res.end();
});

server.listen(3000, () => {
  console.log('Listening on http://localhost:3000');
});

This adapter is boilerplate, but it is explicit. You can see exactly how the conversion happens. There is no hidden compatibility layer or polyfill magic. If you need to modify the conversion (add body streaming for uploads, handle WebSocket upgrades), you change the adapter code.

Understanding root: import.meta.url

The runtime resolves module paths like ./app/routes/home.tsx via dynamic import(). Without root, these paths resolve relative to the Juice package inside node_modules, not your app. Setting root: import.meta.url anchors module resolution to your server.ts file.

If you forget this: you will see ModuleLoadError or "Cannot find module" errors in production. The fix is always root: import.meta.url.

When Deployment Fails

ErrorCauseFix
ModuleLoadErrorMissing root optionAdd root: import.meta.url
"Cannot find module"Stale build artifactsRun juice build again
Empty page / no stylesClient assets not servedCheck that dist/client/ is being served as static files
404 on all routesStale or missing manifestRun juice build to regenerate flight-manifest.json
CSRF 403 on POSTOrigin/Host mismatch behind a proxySet allowedOrigins in CSRF config or check proxy headers

When NOT to Use Juice's Server

If you are deploying to a platform with its own framework conventions (Vercel expects Next.js, AWS Amplify expects their SSR adapter), Juice does not have first-party adapters for these. You can still deploy by writing a custom adapter (like the Node.js example above), but you lose the platform's zero-config deployment experience. Use Juice on platforms that accept a standard fetch handler: Cloudflare Workers, Fly.io (with the Bun or Node adapter), Railway, or any Docker-based hosting.