Static assets
Dynamic Workers can serve static assets like HTML pages, JavaScript bundles, images, and other files alongside your Worker code. This is useful when you need a Dynamic Worker to serve a full-stack application.
Static assets for Dynamic Workers work differently from static assets in regular Workers. Instead of uploading assets at deploy time, you provide them at runtime through the Worker Loader get() callback, sourcing them from R2, KV, or another storage backend.
There are three parts to setting up static assets for Dynamic Workers:
- Store the assets — Upload static files to a KV namespace, keyed by project ID and pathname.
- Define an asset binding in the loader Worker — Create a class that handles requests for static files by reading them from KV and returning them with the correct headers.
- Pass the binding to the Dynamic Worker — The Dynamic Worker uses it to serve static files by calling
env.ASSETS.fetch(request).
Static assets are stored in a KV namespace, separated by project ID so each project's files are isolated from each other:
project/{projectId}/assets/index.html → file contentproject/{projectId}/assets/app.js → file contentproject/{projectId}/manifest → asset manifestWhen a user deploys their project through your platform's upload API, store each file in KV under its pathname:
await env.KV_ASSETS.put(`project/${projectId}/assets${pathname}`, fileContent);You also need to store a manifest, a mapping that tells the asset handler which files exist and what their content types are. Use buildAssetManifest() from @cloudflare/worker-bundler to generate it from your assets:
import { buildAssetManifest } from "@cloudflare/worker-bundler";
const assets = { "/index.html": htmlContent, "/app.js": jsContent, "/style.css": cssContent,};
const manifest = await buildAssetManifest(assets);
await env.KV_ASSETS.put( `project/${projectId}/manifest`, JSON.stringify(manifest),);import { buildAssetManifest } from "@cloudflare/worker-bundler";
const assets = { "/index.html": htmlContent, "/app.js": jsContent, "/style.css": cssContent,};
const manifest = await buildAssetManifest(assets);
await env.KV_ASSETS.put( `project/${projectId}/manifest`, JSON.stringify(manifest),);Grant the loader Worker access to the KV namespace where you stored the assets:
{ "worker_loaders": [{ "binding": "LOADER" }], "kv_namespaces": [ { "binding": "KV_ASSETS", "id": "<your-kv-namespace-id>", }, ],}[[worker_loaders]]binding = "LOADER"
[[kv_namespaces]]binding = "KV_ASSETS"id = "<your-kv-namespace-id>"Create a class in the loader Worker that extends WorkerEntrypoint and define a fetch() method. WorkerEntrypoint makes this method callable from the Dynamic Worker using RPC. When the Dynamic Worker calls env.ASSETS.fetch(request), it runs this method in the loader Worker, where the KV binding and your asset-serving logic live.
The class takes a projectId prop so it knows which project's assets to look up. When fetch() is called, it:
- Loads the project's asset manifest from KV.
- Resolves the request pathname to a file.
- Fetches the file content from KV.
- Returns a
Responsewith the correctContent-Typeheader.
Instead of writing your own logic to match request paths to files, detect content types, and set cache headers, use the @cloudflare/worker-bundler package to handle static asset serving. In your fetch() method, pass handleAssetRequest() two things:
- A manifest, the path-to-content-type mapping you stored in KV during upload, built with
buildAssetManifest(). This tellshandleAssetRequest()which files exist and what their content types are. - A storage object, tells
handleAssetRequest()how to read files from your KV namespace. It has one method,get(pathname), which reads and returns the content for a given file path.
handleAssetRequest() serves the file if it finds a match in the manifest, with the correct headers for content type and caching.
import { WorkerEntrypoint } from "cloudflare:workers";import { handleAssetRequest } from "@cloudflare/worker-bundler";
export class AssetBinding extends WorkerEntrypoint { async fetch(request) { const { projectId } = this.ctx.props;
// Load the project's asset manifest from KV const manifest = await this.env.KV_ASSETS.get( `project/${projectId}/manifest`, { type: "json", cacheTtl: 300 }, );
if (!manifest) { return new Response("No assets found", { status: 404 }); }
// Storage object — handleAssetRequest calls get() to // read file content when it needs to serve an asset const storage = { async get(pathname) { return this.env.KV_ASSETS.get( `project/${projectId}/assets${pathname}`, { type: "arrayBuffer", cacheTtl: 86_400 }, ); }, };
const response = await handleAssetRequest(request, manifest, storage); return response ?? new Response("Not Found", { status: 404 }); }}import { WorkerEntrypoint } from "cloudflare:workers";import { handleAssetRequest } from "@cloudflare/worker-bundler";
export class AssetBinding extends WorkerEntrypoint { async fetch(request: Request) { const { projectId } = this.ctx.props;
// Load the project's asset manifest from KV const manifest = await this.env.KV_ASSETS.get( `project/${projectId}/manifest`, { type: "json", cacheTtl: 300 }, );
if (!manifest) { return new Response("No assets found", { status: 404 }); }
// Storage object — handleAssetRequest calls get() to // read file content when it needs to serve an asset const storage = { async get(pathname: string) { return this.env.KV_ASSETS.get( `project/${projectId}/assets${pathname}`, { type: "arrayBuffer", cacheTtl: 86_400 }, ); }, };
const response = await handleAssetRequest(request, manifest, storage); return response ?? new Response("Not Found", { status: 404 }); }}Once AssetBinding is exported, it becomes available on ctx.exports in the loader Worker's fetch() handler. ctx is the handler's third parameter, after request and env. This is how you pass it to the Dynamic Worker in the next step.
When you call get() to create the Dynamic Worker, include the AssetBinding in the env object so the Dynamic Worker can use it to serve static files. To reference the AssetBinding class you defined in the previous step, use ctx.exports.AssetBinding() and pass the projectId as a prop so it knows which project's assets to serve. This works the same way as custom bindings — props is how you pass information to the class, and the class reads it at this.ctx.props when it runs.
export default { async fetch(request, env, ctx) { const projectId = getProjectIdFromRequest(request);
const worker = env.LOADER.get(projectId, async () => { const serverCode = await loadServerCode(projectId);
return { mainModule: "index.js", modules: { "index.js": { js: serverCode }, }, compatibilityDate: "2026-05-06", env: { ASSETS: ctx.exports.AssetBinding({ props: { projectId }, }), }, }; });
return await worker.getEntrypoint().fetch(request); },};export default { async fetch(request: Request, env: Env, ctx: ExecutionContext) { const projectId = getProjectIdFromRequest(request);
const worker = env.LOADER.get(projectId, async () => { const serverCode = await loadServerCode(projectId);
return { mainModule: "index.js", modules: { "index.js": { js: serverCode }, }, compatibilityDate: "2026-05-06", env: { ASSETS: ctx.exports.AssetBinding({ props: { projectId }, }), }, }; });
return await worker.getEntrypoint().fetch(request); },};The Dynamic Worker sees ASSETS as a binding and can call env.ASSETS.fetch(request) because that is the method you defined on AssetBinding. When the Dynamic Worker calls that method, it runs in the loader Worker, where your AssetBinding class reads the manifest and file content from KV.
From the Dynamic Worker's perspective, env.ASSETS works like any other binding. The user writes their server code and calls env.ASSETS.fetch() to serve static files:
// Inside the Dynamic Workerexport default { async fetch(request, env) { const url = new URL(request.url);
// Handle API routes directly if (url.pathname.startsWith("/api/")) { return Response.json({ hello: "world" }); }
// Everything else — serve static assets return env.ASSETS.fetch(request); },};// Inside the Dynamic Workerexport default { async fetch(request: Request, env: Env) { const url = new URL(request.url);
// Handle API routes directly if (url.pathname.startsWith("/api/")) { return Response.json({ hello: "world" }); }
// Everything else — serve static assets return env.ASSETS.fetch(request); },};When the Dynamic Worker calls env.ASSETS.fetch(request), the call goes through RPC to the loader Worker's AssetBinding, which looks up the file in the manifest and reads it from KV. The Dynamic Worker does not need to handle any of this — it calls env.ASSETS.fetch(request) and gets back the file with the correct headers, ready to return to the client.