You deploy your React app to S3 and CloudFront. The root URL works fine. Then a user refreshes /dashboard and gets a 403. Or worse, a blank page. The app is there, the route exists in React Router, but CloudFront has no idea what /dashboard is. It looks for an object at that key in S3, finds nothing, and returns an error.
This is the classic SPA routing problem on CloudFront, and CloudFront Functions are the right tool to fix it.
Why CloudFront Returns 403s on Deep Links
S3 is an object store. When CloudFront forwards a request for /dashboard to your S3 origin, it looks for an object literally named dashboard (or dashboard/index.html depending on your bucket configuration). That object does not exist only index.html at the root does. S3 returns a 403 (or 404 if the bucket has public access), and CloudFront passes that back to the user.
The fix is to intercept the request at the edge before it reaches S3 and rewrite the URI to /index.html. The React app loads, React Router reads the original URL from the browser's address bar, and renders the correct route. The user never knew anything was rewritten.
CloudFront Functions vs Lambda@Edge
CloudFront offers two edge compute options. Picking the wrong one adds cost and latency for no reason.
| CloudFront Functions | Lambda@Edge | |
|---|---|---|
| Execution time limit | 1ms | 5s (viewer), 30s (origin) |
| Memory | 2MB | 128MB–10GB |
| Runtime | JavaScript (ES5.1 subset) | Node.js, Python |
| Triggers | Viewer request/response only | All four event types |
| Cold starts | None (always warm) | Yes |
| Cost | $0.10 / 1M invocations | $0.60 / 1M + duration |
| Use case | Simple URI rewrites, header manipulation | Complex logic, external API calls, auth |
For SPA routing, you are doing exactly one thing: rewriting a URI string. CloudFront Functions are the correct choice — they run at sub-millisecond latency, have no cold starts, and cost 6x less than Lambda@Edge.
Use Lambda@Edge only when you need to call an external service, run complex auth logic, or need access to the origin request/response — none of which apply to a URL rewrite.
Writing the CloudFront Function
CloudFront Functions run a JavaScript ES5.1 subset (not Node.js no require, no modules, no async/await). The runtime is intentionally minimal.
Here is a routing function for a multi-SPA setup where you have several React apps deployed under different path prefixes:
// infra/cloudfront-function.js
function handler(event) {
var request = event.request;
var uri = request.uri;
// Serve static assets as-is — these have file extensions
// e.g. /assets/main-a1b2c3.js, /favicon.ico, /logo.png
if (uri.match(/\.[a-zA-Z0-9]+$/)) {
return request;
}
// Known SPA roots — rewrite to their index.html
var apps = ["/organization", "/creator", "/dashboard"];
for (var i = 0; i < apps.length; i++) {
if (uri === apps[i] || uri.indexOf(apps[i] + "/") === 0) {
request.uri = apps[i] + "/index.html";
return request;
}
}
// Root SPA — everything else maps to /index.html
request.uri = "/index.html";
return request;
}How the routing logic works
Static asset passthrough — the regex \.[a-zA-Z0-9]+$ matches any URI ending in a file extension. These requests go straight to S3 unchanged. This covers JS, CSS, fonts, images, and any other file with an extension.
Sub-app rewrites — each known path prefix maps to its own index.html. A request for /organization/settings/billing matches the /organization prefix and gets rewritten to /organization/index.html. That sub-app's React Router takes over from there.
Root fallback — anything that doesn't match a static asset or a known sub-app prefix falls back to the root /index.html.
A note on the ES5.1 constraint
CloudFront Functions do not support ES6+ syntax. No const, no let, no arrow functions, no template literals, no Array.prototype.find. Write ES5: var, regular functions, indexOf instead of includes. If you use modern syntax, the function will fail at publish time not at runtime, which makes it easy to catch.
Deploying the Function via CLI
CloudFront Functions have two stages: DEVELOPMENT and LIVE. Changes you make via the console or CLI go to DEVELOPMENT first. They only affect real traffic after you explicitly publish.
Every update and publish operation requires an ETag a version token that prevents concurrent overwrites. The correct deployment sequence is:
# 1. Get current ETag
ETAG=$(aws cloudfront describe-function \
--name "$CLOUDFRONT_FUNCTION_NAME" \
--query 'ETag' \
--output text)
# 2. Upload new function code (goes to DEVELOPMENT stage)
aws cloudfront update-function \
--name "$CLOUDFRONT_FUNCTION_NAME" \
--function-code fileb://infra/cloudfront-function.js \
--function-config Comment="Updated via CodeBuild",Runtime="cloudfront-js-2.0" \
--if-match $ETAG > /dev/null
# 3. Re-fetch ETag — update-function generates a new one
ETAG=$(aws cloudfront describe-function \
--name "$CLOUDFRONT_FUNCTION_NAME" \
--query 'ETag' \
--output text)
# 4. Promote DEVELOPMENT → LIVE
aws cloudfront publish-function \
--name "$CLOUDFRONT_FUNCTION_NAME" \
--if-match $ETAGWhy fetch ETag twice? The update-function call changes the function's version, which changes its ETag. If you try to call publish-function with the original ETag, AWS rejects it with PreconditionFailed. You must describe the function again after updating to get the ETag that corresponds to the new version.
fileb:// not file:// — fileb:// reads the file as binary and base64-encodes it. file:// handles it as text and can mangle certain byte sequences. Always use fileb:// for function code uploads.
> /dev/null on update the update response echoes the entire function code back. Suppressing it keeps CI logs readable.
Creating the Function for the First Time
The CLI commands above assume the function already exists. To create it from scratch:
aws cloudfront create-function \
--name "spa-router" \
--function-code fileb://infra/cloudfront-function.js \
--function-config Comment="SPA routing",Runtime="cloudfront-js-2.0"
# Publish immediately after creation
ETAG=$(aws cloudfront describe-function \
--name "spa-router" \
--query 'ETag' \
--output text)
aws cloudfront publish-function \
--name "spa-router" \
--if-match $ETAGThen associate it with your distribution's default cache behavior via the console (CloudFront → Distribution → Behaviors → Edit → Function associations → Viewer request) or via the CLI by updating the distribution config to include the function ARN under the default cache behavior's FunctionAssociations.
The function must be on the viewer-request event that is the only place where you can rewrite the URI before CloudFront decides whether to serve from cache or forward to origin.
Adding a New Sub-App Without Changing the Function
The routing function above has a hardcoded apps array. Every time you add a sub-app, you update the function. This is manageable but fragile at scale.
An alternative approach: serve all deep links from the root index.html and let each sub-app's React Router handle path matching client-side. This works if your sub-apps share a single React entry point. If they are independent deployments with separate index.html files (common in micro-frontend architectures), you need per-prefix routing and the explicit apps array is the right call.
Another option: keep the routing logic path-agnostic by treating everything that lacks a file extension as an SPA route:
function handler(event) {
var request = event.request;
var uri = request.uri;
// Pass through anything with a file extension
if (uri.match(/\.[a-zA-Z0-9]+$/)) {
return request;
}
// Find the closest index.html by walking up the path
// e.g. /organization/settings → /organization/index.html if it exists
// This requires knowing your app structure at function-write time,
// so the explicit prefix list is usually simpler and more predictable.
request.uri = "/index.html";
return request;
}This simplified version works for single-SPA setups. For multi-SPA, the explicit prefix list gives you precise control over which paths belong to which app.
Testing Before Deploy
CloudFront Functions can be tested in the console against synthetic events, or via the CLI:
aws cloudfront test-function \
--name "spa-router" \
--if-match $ETAG \
--event-object fileb://test-event.json \
--stage DEVELOPMENTWhere test-event.json is a minimal viewer-request event:
{
"version": "1.0",
"context": { "eventType": "viewer-request" },
"viewer": { "ip": "1.2.3.4" },
"request": {
"method": "GET",
"uri": "/organization/settings",
"headers": {},
"cookies": {},
"querystring": {}
}
}Run this in your CI pipeline before publishing to catch syntax errors and logic bugs without affecting live traffic.
Summary
| Problem | Solution |
|---|---|
| 403 on deep links | Rewrite non-asset URIs to the appropriate index.html at the edge |
| Lambda@Edge is overkill | CloudFront Functions: sub-millisecond, no cold starts, 6x cheaper |
| ES6 syntax fails at publish | Write ES5.1 — var, indexOf, regular functions |
PreconditionFailed on publish | Re-fetch ETag after update-function, before publish-function |
| Manual function updates for new apps | Use a path-agnostic fallback, or maintain an explicit prefix list |
The function itself is simple 15 lines of ES5. The operational details (ETag lifecycle, fileb://, DEVELOPMENT vs LIVE staging) are what catch people off guard. Get those right in your CI pipeline and routing on CloudFront becomes a non-issue.