Back to notes

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';"
  • StrictTransportSecurity sends the Strict-Transport-Security header. It tells browsers to only connect over HTTPS for one year (31536000 seconds), includes all subdomains, and opts into the browser preload list so the rule applies even on the very first visit.
  • ContentTypeOptions sends X-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: DENY sends X-Frame-Options: DENY. It blocks other sites from embedding your pages in an <iframe>, preventing clickjacking attacks.
  • ReferrerPolicy: strict-origin-when-cross-origin sends 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.
  • XSSProtection sends X-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.