Single Page Application (SPA)
Single Page Applications (SPAs) are web applications which are client-side rendered (CSR). They are often built with a framework such as React, Vue or Svelte. The build process of these frameworks will produce a single /index.html
file and accompanying client-side resources (e.g. JavaScript bundles, CSS stylesheets, images, fonts, etc.). Typically, data is fetched by the client from an API with client-side requests.
In order to deploy a Single Page Application to Workers, you must configure the assets.directory
and assets.not_found_handling
options in your Wrangler configuration file:
{ "name": "my-worker", "compatibility_date": "2025-05-14", "assets": { "directory": "./dist/", "not_found_handling": "single-page-application" }}
name = "my-worker"compatibility_date = "2025-05-14"
[assets]directory = "./dist/"not_found_handling = "single-page-application"
Configuring assets.not_found_handling
to single-page-application
overrides the default serving behavior of Workers for static assets. When an incoming request does not match a file in the assets.directory
, Workers will serve the contents of the /index.html
file with a 200 OK
status.
If you have a Worker script (main
), have configured assets.not_found_handling
, and use the assets_navigation_prefers_asset_serving
compatibility flag (or set a compatibility date of 2025-04-01
or greater), navigation requests will not invoke the Worker script. A navigation request is a request made with the Sec-Fetch-Mode: navigate
header, which browsers automatically attach when navigating to a page. This reduces billable invocations of your Worker script, and is particularly useful for client-heavy applications which would otherwise invoke your Worker script very frequently and unnecessarily.
In some cases, you might need to pass a value from a navigation request to your Worker script. For example, if you are acting as an OAuth callback, you might expect to see requests made to some route such as /oauth/callback?code=...
. With the assets_navigation_prefers_asset_serving
flag, your HTML assets will be server, rather than your Worker script. In this case, we recommend, either as part of your client application for this appropriate route, or with a slimmed-down endpoint-specific HTML file, passing the value to the server with client-side JavaScript.
<!DOCTYPE html><html> <head> <title>OAuth callback</title> </head> <body> <p>Loading...</p> <script> (async () => { const response = await fetch("/api/oauth/callback" + window.location.search); if (response.ok) { window.location.href = '/'; } else { document.querySelector('p').textContent = 'Error: ' + (await response.json()).error; } })(); </script> </body></html>
import { WorkerEntrypoint } from "cloudflare:workers";
export default class extends WorkerEntrypoint { async fetch(request) { const url = new URL(request.url); if (url.pathname === "/api/oauth/callback") { const code = url.searchParams.get("code");
const sessionId = await exchangeAuthorizationCodeForAccessAndRefreshTokensAndPersistToDatabaseAndGetSessionId( code, );
if (sessionId) { return new Response(null, { headers: { "Set-Cookie": `sessionId=${sessionId}; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=86400`, }, }); } else { return Response.json( { error: "Invalid OAuth code. Please try again." }, { status: 400 }, ); } }
return new Response(null, { status: 404 }); }}
import { WorkerEntrypoint } from "cloudflare:workers";
export default class extends WorkerEntrypoint { async fetch(request: Request) { const url = new URL(request.url); if (url.pathname === "/api/oauth/callback") { const code = url.searchParams.get("code");
const sessionId = await exchangeAuthorizationCodeForAccessAndRefreshTokensAndPersistToDatabaseAndGetSessionId(code);
if (sessionId) { return new Response(null, { headers: { "Set-Cookie": `sessionId=${sessionId}; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=86400`, }, }); } else { return Response.json( { error: "Invalid OAuth code. Please try again." }, { status: 400 } ); } }
return new Response(null, { status: 404 }); }}
If you are using a Vite-powered SPA framework, you might be interested in using our Vite plugin which offers a Vite-native developer experience.
In most cases, configuring assets.not_found_handling
to single-page-application
will provide the desired behavior. If you are building your own framework, or have specialized needs, the following diagram can provide insight into exactly how the routing decisions are made.
Full routing decision diagram
flowchart Request@{ shape: stadium, label: "Incoming request" } Request-->RunWorkerFirst RunWorkerFirst@{ shape: diamond, label: "Run Worker script first?" } RunWorkerFirst-->|No|RequestMatchesAsset RunWorkerFirst-->|Yes|WorkerScriptInvoked RequestMatchesAsset@{ shape: diamond, label: "Request matches asset?" } RequestMatchesAsset-->|Yes|AssetServing RequestMatchesAsset-->|No|WorkerScriptPresent WorkerScriptPresent@{ shape: diamond, label: "Worker script present?" } WorkerScriptPresent-->|No|AssetServing WorkerScriptPresent-->|Yes|RequestNavigation RequestNavigation@{ shape: diamond, label: "Request is navigation request?" } RequestNavigation-->|No|WorkerScriptInvoked WorkerScriptInvoked@{ shape: rect, label: "Worker script invoked" } WorkerScriptInvoked-.->|Asset binding|AssetServing RequestNavigation-->|Yes|AssetServing subgraph Asset serving AssetServing@{ shape: diamond, label: "Request matches asset?" } AssetServing-->|Yes|AssetServed AssetServed@{ shape: stadium, label: "**200 OK**<br />asset served" } AssetServing-->|No|NotFoundHandling subgraph single-page-application NotFoundHandling@{ shape: rect, label: "Request rewritten to /index.html" } NotFoundHandling-->SPAExists SPAExists@{ shape: diamond, label: "HTML Page exists?" } SPAExists-->|Yes|SPAServed SPAExists-->|No|Generic404PageServed Generic404PageServed@{ shape: stadium, label: "**404 Not Found**<br />null-body response served" } SPAServed@{ shape: stadium, label: "**200 OK**<br />/index.html page served" } end end
Requests are only billable if a Worker script is invoked. From there, it is possible to serve assets using the assets binding (depicted as the dotted line in the diagram above).
Although unlikely to impact how a SPA is served, you can read more about how we match assets in the HTML handling docs.
Was this helpful?
- Resources
- API
- New to Cloudflare?
- Products
- Sponsorships
- Open Source
- Support
- Help Center
- System Status
- Compliance
- GDPR
- Company
- cloudflare.com
- Our team
- Careers
- 2025 Cloudflare, Inc.
- Privacy Policy
- Terms of Use
- Report Security Issues
- Trademark