Cloudflare Workers
Cloudflare Workers is Juice's primary deployment target. Workers natively use the fetch handler pattern, so there is no adapter, no compatibility layer, and no cold-start penalty. Your Juice app deploys as a single Worker with edge-distributed static assets.
Server Entry
The entire server entry is one line. Workers expect a default export with a fetch method -- exactly what createRouter returns.
// server.ts
import { createRouter } from '@cmj/juice/runtime';
import manifest from './flight-manifest.json';
export default {
fetch: createRouter(manifest, {
root: import.meta.url,
}),
};No framework adapter, no platform-specific shim. The Worker calls your fetch handler directly for every request.
wrangler.toml
Configure your Worker with a wrangler.toml at the project root.
name = "my-juice-app"
main = "dist/server.js"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
[site]
bucket = "./dist/client"name-- the Worker name on your Cloudflare accountmain-- the built server entry point (output ofjuice build)compatibility_date-- pins the Workers runtime behavior to a specific date. Use a recent date to get the latest APIs.compatibility_flags--nodejs_compatenables Node.js built-in modules that some dependencies may require[site] bucket-- tells Wrangler to upload your client assets to Workers KV, where they are served at the edge
Deploy
Build your app, then deploy with Wrangler.
juice build && wrangler deployWrangler outputs the deployed URL, bundle size, and startup time:
✨ Built successfully
📦 Bundle size: 25.1 KB (gzipped)
🚀 Deployed to https://my-juice-app.workers.dev
⏱ Startup: 4msEnvironment Variables
Workers do not use process.env. Instead, environment variables are passed as the second argument to the fetch handler.
Set secrets with the Wrangler CLI:
wrangler secret put DATABASE_URLOr define non-secret variables in wrangler.toml:
[vars]
APP_ENV = "production"
PUBLIC_API_URL = "https://api.example.com"To access bindings in your Juice routes, receive the env parameter in your fetch handler and share it via context:
// server.ts
import { createRouter } from '@cmj/juice/runtime';
import { createContextKey, setContext } from '@cmj/juice/runtime';
import manifest from './flight-manifest.json';
export interface Env {
DATABASE_URL: string;
APP_ENV: string;
MY_KV: KVNamespace;
MY_DB: D1Database;
MY_BUCKET: R2Bucket;
}
export const envKey = createContextKey<Env>('env');
const router = createRouter(manifest, {
root: import.meta.url,
onBeforeRequest: async (req) => {
// env is attached to the request by the fetch wrapper below
const env = (req as any).__env;
setContext(req, envKey, env);
},
});
export default {
fetch: (req: Request, env: Env) => {
// Attach env to the request so onBeforeRequest can access it
(req as any).__env = env;
return router(req);
},
};Now any route can read bindings from context:
// app/routes/home.tsx
import { getContext } from '@cmj/juice/runtime';
import { envKey } from '../../server.js';
export default async function Home({ request }: { request: Request }) {
const env = getContext(request, envKey);
// env.DATABASE_URL, env.MY_KV, env.MY_DB, etc.
}KV Storage
Workers KV is a globally distributed key-value store. Add a KV namespace to your wrangler.toml:
[[kv_namespaces]]
binding = "MY_KV"
id = "abc123def456"Create the namespace with the CLI:
wrangler kv namespace create MY_KVOnce shared via the context pattern above, read and write from any route:
// app/routes/api/session.tsx
import { getContext } from '@cmj/juice/runtime';
import { envKey } from '../../../server.js';
export async function GET({ request }: { request: Request }) {
const env = getContext(request, envKey);
// Read
const session = await env.MY_KV.get('session:abc', 'json');
// Write
await env.MY_KV.put('session:abc', JSON.stringify({ userId: '1' }), {
expirationTtl: 3600, // 1 hour
});
return new Response(JSON.stringify(session));
}Common use cases: session storage, feature flags, cached API responses, rate limiting counters.
D1 Database
D1 is Cloudflare's serverless SQLite database. Add a D1 binding to your wrangler.toml:
[[d1_databases]]
binding = "MY_DB"
database_name = "my-juice-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"Create the database with the CLI:
wrangler d1 create my-juice-dbQuery with the D1 API:
// app/routes/api/tasks.tsx
import { getContext } from '@cmj/juice/runtime';
import { envKey } from '../../../server.js';
export async function GET({ request }: { request: Request }) {
const env = getContext(request, envKey);
const { results } = await env.MY_DB
.prepare('SELECT * FROM tasks WHERE completed = ?')
.bind(0)
.all();
return new Response(JSON.stringify(results));
}D1 works with Drizzle ORM. Pass the D1 binding to the Drizzle constructor:
import { drizzle } from 'drizzle-orm/d1';
const db = drizzle(env.MY_DB);See the Drizzle integration page for full schema and query examples.
R2 Object Storage
R2 is S3-compatible object storage with zero egress fees. Add an R2 bucket to your wrangler.toml:
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-juice-uploads"Create the bucket:
wrangler r2 bucket create my-juice-uploadsUpload a file from a server action:
// app/routes/upload.tsx
import React from 'react';
import { getContext } from '@cmj/juice/runtime';
import { envKey } from '../../server.js';
async function handleUpload(formData: FormData) {
'use server';
const env = getContext(this.request, envKey);
const file = formData.get('file') as File;
await env.MY_BUCKET.put(file.name, file.stream(), {
httpMetadata: { contentType: file.type },
});
return { success: true, key: file.name };
}
export default function Upload() {
return (
<form action={handleUpload} method="POST" encType="multipart/form-data">
<input type="file" name="file" required />
<button type="submit">Upload</button>
</form>
);
}Serve files back from R2:
// app/routes/files/[key].tsx
import { getContext } from '@cmj/juice/runtime';
import { envKey } from '../../../server.js';
export async function GET({ request, params }: { request: Request; params: { key: string } }) {
const env = getContext(request, envKey);
const object = await env.MY_BUCKET.get(params.key);
if (!object) {
return new Response('Not found', { status: 404 });
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}Durable Objects
Durable Objects provide strongly consistent, stateful compute at the edge. They work with Juice but are an advanced pattern -- each Durable Object is its own isolated execution context with its own storage. Typical use cases include real-time collaboration, rate limiting with strong consistency, and WebSocket coordination.
See the Cloudflare Durable Objects documentation for setup and usage patterns.
Bundle Size
The Juice runtime compiles to approximately 25KB gzipped on Workers. This matters because smaller bundles mean faster cold starts and lower latency at the edge.
| Framework | Workers Bundle | Cold Start |
|---|---|---|
| Juice | ~25 KB gzipped | ~4ms |
| Next.js (@opennextjs/cloudflare) | 2-3 MB | ~200-500ms |
A 100x smaller bundle translates directly to faster edge deployments and consistently low latency for users worldwide.
When NOT to Use Workers
- Apps that need filesystem access. Workers run in an isolated V8 environment with no filesystem. Use Bun or Node.js instead.
- Apps that need long-running processes. Workers have a 30-second CPU time limit (or 15 minutes with Cron Triggers). For long-running tasks, use Bun or Deno.
- Apps with large dependency trees. Workers have a 10MB bundle size limit (after compression). If your dependencies push past this, consider a traditional server runtime.