Post

Deploy to AWS with S3, CloudFront & CDK

Deploy to AWS with S3, CloudFront & CDK

Series Overview

  1. TypeScript Patterns for React
  2. Type-Safe Routing with TanStack Router
  3. Server State with TanStack Query
  4. Styling with Tailwind CSS v4 & shadcn/ui
  5. Modular Feature Architecture
  6. Deploy to AWS with S3, CloudFront & CDK (this post)

Introduction

With our React SPA built using TanStack Router, TanStack Query, Tailwind CSS, and a modular feature architecture, the final step is getting it into production. This post covers deploying to AWS using S3 for static file hosting, CloudFront as a CDN with HTTPS, and AWS CDK for infrastructure-as-code.

The architecture is straightforward: the browser hits CloudFront, which serves files from a private S3 bucket via Origin Access Control (OAC). Client-side routing is handled by custom error responses that redirect back to index.html, letting TanStack Router manage navigation on the client.

Architecture Overview

1
2
3
4
5
6
7
8
Browser --HTTPS--> CloudFront (CDN, TLS 1.2+, HTTP/2+3)
                        |
                        |  Origin Access Control (OAC)
                        |  signs every request with SigV4
                        v
                   S3 Bucket (PRIVATE, block all public access)
                        |
                   Static assets: index.html, JS, CSS, images

Key design decisions:

  • The S3 bucket stays fully private – no public access, no static website hosting enabled. CloudFront uses Origin Access Control (OAC) to sign requests using SigV4 short-term credentials. This is the AWS-recommended approach, replacing the legacy Origin Access Identity (OAI).
  • Client-side routing is handled via CloudFront custom error responses that redirect 403/404 errors back to index.html, allowing TanStack Router to handle navigation.
  • CDK’s BucketDeployment construct uploads the built React app to S3 and automatically invalidates the CloudFront cache.

Prerequisites

ToolMinimum VersionPurpose
Node.jsv18+Runtime for Vite, CDK, and npm
AWS CLIv2Authenticate and interact with AWS
AWS CDK CLIv2.150+Synthesize and deploy CDK stacks
TypeScriptv5.3+Required by TanStack Router

AWS Account Setup

1
2
3
4
5
# Configure credentials
aws configure

# Verify
aws sts get-caller-identity

Project Structure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
my-app/
├── frontend/                    # React TypeScript SPA (Vite + TanStack)
│   ├── src/
│   │   ├── routes/              #   TanStack Router file-based routes
│   │   ├── features/            #   Feature modules
│   │   ├── shared/              #   Shared code
│   │   └── main.tsx             #   Entry point
│   ├── package.json
│   └── vite.config.ts
│
├── infra/                       # CDK TypeScript project
│   ├── bin/
│   │   └── infra.ts             #   CDK App entry point
│   ├── lib/
│   │   └── spa-stack.ts         #   Main infrastructure stack
│   ├── cdk.json
│   └── package.json
│
└── .github/
    └── workflows/
        └── deploy.yml           # CI/CD pipeline (optional)

Phase 1: Frontend Build Configuration

Vite Configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { tanstackRouter } from '@tanstack/router-plugin/vite';
import path from 'path';

export default defineConfig({
  plugins: [
    tanstackRouter({
      target: 'react',
      autoCodeSplitting: true,
    }),
    react(),
  ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  build: {
    sourcemap: false,
    outDir: 'dist',
    chunkSizeWarningLimit: 500,
  },
});

The autoCodeSplitting: true option automatically wraps each route component in React.lazy(), so only the code needed for the current route is downloaded.

Verify the Build

1
2
3
4
cd frontend
npm run build
npm run preview
# Test navigation, deep links, and data loading

Phase 2: CDK Infrastructure Setup

Initialize the CDK Project

1
2
mkdir infra && cd infra
cdk init app --language typescript

CDK v2 consolidates all construct libraries into the single aws-cdk-lib package.

Bootstrap the AWS Environment

Bootstrapping creates a CDKToolkit CloudFormation stack with the resources CDK needs for deployment. Each AWS account + region pair must be bootstrapped once:

1
2
ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)
cdk bootstrap aws://$ACCOUNT_ID/us-east-1 --termination-protection

