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:
- Client build -- produces browser-ready JavaScript chunks, CSS files, and the flight manifest. These are the static assets your CDN serves.
- 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 buildPreview 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 previewComing 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 deployThe [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.jsDeno
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.jsNode.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
| Error | Cause | Fix |
|---|---|---|
ModuleLoadError | Missing root option | Add root: import.meta.url |
| "Cannot find module" | Stale build artifacts | Run juice build again |
| Empty page / no styles | Client assets not served | Check that dist/client/ is being served as static files |
| 404 on all routes | Stale or missing manifest | Run juice build to regenerate flight-manifest.json |
| CSRF 403 on POST | Origin/Host mismatch behind a proxy | Set 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.