The @cloudflare/kv-asset-handler package provides utilities for serving static assets from Workers KV, commonly used with Workers Sites.
Installation
npm install @cloudflare/kv-asset-handler
Core Functions
getAssetFromKV()
Fetch and serve a static asset from KV storage.
import { getAssetFromKV } from "@cloudflare/kv-asset-handler";
export default {
async fetch(request, env, ctx) {
try {
return await getAssetFromKV(
{
request,
waitUntil: ctx.waitUntil.bind(ctx),
},
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
}
);
} catch (e) {
return new Response("Not Found", { status: 404 });
}
},
};
Event-like object with request and waitUntil
waitUntil
(promise: Promise<unknown>) => void
required
Function to extend execution for async tasks
Configuration options
KV namespace containing the static assets
ASSET_MANIFEST
AssetManifestType | string
required
Manifest mapping paths to hashed filenames
cacheControl
CacheControl | ((req: Request) => CacheControl)
Cache control configurationShow CacheControl properties
Browser cache TTL in seconds (null = no caching)
Edge cache TTL in seconds (default: 172800 = 2 days)
Bypass Cloudflare cache (default: false)
mapRequestToAsset
(req: Request, options?: Partial<Options>) => Request
Custom function to map requests to asset paths
defaultMimeType
string
default:"text/plain"
Default MIME type for files without extension
defaultDocument
string
default:"index.html"
Default document for directory requests
Whether request paths are URL-encoded
defaultETag
'strong' | 'weak'
default:"strong"
ETag validation type
HTTP response with the asset content
Errors:
- Throws
NotFoundError if asset doesn’t exist
- Throws
MethodNotAllowedError for non-GET/HEAD requests
- Throws
InternalError for KV namespace issues
mapRequestToAsset()
Map a request URL to an asset path in KV.
import { mapRequestToAsset, getAssetFromKV } from "@cloudflare/kv-asset-handler";
export default {
async fetch(request, env, ctx) {
return await getAssetFromKV(
{ request, waitUntil: ctx.waitUntil.bind(ctx) },
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
mapRequestToAsset: mapRequestToAsset,
}
);
},
};
Modified request with mapped asset path
Behavior:
- Appends
defaultDocument to directory paths (e.g., /about/ → /about/index.html)
- Adds
defaultDocument to paths without extensions (e.g., /about → /about/index.html)
- Preserves paths with file extensions as-is
serveSinglePageApp()
Serve a single-page application (SPA), returning index.html for all HTML requests.
import { serveSinglePageApp, getAssetFromKV } from "@cloudflare/kv-asset-handler";
export default {
async fetch(request, env, ctx) {
return await getAssetFromKV(
{ request, waitUntil: ctx.waitUntil.bind(ctx) },
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
mapRequestToAsset: serveSinglePageApp,
}
);
},
};
Modified request, mapping HTML requests to root index.html
Behavior:
- Static assets (JS, CSS, images) are served normally
- Requests for HTML files return the root
index.html
- Perfect for client-side routing in React, Vue, etc.
Error Classes
NotFoundError
Thrown when an asset is not found in KV.
import { getAssetFromKV, NotFoundError } from "@cloudflare/kv-asset-handler";
export default {
async fetch(request, env, ctx) {
try {
return await getAssetFromKV(
{ request, waitUntil: ctx.waitUntil.bind(ctx) },
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
}
);
} catch (e) {
if (e instanceof NotFoundError) {
return new Response("Custom 404 page", { status: 404 });
}
throw e;
}
},
};
MethodNotAllowedError
Thrown for unsupported HTTP methods.
import { getAssetFromKV, MethodNotAllowedError } from "@cloudflare/kv-asset-handler";
export default {
async fetch(request, env, ctx) {
try {
return await getAssetFromKV(
{ request, waitUntil: ctx.waitUntil.bind(ctx) },
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
}
);
} catch (e) {
if (e instanceof MethodNotAllowedError) {
return new Response("Method not allowed", { status: 405 });
}
throw e;
}
},
};
InternalError
Thrown for internal KV or configuration errors.
import { getAssetFromKV, InternalError } from "@cloudflare/kv-asset-handler";
export default {
async fetch(request, env, ctx) {
try {
return await getAssetFromKV(
{ request, waitUntil: ctx.waitUntil.bind(ctx) },
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
}
);
} catch (e) {
if (e instanceof InternalError) {
return new Response("Internal error", { status: 500 });
}
throw e;
}
},
};
Usage Examples
Basic Static Site
import { getAssetFromKV } from "@cloudflare/kv-asset-handler";
export default {
async fetch(request, env, ctx) {
try {
return await getAssetFromKV(
{ request, waitUntil: ctx.waitUntil.bind(ctx) },
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
}
);
} catch (e) {
return new Response("Not Found", { status: 404 });
}
},
};
Custom Cache Control
import { getAssetFromKV } from "@cloudflare/kv-asset-handler";
export default {
async fetch(request, env, ctx) {
return await getAssetFromKV(
{ request, waitUntil: ctx.waitUntil.bind(ctx) },
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
cacheControl: {
browserTTL: 60 * 60 * 24, // 1 day
edgeTTL: 60 * 60 * 24 * 7, // 7 days
bypassCache: false,
},
}
);
},
};
Dynamic Cache Control
import { getAssetFromKV } from "@cloudflare/kv-asset-handler";
export default {
async fetch(request, env, ctx) {
return await getAssetFromKV(
{ request, waitUntil: ctx.waitUntil.bind(ctx) },
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
cacheControl: (request) => {
const url = new URL(request.url);
// Cache images longer
if (url.pathname.match(/\.(jpg|jpeg|png|gif|webp)$/)) {
return {
browserTTL: 60 * 60 * 24 * 30, // 30 days
edgeTTL: 60 * 60 * 24 * 30,
bypassCache: false,
};
}
// Don't cache HTML
if (url.pathname.endsWith(".html")) {
return {
browserTTL: null,
edgeTTL: 60 * 60, // 1 hour
bypassCache: false,
};
}
// Default cache
return {
browserTTL: 60 * 60 * 24, // 1 day
edgeTTL: 60 * 60 * 24 * 7, // 7 days
bypassCache: false,
};
},
}
);
},
};
Single Page Application
import { getAssetFromKV, serveSinglePageApp } from "@cloudflare/kv-asset-handler";
export default {
async fetch(request, env, ctx) {
try {
return await getAssetFromKV(
{ request, waitUntil: ctx.waitUntil.bind(ctx) },
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
mapRequestToAsset: serveSinglePageApp,
}
);
} catch (e) {
return new Response("Not Found", { status: 404 });
}
},
};
API + Static Assets
import { getAssetFromKV, NotFoundError } from "@cloudflare/kv-asset-handler";
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Handle API requests
if (url.pathname.startsWith("/api/")) {
return handleApiRequest(request, env);
}
// Serve static assets
try {
return await getAssetFromKV(
{ request, waitUntil: ctx.waitUntil.bind(ctx) },
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
}
);
} catch (e) {
if (e instanceof NotFoundError) {
// Handle 404s differently for API vs assets
if (url.pathname.startsWith("/api/")) {
return new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response("Page not found", { status: 404 });
}
throw e;
}
},
};
function handleApiRequest(request, env) {
return new Response(JSON.stringify({ message: "API response" }), {
headers: { "Content-Type": "application/json" },
});
}
Custom 404 Page
import { getAssetFromKV, NotFoundError } from "@cloudflare/kv-asset-handler";
const CUSTOM_404_HTML = `
<!DOCTYPE html>
<html>
<head><title>404 Not Found</title></head>
<body><h1>Page not found</h1></body>
</html>
`;
export default {
async fetch(request, env, ctx) {
try {
return await getAssetFromKV(
{ request, waitUntil: ctx.waitUntil.bind(ctx) },
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
}
);
} catch (e) {
if (e instanceof NotFoundError) {
return new Response(CUSTOM_404_HTML, {
status: 404,
headers: { "Content-Type": "text/html" },
});
}
throw e;
}
},
};
import { getAssetFromKV } from "@cloudflare/kv-asset-handler";
export default {
async fetch(request, env, ctx) {
const response = await getAssetFromKV(
{ request, waitUntil: ctx.waitUntil.bind(ctx) },
{
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
}
);
// Add security headers
const headers = new Headers(response.headers);
headers.set("X-Frame-Options", "DENY");
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
},
};
Type Definitions
import type {
Options,
CacheControl,
AssetManifestType,
} from "@cloudflare/kv-asset-handler";
const options: Partial<Options> = {
ASSET_NAMESPACE: env.__STATIC_CONTENT,
ASSET_MANIFEST: env.__STATIC_CONTENT_MANIFEST,
cacheControl: {
browserTTL: 3600,
edgeTTL: 86400,
bypassCache: false,
},
};