Phase 3: The CDK Stack

CDK App Entry Point

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// infra/bin/infra.ts
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { SpaStack } from '../lib/spa-stack';

const app = new cdk.App();

new SpaStack(app, 'ReactSpaStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  tags: {
    Project: 'react-spa',
    ManagedBy: 'cdk',
  },
});

The Main Stack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// infra/lib/spa-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as path from 'path';

export class SpaStack extends cdk.Stack {
  public readonly distribution: cloudfront.Distribution;
  public readonly siteBucket: s3.Bucket;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 1. S3 BUCKET -- Private, no website hosting
    this.siteBucket = new s3.Bucket(this, 'SiteBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // 2. SECURITY RESPONSE HEADERS POLICY
    const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(
      this, 'SecurityHeaders', {
        responseHeadersPolicyName: `${id}-security-headers`,
        securityHeadersBehavior: {
          contentTypeOptions: { override: true },
          frameOptions: {
            frameOption: cloudfront.HeadersFrameOption.DENY,
            override: true,
          },
          referrerPolicy: {
            referrerPolicy:
              cloudfront.HeadersReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN,
            override: true,
          },
          strictTransportSecurity: {
            accessControlMaxAge: cdk.Duration.days(365),
            includeSubdomains: true,
            preload: true,
            override: true,
          },
          xssProtection: {
            protection: true,
            modeBlock: true,
            override: true,
          },
        },
      }
    );

    // 3. CLOUDFRONT DISTRIBUTION WITH OAC
    this.distribution = new cloudfront.Distribution(this, 'SiteDistribution', {
      comment: 'React SPA with TanStack Router + Query',
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(this.siteBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        responseHeadersPolicy,
        compress: true,
      },
      defaultRootObject: 'index.html',
      errorResponses: [
        {
          httpStatus: 403,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: cdk.Duration.minutes(5),
        },
        {
          httpStatus: 404,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: cdk.Duration.minutes(5),
        },
      ],
      minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
      httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
      enableIpv6: true,
      priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
    });

    // 4. DEPLOY FRONTEND BUILD TO S3 + INVALIDATE
    new s3deploy.BucketDeployment(this, 'DeploySite', {
      sources: [
        s3deploy.Source.asset(path.join(__dirname, '../../frontend/dist')),
      ],
      destinationBucket: this.siteBucket,
      distribution: this.distribution,
      distributionPaths: ['/*'],
      memoryLimit: 256,
    });

    // 5. STACK OUTPUTS
    new cdk.CfnOutput(this, 'CloudFrontURL', {
      value: `https://${this.distribution.distributionDomainName}`,
      description: 'CloudFront Distribution URL',
    });

    new cdk.CfnOutput(this, 'CloudFrontDistributionId', {
      value: this.distribution.distributionId,
      description: 'Distribution ID (for manual invalidations)',
    });

    new cdk.CfnOutput(this, 'S3BucketName', {
      value: this.siteBucket.bucketName,
    });
  }
}

Understanding Each Construct

S3 Bucket – Private, no website hosting. OAC requires the S3 REST API endpoint, not the website endpoint. Do not enable “static website hosting.”

CloudFront Distribution with OACS3BucketOrigin.withOriginAccessControl() replaces the deprecated S3Origin (which used legacy OAI). It automatically creates an OAC, attaches it, and updates the bucket policy.

Error Responses for SPA Routing – When TanStack Router navigates to /posts/42, S3 returns 403 (no such object). CloudFront rewrites this to /index.html with status 200, and TanStack Router handles the path client-side.

BucketDeployment – Uses a Lambda to sync files from frontend/dist/ to S3 and invalidate CloudFront cache. Increase memoryLimit for large bundles.

Response Headers Policy – Adds HSTS, X-Frame-Options, X-Content-Type-Options, XSS-Protection, and Referrer-Policy to all responses.

Phase 4: Build and Deploy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 1. Build the frontend
cd frontend
npm run build
cd ..

# 2. Synthesize and review the CloudFormation template
cd infra
npx cdk synth

