Skip to main content
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
object
required
Event-like object with request and waitUntil
options
Partial<Options>
Configuration options
response
Promise<Response>
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,
      }
    );
  },
};
request
Request
required
Original request
options
Partial<Options>
Asset handler options
request
Request
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,
      }
    );
  },
};
request
Request
required
Original request
options
Partial<Options>
Asset handler options
request
Request
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;
    }
  },
};
status
number
default:"404"
HTTP status code
message
string
Error message

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;
    }
  },
};
status
number
default:"405"
HTTP status code

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;
    }
  },
};
status
number
default:"500"
HTTP status code

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;
    }
  },
};

With Custom Headers

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,
  },
};

Build docs developers (and LLMs) love