Skip to content

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 account
  • main -- the built server entry point (output of juice build)
  • compatibility_date -- pins the Workers runtime behavior to a specific date. Use a recent date to get the latest APIs.
  • compatibility_flags -- nodejs_compat enables 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 deploy

Wrangler 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: 4ms

Environment 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_URL

Or 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_KV

Once 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-db

Query 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-uploads

Upload 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.

FrameworkWorkers BundleCold 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.