When deploying modern web applications to CloudFront, the routing strategy you need depends entirely on your application architecture. Get it wrong, and your users will see the homepage when they refresh /about. Get it right, and everything just works.
The Tale of Two Architectures
When deploying modern web applications to CloudFront, the routing strategy you need depends entirely on your application architecture. Get it wrong, and your users will see the homepage when they refresh /about
. Get it right, and everything just works.
In this post, I'll explain why the same CloudFront configuration that works perfectly for SPAs breaks static site generation—and how to fix it based on real-world experience.
Understanding Single Page Applications (SPAs)
How SPAs Work
A traditional SPA has one HTML file (index.html
) that handles all routes client-side:
S3 Bucket Structure (SPA):
├── index.html ← The ONLY HTML file
├── main.js ← JavaScript that handles routing
├── style.css
└── assets/
└── logo.png
When a user visits /about
:
- Browser requests:
https://example.com/about
- CloudFront looks in S3: No file at
/about
- S3 returns:
404 Not Found
- JavaScript router can't load: The browser never got
index.html
!
The SPA Solution: 404 → index.html
This is why SPAs need error response configuration:
{
"error_responses": [
{
"http_status": 404,
"response_page_path": "/index.html",
"response_http_status": 200,
"ttl": 0
}
]
}
The Flow:
1. User visits /about
2. S3 doesn't have /about → 404
3. CloudFront error response: Return /index.html (200)
4. Browser receives index.html
5. JavaScript loads and shows /about content ✅
This is brilliant for SPAs because:
- ✅ All routes return the same HTML file
- ✅ JavaScript handles the routing
- ✅ Deep links work on refresh
- ✅ Users can bookmark any page
Examples of SPAs:
- React apps (Create React App, Vite)
- Vue apps (Vue CLI, client-only)
- Angular apps
- Any framework using client-side routing only
Static Site Generation (SSG): A Different Beast
How SSG Works
Static site generators create individual HTML files for each route:
S3 Bucket Structure (SSG):
├── index.html ← Homepage
├── about/
│ └── index.html ← About page
├── blog/
│ └── index.html ← Blog listing page
├── blog/
│ └── my-first-post/
│ └── index.html ← Blog post
└── products/
├── product-a/
│ └── index.html ← Product page
└── product-b/
└── index.html ← Product page
Key difference: Each page is pre-rendered at build time with:
- ✅ Full HTML content (SEO-friendly)
- ✅ Proper
<title>
tags - ✅ Meta descriptions
- ✅ Structured data
- ✅ Page-specific content
Examples of SSG:
- Nuxt (with
nuxt generate
) - Next.js (static export)
- Gatsby
- Hugo, Jekyll
- Eleventy
The Problem: When SPA Config Meets SSG
Here's what happens when you use SPA error responses with a static site:
Scenario: User Refreshes /about
With SPA Error Responses (WRONG for SSG):
1. Browser requests: /about
2. CloudFront URL rewrite: /about → /about/index.html ✅
3. S3 check: /about/index.html exists!
4. BUT... (hypothetical 404 for demo)
5. Error response triggers: Return /index.html
6. User sees: Homepage ❌
7. Browser URL: Still shows /about
8. SEO impact: Google sees wrong content
The Core Issue:
- 404 error responses return
/index.html
for everything - This works for SPAs (intentional)
- But for SSG, it means all pages look the same to search engines
Real-World Impact
When deployed with SPA config on an SSG site:
✅ Navigating from homepage to /about: Works
✅ Clicking menu links: Works
❌ Refreshing /about: Shows homepage
❌ Direct link to /about: Shows homepage
❌ SEO crawler visits /about: Sees homepage content
❌ Canonical URL points to /about but content is /: Canonical error
Common SEO crawler results:
- High percentage of canonical errors (often 90%+)
- All pages showing identical content
- Google would index homepage content for every URL
The Solution: URL Rewrite Without Error Responses
CloudFront Function for Clean URLs
Instead of error responses, use a CloudFront Function to rewrite URLs before they hit S3:
function handler(event) {
var request = event.request;
var uri = request.uri;
// If URI doesn't have a file extension and doesn't end with /
if (!uri.includes('.') && !uri.endsWith('/')) {
request.uri = uri + '/index.html';
}
// If URI ends with / but not index.html
else if (uri.endsWith('/') && !uri.endsWith('index.html')) {
request.uri = uri + 'index.html';
}
// If URI is exactly /
else if (uri === '/') {
request.uri = '/index.html';
}
return request;
}
The Flow:
1. User visits /about
2. CloudFront Function: Rewrite to /about/index.html
3. S3 returns: /about/index.html (200) ✅
4. Browser receives: Actual about page content
5. SEO: Correct content for each page
Configuration
For SPAs:
{
"cloudfront": {
"enabled": true,
"error_responses": [
{
"http_status": 404,
"response_page_path": "/index.html",
"response_http_status": 200
}
]
}
}
For SSG:
{
"cloudfront": {
"enabled": true,
"enable_url_rewrite": true
}
}
Note: No error_responses for SSG!
Why This Matters for SEO
With SPA Error Responses (Wrong for SSG)
Search engine crawls your site:
Google visits /about
├─ Gets: /index.html content
├─ Sees: "Welcome to My Site" (homepage title)
├─ Canonical: <link rel="canonical" href="https://example.com/about">
└─ Problem: Content doesn't match URL ❌
Google visits /blog
├─ Gets: /index.html content (same as /about!)
├─ Sees: "Welcome to My Site" (homepage title again)
├─ Canonical: <link rel="canonical" href="https://example.com/blog">
└─ Problem: Duplicate content ❌
Result:
- 90%+ canonical errors
- Pages can't rank individually
- Search visibility: Destroyed
With URL Rewrite (Correct for SSG)
Search engine crawls your site:
Google visits /about
├─ Gets: /about/index.html content
├─ Sees: "About Us | My Site" (correct title)
├─ Canonical: <link rel="canonical" href="https://example.com/about">
└─ Content matches URL ✅
Google visits /blog
├─ Gets: /blog/index.html content
├─ Sees: "Blog Posts | My Site" (unique title)
├─ Canonical: <link rel="canonical" href="https://example.com/blog">
└─ Unique content ✅
Result:
- 0% canonical errors
- Each page ranks independently
- Search visibility: Perfect ✅
Decision Matrix: Which Approach Do I Need?
Use SPA Error Responses (404 → index.html) When:
✅ Single HTML file: Your build produces one index.html
✅ Client-side routing: React Router, Vue Router, Angular Router
✅ No pre-rendering: Content loads dynamically in browser
✅ Examples: Create React App, Vue CLI default, Angular CLI
Detection: Run npm run build
and check output:
dist/
├── index.html ← Only one HTML file
├── js/
└── css/
Use URL Rewrite (No Error Responses) When:
✅ Multiple HTML files: Each route has its own HTML
✅ Pre-rendered content: Pages generated at build time
✅ SEO-critical: Need unique content per page
✅ Examples: Nuxt generate, Next.js export, Gatsby, Hugo
Detection: Run npm run generate
and check output:
dist/
├── index.html
├── about/
│ └── index.html ← Multiple HTML files
├── education/
│ └── index.html
└── contact/
└── index.html
Hybrid Approaches
Next.js: Mix of Both
Next.js can do both SSG and SPA:
// Static generation (use URL rewrite)
export async function getStaticProps() {
return { props: { ... } }
}
// Client-side only (use error responses)
// No getStaticProps or getServerSideProps
Solution: Use URL rewrite + error responses for truly dynamic routes.
Nuxt: Mostly SSG with Some Dynamic
// nuxt.config.ts
export default defineNuxtConfig({
// Generate static pages
ssr: true,
// But some routes are client-only
routeRules: {
'/admin/**': { ssr: false }, // Client-only
'/blog/**': { prerender: true } // Static
}
})
Solution: URL rewrite for static pages, SPA-style routing for dynamic sections.
Implementation Guide
Step 1: Identify Your Architecture
# Check your build output
npm run build # or npm run generate
# Count HTML files
find dist -name "*.html" | wc -l
# If output is 1: You need SPA config
# If output is >1: You need SSG config
Step 2: Configure CloudFront
For SPA:
// CDK
new cloudfront.Distribution(this, 'Distribution', {
defaultRootObject: 'index.html',
errorResponses: [
{
httpStatus: 404,
responsePagePath: '/index.html',
responseHttpStatus: 200,
ttl: Duration.seconds(0)
}
]
})
For SSG:
// CDK
const urlRewriteFunction = new cloudfront.Function(this, 'UrlRewrite', {
code: cloudfront.FunctionCode.fromInline(`
function handler(event) {
var request = event.request;
var uri = request.uri;
if (!uri.includes('.') && !uri.endsWith('/')) {
request.uri = uri + '/index.html';
} else if (uri.endsWith('/')) {
request.uri = uri + 'index.html';
} else if (uri === '/') {
request.uri = '/index.html';
}
return request;
}
`)
});
new cloudfront.Distribution(this, 'Distribution', {
defaultRootObject: 'index.html',
defaultBehavior: {
functionAssociations: [{
function: urlRewriteFunction,
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
}]
}
// NO errorResponses!
})
Step 3: Test Thoroughly
# Test direct navigation
curl -I https://example.com/about
# Test with Accept header (simulates browser)
curl -H "Accept: text/html" https://example.com/about | grep "<title>"
# Each URL should return unique content
Performance Considerations
CloudFront Functions vs Lambda@Edge
CloudFront Functions (what we use):
- ⚡ Sub-millisecond latency
- 💰 $0.10 per 1M invocations
- 🌍 Runs at all edge locations
- ✅ Perfect for URL rewrites
Lambda@Edge (overkill for this):
- 🐌 ~50ms latency
- 💸 $0.60 per 1M invocations (6x more expensive)
- 🔧 Only needed for complex logic
Caching Impact
With URL Rewrite:
Request: /about
├─ CloudFront Function: <1ms
├─ Cache key: /about/index.html
└─ Subsequent requests: Cached at edge ✅
With Error Responses:
Request: /about
├─ S3 lookup: Miss (404)
├─ Error response: /index.html
├─ Cache key: /index.html
└─ Problem: Same cache for all routes ❌
Debugging Tips
Check What CloudFront Returns
# Get response headers
curl -I https://example.com/about
# Should see:
# HTTP/2 200
# content-type: text/html
# x-cache: Hit from cloudfront (if cached)
Verify Unique Content
# Homepage
curl -s https://example.com/ | grep "<title>"
# Output: <title>Democracy Health Check</title>
# About page (should be different!)
curl -s https://example.com/about | grep "<title>"
# Output: <title>About | Democracy Health Check</title>
Check S3 File Structure
aws s3 ls s3://your-bucket/ --recursive | grep index.html
# Should see:
# index.html
# about/index.html
# education/index.html
# etc.
Common Pitfalls
1. Mixing Architectures
Problem: Using SPA config for SSG or vice versa
Symptom: Pages work on navigation but break on refresh
Fix: Identify architecture correctly, choose matching config
2. CloudFront Function Not Actually Associated
Problem: You created the function but it's not running
Symptom: Getting S3/CloudFront 403/404 XML errors on refresh
How to Check:
# List all functions
aws cloudfront list-functions \
--query 'FunctionList.Items[].{Name:Name,Status:Status}' \
--output table
# Check what's actually associated with your distribution
aws cloudfront get-distribution-config \
--id YOUR_DIST_ID \
--query 'DistributionConfig.DefaultCacheBehavior.FunctionAssociations'
What You Should See:
{
"Quantity": 1,
"Items": [{
"FunctionARN": "arn:aws:cloudfront::123456:function/UrlRewriteFunction",
"EventType": "viewer-request"
}]
}
What's Wrong:
{
"Quantity": 0 // ❌ No function associated!
}
Fix: Redeploy with correct CDK configuration
3. Multiple Functions on Same Event Type
Problem: CloudFront only allows ONE function per event type
Symptom: You have multiple functions created, but only one is active
Real-World Example:
// ❌ WRONG - Both on VIEWER_REQUEST
function_associations = [
{ function: urlRewriteFunction, eventType: VIEWER_REQUEST },
{ function: hostRestrictionFunction, eventType: VIEWER_REQUEST }
]
// Result: Only the LAST one (hostRestrictionFunction) actually runs!
Solution: Combine functions into one:
function handler(event) {
var request = event.request;
// Do host restrictions FIRST (security)
var allowedHosts = ['example.com', 'www.example.com'];
var hostHeader = request.headers.host.value;
if (allowedHosts.indexOf(hostHeader) === -1) {
return { statusCode: 403, statusDescription: 'Forbidden' };
}
// Then do URL rewrite (routing)
var uri = request.uri;
if (!uri.includes('.') && !uri.endsWith('/')) {
request.uri = uri + '/index.html';
} else if (uri.endsWith('/')) {
request.uri = uri + 'index.html';
} else if (uri === '/') {
request.uri = '/index.html';
}
return request;
}
Available Event Types:
viewer-request
- Before CloudFront forwards to originorigin-request
- Before CloudFront sends to origin (Lambda@Edge only)origin-response
- After origin responds (Lambda@Edge only)viewer-response
- Before CloudFront returns to viewer
Tip: Use viewer-request
for URL rewrites since it's fastest and cheapest.
4. CloudFront Caching
Problem: Old config cached at edge locations
Symptom: Changes don't take effect immediately
Fix:
aws cloudfront create-invalidation \
--distribution-id YOUR_ID \
--paths "/*"
Note: Invalidations can take 15-30 minutes to propagate globally.
5. Origin Path vs URI Rewriting
Problem: Your S3 bucket uses version folders (e.g., /20250104/
)
Setup:
S3 Bucket: my-site-bucket
├── 20250104/ ← Version folder (origin path)
│ ├── index.html
│ ├── about/
│ │ └── index.html
│ └── blog/
│ └── index.html
CloudFront Config:
- Origin Path:
/20250104
- URL Rewrite Function: Rewrites
/about
→/about/index.html
- Final S3 Request:
/20250104/about/index.html
✅
How It Works:
1. User requests: /about
2. Function rewrites: /about → /about/index.html
3. CloudFront adds origin path: /20250104/about/index.html
4. S3 returns: File from /20250104/about/index.html ✅
Common Mistake: Don't include origin path in rewrite function!
6. Trailing Slashes
Problem: /about
works but /about/
doesn't
Fix: URL rewrite function handles both cases:
// Handles /about
if (!uri.includes('.') && !uri.endsWith('/')) {
request.uri = uri + '/index.html'; // → /about/index.html
}
// Handles /about/
else if (uri.endsWith('/') && !uri.endsWith('index.html')) {
request.uri = uri + 'index.html'; // → /about/index.html
}
7. File Extensions Getting Rewritten
Problem: CSS/JS files get rewritten incorrectly (e.g., /style.css
→ /style.css/index.html
)
Symptom: Assets fail to load, broken styling
Fix: Check for file extensions in rewrite function:
if (!uri.includes('.') && !uri.endsWith('/')) {
// This prevents /style.css from being rewritten
request.uri = uri + '/index.html';
}
Why This Works:
/about
- No dot, gets rewritten ✅/style.css
- Has dot, skipped ✅/assets/logo.png
- Has dot, skipped ✅
8. Testing Functions in Console
Problem: Function deployed but you're not sure if it works
Solution: Use CloudFront Function Test in AWS Console
Test Event for /about
:
{
"version": "1.0",
"context": {
"eventType": "viewer-request"
},
"viewer": {
"ip": "1.2.3.4"
},
"request": {
"method": "GET",
"uri": "/about",
"querystring": {},
"headers": {
"host": {
"value": "example.com"
}
},
"cookies": {}
}
}
Expected Output:
{
"request": {
"method": "GET",
"uri": "/about/index.html", // ✅ Rewritten!
"querystring": {},
"headers": {
"host": {
"value": "example.com"
}
}
}
}
If Output Shows uri: "/about"
: Function isn't working, check code.
9. 403 vs 404 Errors
Understanding the difference:
403 Forbidden:
- S3 bucket exists, but access denied
- CloudFront Function blocked the request
- OAI/OAC permissions issue
404 Not Found:
- File doesn't exist in S3
- URL rewrite pointed to wrong path
- Origin path misconfigured
Debugging:
# Check if file exists in S3
aws s3 ls s3://your-bucket/version/about/ --recursive
# Should show:
# about/index.html
Conclusion
The architecture you choose determines the CloudFront configuration you need:
Architecture | Files | Routing | CloudFront Config |
---|---|---|---|
SPA | Single HTML | Client-side | Error responses (404 → index.html) |
SSG | Multiple HTML | Pre-rendered | URL rewrite function |
Hybrid | Mixed | Both | URL rewrite + selective error responses |
Example SSG project results:
- Architecture: SSG (Nuxt/Next.js static export)
- Output: Multiple HTML files (one per page)
- Config: URL rewrite function
- Result: 0% canonical errors, perfect SEO ✅
Choose wisely, and your users will thank you when they can refresh any page without seeing the homepage!
Troubleshooting Checklist
When URL rewriting isn't working, check these in order:
✅ Step 1: Verify Your Architecture
# Count HTML files in build output
find dist -name "*.html" | wc -l
# 1 file = SPA (use error responses)
# Multiple files = SSG (use URL rewrite)
✅ Step 2: Check Function Association
# See what functions are actually running
aws cloudfront get-distribution-config \
--id YOUR_DIST_ID \
--query 'DistributionConfig.DefaultCacheBehavior.FunctionAssociations'
# Should show Quantity: 1 and your URL rewrite function
✅ Step 3: Test Function in Console
- Go to CloudFront → Functions → Your Function
- Click "Test" tab
- Input:
{ "request": { "uri": "/about" } }
- Output should show:
"uri": "/about/index.html"
✅ Step 4: Check Only One Function Per Event Type
# List all functions
aws cloudfront list-functions --output table
# If you see multiple functions, check if they conflict
# Only ONE can be on viewer-request!
✅ Step 5: Verify S3 File Structure
# Check files exist where you expect
aws s3 ls s3://your-bucket/version/ --recursive | grep index.html
# Should show:
# index.html
# about/index.html
# blog/index.html
# etc.
✅ Step 6: Check Origin Path
aws cloudfront get-distribution-config \
--id YOUR_DIST_ID \
--query 'DistributionConfig.Origins.Items[0].OriginPath'
# If output: /20250104
# Then S3 path is: /20250104/about/index.html
# URL rewrite should NOT include /20250104!
✅ Step 7: Clear Cache
# Invalidate all cached content
aws cloudfront create-invalidation \
--distribution-id YOUR_DIST_ID \
--paths "/*"
# Wait 15-30 minutes for global propagation
✅ Step 8: Test in Browser
# Open DevTools → Network tab
# Navigate to: https://example.com/about
# Refresh page
# Check response:
# - Status: 200 (not 403/404)
# - Content-Type: text/html
# - Body: Shows about page content (not homepage)
✅ Step 9: Check Error Responses
aws cloudfront get-distribution-config \
--id YOUR_DIST_ID \
--query 'DistributionConfig.CustomErrorResponses'
# For SSG: Should show "Quantity": 0
# For SPA: Should have 404 → /index.html
✅ Step 10: Verify Function Code
- Check function has no syntax errors
- Handles all cases:
/about
,/about/
,/
- Doesn't rewrite files with extensions:
.css
,.js
,.png
Quick Reference
SPA Setup
// CloudFront Distribution
errorResponses: [
{
httpStatus: 404,
responsePagePath: '/index.html',
responseHttpStatus: 200,
ttl: 0
}
]
// NO URL rewrite function needed
SSG Setup
// CloudFront Function (viewer-request)
function handler(event) {
var request = event.request;
var uri = request.uri;
if (!uri.includes('.') && !uri.endsWith('/')) {
request.uri = uri + '/index.html';
} else if (uri.endsWith('/')) {
request.uri = uri + 'index.html';
} else if (uri === '/') {
request.uri = '/index.html';
}
return request;
}
// NO error responses (or only 403 fallback)
Resources:
Tags: #CloudFront #AWS #Nuxt #Next.js #SSG #SPA #SEO #WebDevelopment #StaticSiteGeneration