TIL: How to configure CloudFront Functions for SPA
Last week, we audited parts of our old infrastructure and found many outdated configurations. They were working, but could be doing better.
We had a strange chain of CloudFront → Nginx → S3 bucket. Nginx was doing some complex redirects that were necessary at that time, as the single-page application was gradually covering old pages. On top of this chain, I found an AWS Lambda attached to the Viewer Response. It was also doing some redirects. Yeah, when people leave the company, they take some knowledge with them, and you need to be very good at technical handoff to keep this knowledge.
Since 2020, when this part of the infrastructure was deployed, CloudFront has released several interesting features.
Feature 1. CloudFront Functions
This is exactly what you need to do simple redirects or HTTP header modifications.

Single page applications do the virtual routing, meaning that the JS app handles all the routing and the actual page files do not exist on disk. So all you need is to rewrite all requests like www.example.com/auth/login/ to s3://your-bucket-name/index.html, and the JS app will load and handle the rest in the browser.
Our app lives in a subroute, so the CloudFront function can look like this
function handler(event) {
var request = event.request;
var uri = request.uri;
// Check if URI is missing an extension (like .js, .css, .html)
if (!uri.includes('.')) {
// Rewrite to SPA root index.html
request.uri = '/index.html';
}
return request;
}It rewrites all requests to index.html in the root of the bucket. The important thing here is to decide what caching strategy you want for this file. You need to set it at upload time.
The recommended setup is to always return a fresh version of index.html, meaning you will have Cache-Control: max-age=0. However, there is an awesome feature that I didn’t know about: s-maxage.
Cache-Control metadata for index.html can be: public, max-age=0, s-maxage=86400, must-revalidate
CloudFront respects this and can return a cached version for some time while doing a refresh request in the background. The user’s browser won’t cache the file because it respects the max-age directive. You need to be careful with how you deploy the app and execute invalidation requests in order to keep the previous index.html content available for some time.
Feature 2. CloudFront Key Value Store (KVS)
Nginx had some rules to easily enable or disable maintenance mode. CloudFront KVS can do this for you. Both Functions and Key Value Store are designed to be blazingly fast and have a microseconds-level budget, so they will not affect your latency as much as you might think.

In the Use Cases section, AWS docs mention that it is useful for A/B testing, rewrites, and handling auth-based locations. Maintenance, broadly speaking, is just a subset of A/B testing. So you can easily add a variable like maintenance-mode and read it on each request. Yes, it comes at a certain price, so be mindful of the number of requests coming to your app.
So your function can look like this
import cf from 'cloudfront';
const kvsId = '<KEY_VALUE_STORE_ID>';
// If the KeyValueStore isn't associated with this function, creating the handle can fail.
// In that case we fall back to behaving as if maintenance mode is off.
let kvsHandle = null;
try {
kvsHandle = cf.kvs(kvsId);
} catch (e) {
kvsHandle = null;
}
async function handler(event) {
const request = event.request;
const uri = request.uri || '/';
// Rule 1: Requests to "static files" (last path segment contains an extension) pass through unchanged,
// even if maintenance mode is enabled.
const lastSegment = uri.split('/').pop() || '';
const hasExtension = lastSegment.includes('.') && !lastSegment.endsWith('.');
if (hasExtension) {
return request;
}
// Rule 2: Check maintenance flag from CloudFront KeyValueStore (only for non-static routes)
const key = 'maintenance-mode';
let maintenanceOn = false;
if (kvsHandle) {
try {
const raw = await kvsHandle.get(key);
// Not sure how the key will be stored in kvs, need to check
maintenanceOn = String(raw).trim().toLowerCase() === 'true';
} catch (err) {
// If there's an error, treat it as maintenance off (per your requirement)
console.log(`KVS lookup failed for ${key}: ${err}`);
maintenanceOn = false;
}
}
// Rule 3: Rewrite to index.html by default, or maintenance page if flag is true
request.uri = maintenanceOn ? '/maintenance/index.html' : '/index.html';
return request;
}
export { handler };Associate this function with Viewer Request and that's it.
