IAnswerable

Custom Error Pages in CloudFront for SPAs

30 Dec 2025

You deploy a React or Vue single-page app behind CloudFront, hit refresh on /dashboard/settings, and get a 403 or 404 instead of your app. This is the single most common CloudFront + SPA bug, and the fix is two settings, not a routing rewrite.

Why it happens

Your SPA has exactly one real file in S3: index.html. Every other route — /dashboard, /settings, /whatever — is handled client-side by your JS router, not by separate files on disk. When a user refreshes on /dashboard/settings, the browser asks CloudFront (and behind it, S3) for an object literally named dashboard/settings. It doesn’t exist. S3 returns a 403 (if the bucket is private behind Origin Access Control, which it should be) or a 404 (if public). CloudFront passes that error straight through by default, and the user gets AWS’s raw error page instead of your app.

The fix

Tell CloudFront to serve index.html in place of the error, with a 200 status, for exactly these cases. In the distribution’s Error Pages settings:

HTTP error code Response page path Response HTTP code
403 /index.html 200
404 /index.html 200
aws cloudfront get-distribution-config --id YOUR_DIST_ID > config.json
# edit config.json to add CustomErrorResponses, then:
aws cloudfront update-distribution --id YOUR_DIST_ID --distribution-config file://config.json --if-match ETAG

Or in the console: Distribution → Error pages → Create custom error response, set both 403 and 404 to return /index.html with response code 200.

Why the response code matters

Setting the response code to 200 (not just pointing at index.html) matters more than it looks. If you leave it at 403 or 404, the browser still gets your app’s HTML back, but marks the response as a failed request — which breaks anything checking response.ok on the initial page load, confuses search engine crawlers into thinking the page doesn’t exist, and can trip client-side error boundaries that treat a non-200 navigation as a hard failure. The user sees the app either way; the difference is whether the rest of the stack agrees the page loaded successfully.

Real 404s stop being real 404s

This is the actual tradeoff, and it’s a real one: with this rule in place, CloudFront can’t distinguish “SPA route the JS router will handle” from “genuinely nonexistent asset,” because both produce the same S3 error. A typo’d image URL or a truly broken link now serves your app shell with a 200 instead of an honest 404. Handle “not found” inside your app instead — your router already needs a catch-all/* route; render your own 404 UI there, and if it matters for SEO, set the document’s meta status via your framework’s mechanism for it (most SSR/SSG setups have one) so crawlers still see a real 404 where it counts.

Cache the error response briefly, not forever

Default error caching TTL in CloudFront is 10 seconds for these responses. That’s usually fine — if you fix a bucket permissions issue, you don’t want CloudFront serving a cached failure for hours afterward. Leave it low, and if you need it to clear immediately during debugging, that’s exactly the kind of thing worth a one-off invalidation on /index.html.

Gotcha

If you’re using Origin Access Control and just switched from a public bucket, double check the OAC policy is actually attached — a misconfigured OAC also produces a 403, so you can spend an hour convinced this custom-error-page fix isn’t working when the real issue is CloudFront can’t reach the origin at all. Confirm you get a 403 (not a timeout) directly from the S3 origin URL before troubleshooting the error page config.