CloudFront Routing: SPAs vs Static Site Generation

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:

  1. Browser requests: https://example.com/about
  2. CloudFront looks in S3: No file at /about
  3. S3 returns: 404 Not Found
  4. 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 origin
  • origin-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