# 3. Preview changes
npx cdk diff

# 4. Deploy
npx cdk deploy

# 5. Verify: open the CloudFrontURL output in your browser
#    Test: navigate to /about, refresh the page
#    Test: navigate to /posts, verify data loads

For subsequent code-only deployments:

1
cd frontend && npm run build && cd ../infra && npx cdk deploy

Phase 5: Custom Domain with SSL (Optional)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as route53targets from 'aws-cdk-lib/aws-route53-targets';

const domainName = 'app.example.com';

const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
  domainName: 'example.com',
});

const certificate = new acm.Certificate(this, 'SiteCertificate', {
  domainName,
  validation: acm.CertificateValidation.fromDns(hostedZone),
});

// Add to Distribution props:
//   domainNames: [domainName],
//   certificate,

new route53.ARecord(this, 'SiteAliasRecord', {
  zone: hostedZone,
  recordName: domainName,
  target: route53.RecordTarget.fromAlias(
    new route53targets.CloudFrontTarget(this.distribution)
  ),
});

CloudFront requires ACM certificates in us-east-1. Either deploy the stack to us-east-1 or create a separate certificate stack in that region.

Phase 6: CI/CD Pipeline (Optional)

GitHub Actions with OIDC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# .github/workflows/deploy.yml
name: Deploy React SPA
on:
  push:
    branches: [main]

concurrency:
  group: deploy-$
  cancel-in-progress: false

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
          cache-dependency-path: |
            frontend/package-lock.json
            infra/package-lock.json

      - name: Build frontend
        working-directory: frontend
        run: |
          npm ci
          npm run build

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::$:role/GitHubActionsDeployRole
          aws-region: us-east-1

      - name: Deploy CDK
        working-directory: infra
        run: |
          npm ci
          npx cdk deploy --require-approval never

Troubleshooting Guide

Cause: S3 returns 403 for non-existent objects in a private bucket. Fix: Ensure CloudFront error responses map 403 and 404 to /index.html with status 200.

routeTree.gen.ts Not Generated

Cause: The Vite dev server is not running, or the TanStack Router plugin is not configured. Fix: Ensure tanstackRouter() is listed BEFORE react() in vite.config.ts plugins. Start the dev server with npm run dev.

Stale Content After Deployment

Fix: Ensure distributionPaths: ['/*'] is set on BucketDeployment. For manual invalidation:

1
aws cloudfront create-invalidation --distribution-id DIST_ID --paths "/*"

BucketDeployment Lambda Timeout

Fix: Increase memoryLimit to 512 or use aws s3 sync in CI/CD instead.

“S3Origin is deprecated” Warnings

Fix: Replace new origins.S3Origin(bucket) with origins.S3BucketOrigin.withOriginAccessControl(bucket).

Production Hardening Checklist

  • S3 bucket: Change to RemovalPolicy.RETAIN, remove autoDeleteObjects
  • Add WAF on CloudFront for bot protection and rate limiting
  • Set Cache-Control: long max-age on hashed assets, no-cache on index.html
  • Enable CloudFront access logging to a separate S3 bucket
  • Configure CloudWatch alarms for 4xx/5xx error rates
  • Add Content-Security-Policy header tailored to your app
  • Set up environment separation (dev/staging/prod stacks or accounts)
  • Enable bootstrap termination protection on production
  • Tune TanStack Query staleTime and gcTime per query
  • Add TanStack Router error boundaries on routes

Conclusion

This concludes the “Building Modern React SPAs” series. We have covered the full stack: TypeScript patterns for type-safe components, TanStack Router for compile-time safe navigation, TanStack Query for declarative server-state management, Tailwind CSS v4 and shadcn/ui for a themeable design system, a modular feature architecture that scales with your team, and finally AWS infrastructure with S3, CloudFront, and CDK.

Together, these tools and patterns form a production-ready foundation for building modern single-page applications. The key theme throughout has been type safety and developer experience – catching errors at compile time, eliminating boilerplate, and providing clear patterns that make the codebase predictable as it grows.

References

This post is licensed under CC BY 4.0 by the author.