React SPAs have a single index.html entry point. All routing happens in the browser with React Router or similar. This changes three things compared to the Astro deploy in post 13: the caching commands, the CSP policy, and the client-side routing setup.
React Template Parameters
The React template adds three parameters that the static and Astro templates don’t use.
Parameters:
ProjectName:
Type: String
Description: Prefix used for naming all resources.
AllowedPattern: '^[A-Za-z][A-Za-z0-9]*$'
GitHubBranch:
Type: String
Description: Branch to track for CI/CD pipeline.
Default: main
BuildOutputDirectory:
Type: String
Description: Build output directory for the SPA framework.
Default: dist
ProjectName replaces ${AWS::StackName} as the naming prefix throughout the template. Resource names like !Sub '${ProjectName}Build' and !Sub '${ProjectName}Deploy' use it instead of the stack name. This gives you control over naming independent of what you call the CloudFormation stack.
GitHubBranch parameterizes the branch in the pipeline’s Source stage. The static and Astro templates hardcode BranchName: main. The React template uses BranchName: !Ref GitHubBranch so you can point the pipeline at a different branch without editing the template.
BuildOutputDirectory parameterizes the build output path in the BuildProject’s buildspec. Instead of hardcoding base-directory: dist, the template uses !Sub to inject the parameter value:
Source:
Type: CODEPIPELINE
BuildSpec: !Sub |
version: 0.2
phases:
install:
runtime-versions:
nodejs: 20
commands:
- npm ci
build:
commands:
- npm run build
artifacts:
base-directory: ${BuildOutputDirectory}
files:
- '**/*'
The !Sub intrinsic function resolves ${BuildOutputDirectory} to the parameter value at deploy time. Most React frameworks (Vite, Create React App) output to dist or build. This parameter lets you switch without touching the template.
Security Headers Policy
The CSP adds connect-src 'self', which the Astro CSP from post 7 omits. This directive allows fetch and XMLHttpRequest calls to the same origin.
SecurityHeadersPolicy:
Type: 'AWS::CloudFront::ResponseHeadersPolicy'
Properties:
ResponseHeadersPolicyConfig:
Name: !Sub '${ProjectName}SecurityHeaders'
SecurityHeadersConfig:
StrictTransportSecurity:
AccessControlMaxAgeSec: 31536000
IncludeSubdomains: true
Preload: true
Override: true
ContentTypeOptions:
Override: true
FrameOptions:
FrameOption: DENY
Override: true
ReferrerPolicy:
ReferrerPolicy: strict-origin-when-cross-origin
Override: true
XSSProtection:
Protection: true
ModeBlock: true
Override: true
ContentSecurityPolicy:
Override: true
ContentSecurityPolicy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
StrictTransportSecuritysends theStrict-Transport-Securityheader. It tells browsers to only connect over HTTPS for one year (31536000seconds), includes all subdomains, and opts into the browser preload list so the rule applies even on the very first visit.ContentTypeOptionssendsX-Content-Type-Options: nosniff. It prevents browsers from guessing a file’s MIME type, which stops attacks where a malicious file is disguised as something harmless.FrameOptions: DENYsendsX-Frame-Options: DENY. It blocks other sites from embedding your pages in an<iframe>, preventing clickjacking attacks.ReferrerPolicy: strict-origin-when-cross-originsends the full URL as a referrer for same-origin requests but only the origin (no path) for cross-origin requests. This prevents leaking page paths to third-party sites.XSSProtectionsendsX-XSS-Protection: 1; mode=block. It enables the browser’s legacy XSS filter. Modern browsers rely on CSP instead, but this header provides a fallback for older browsers.
The key difference from the Astro CSP is connect-src 'self'. React SPAs typically make API calls with fetch or XMLHttpRequest, and the browser blocks those requests unless the CSP explicitly allows them. Adding connect-src 'self' permits requests to the same origin. If your app calls an external API, add that domain to the directive (for example, connect-src 'self' https://api.example.com).
SPA Routing Function
React Router handles all navigation in the browser. When a user visits /dashboard directly, CloudFront asks S3 for /dashboard, which doesn’t exist. Instead of relying on S3 error responses, a CloudFront Function rewrites the request before it reaches S3.
SPARoutingFunction:
Type: AWS::CloudFront::Function
Properties:
Name: !Sub '${ProjectName}SPARouting'
AutoPublish: true
FunctionConfig:
Comment: Rewrites non-asset paths to /index.html for SPA client-side routing
Runtime: cloudfront-js-2.0
FunctionCode: |
function handler(event) {
var request = event.request;
var uri = request.uri;
var isAsset = /\.(html|js|css|png|jpg|jpeg|gif|ico|svg|json|webp|woff|woff2|ttf|eot|map|txt|xml)$/i.test(uri);
if (isAsset) {
return request;
}
request.uri = '/index.html';
return request;
}
The function runs on every viewer request. It checks whether the URI ends with a known asset extension (.js, .css, .png, etc.). If it does, the request passes through to S3 unchanged. If not, the URI is rewritten to /index.html so React Router can handle the path.
This is cleaner than the CustomErrorResponses approach where S3 returns a 403 for missing keys and CloudFront intercepts the error. With the function, route-like paths always resolve to index.html without triggering an error. And missing assets (like a broken <script src="/app.abc123.js">) still hit S3 as-is, so they fail properly instead of being silently served as HTML.
Function Association
The function and security headers policy both need to be wired to the distribution’s DefaultCacheBehavior. Add ResponseHeadersPolicyId and FunctionAssociations to the CloudFront distribution from post 7.
DefaultCacheBehavior:
TargetOriginId: S3Origin
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # AWS Managed CachingOptimized policy
ResponseHeadersPolicyId: !Ref SecurityHeadersPolicy
Compress: true
FunctionAssociations:
- EventType: viewer-request
FunctionARN: !GetAtt SPARoutingFunction.FunctionARN
ResponseHeadersPolicyId attaches the security headers policy defined above so every response includes the CSP and other headers. EventType: viewer-request means the function runs before CloudFront checks its cache or contacts S3. FunctionARN references the SPA routing function.
SEO and Soft 404s
A static S3/CloudFront setup cannot dynamically change HTTP status codes based on client-side routes. There is no server rendering a response per route, so every path returns HTTP 200.
The standard mitigation for CSR SPAs is a soft 404. The React app includes a catch-all route (<Route path="*" />) that renders a 404 page. That page injects <meta name="robots" content="noindex"> into the <head>. When Googlebot receives the 200, it renders the page, reads the HTML, sees noindex, and drops the URL from its search index.
True HTTP 404 status codes require server-side rendering (SSR). For a static SPA on S3/CloudFront, the noindex meta tag is the standard pattern.
Deploy Service Role
The deploy role is identical to post 12. It needs CloudWatch Logs, artifact bucket read, site bucket read/write/delete, and CloudFront invalidation permissions. Refer to post 12 for the full YAML.
Deploy Project
The two-tier caching strategy is similar to post 13, but React SPAs have a single index.html instead of many HTML files. The second command uses s3 cp instead of s3 sync because there’s only one file.
DeployProject:
Type: 'AWS::CodeBuild::Project'
Properties:
Name: !Sub '${ProjectName}Deploy'
ServiceRole: !GetAtt DeployServiceRole.Arn
Artifacts:
Type: CODEPIPELINE
Environment:
Type: LINUX_CONTAINER
ComputeType: BUILD_GENERAL1_SMALL
Image: aws/codebuild/amazonlinux2-x86_64-standard:5.0
EnvironmentVariables:
- Name: DEPLOY_BUCKET
Value: !Ref S3WebsiteBucket
- Name: CLOUDFRONT_DIST_ID
Value: !Ref CloudFrontDistribution
Source:
Type: CODEPIPELINE
BuildSpec: |
version: 0.2
phases:
build:
commands:
- aws s3 sync . s3://$DEPLOY_BUCKET --delete --cache-control "max-age=31536000,public,immutable" --exclude "index.html"
- aws s3 cp index.html s3://$DEPLOY_BUCKET/index.html --cache-control "max-age=0,no-cache,no-store,must-revalidate"
post_build:
commands:
- aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DIST_ID --paths "/*"
The first command syncs everything except index.html with a one-year immutable cache. Vite, Create React App, and similar bundlers produce hashed filenames for JS, CSS, and assets. New builds create new hashes, so old cached versions are never served.
The second command copies index.html with no-cache headers. This is the only file that keeps its name across builds. It references the hashed assets, so the browser always gets the latest version and loads the correct bundle.
Deploy Stage
The pipeline stage configuration is identical to post 12. Add the Deploy stage to the pipeline’s Stages array with Provider: CodeBuild pointing to the deploy project. Refer to post 12 for the full YAML.