Back
View all
All posts

Deploy React Single Page Application to AWS S3 and CloudFront Using AWS SAM

December 5, 2025
<a href="https://www.freepik.com/free-vector/business-team-brainstorm-idea-lightbulb-from-jigsaw-working-team-collaboration-enterprise-cooperation-colleagues-mutual-assistance-concept-pinkish-coral-bluevector-isolated-illustration_11667116.htm#query=collaboration&position=2&from_view=keyword&track=sph&uuid=21ea3c1d-7461-49c7-9157-5822c4372896">Image by vectorjuice</a> on Freepik

At Move Work Forward we build products that improve team visibility, communication, coordination, and productivity. One of our newest products is Model Viewer for Outlook, an Outlook add-in that lets professionals preview 2D and 3D models directly inside their inbox without switching tools.

The add-in’s UI is a React single-page application. We wanted a globally distributed, low-maintenance setup, so we decided to use S3 and CloudFront, and manage the infrastructure with AWS SAM.

Setting up S3 and CloudFront within AWS SAM was straightforward — there are plenty of guides online — but adding a custom domain was more challenging. In this post, we walk through how we configured the infrastructure, deployed the UI, and connected a custom domain, all using AWS SAM to automate everything.

Note: In this post — we assume you are already familiar with React (for the demo we use React + Vite). If you want to jump straight to the code, everything is in the demo repository.

Architecture & Tools

The React app we’re deploying is intentionally simple. It was created using Vite’s react-ts template, stripped down to the basics, and uses wouter for routing. There are two routes /home and /about plus a small Not Found page. And a couple of link elements let us navigate between routes.

To keep things focused, we’ll only deploy the minimal resources required to get everything running. You can expand or modify the setup later to match your own system.

To deploy our React app, we’ll need a few AWS resources:

  • S3 Bucket — Hosts the built SPA files
  • CloudFront Distribution — Serves the files from S3 and handles caching
  • ACM Certificate — Automatically generated so we can secure our domain over HTTPS
  • Note: For automatic validation to work — we need to use DNS validation, our domain’s Hosted Zone Id and the Domain must be hosted on AWS
  • Route53 Hosted Zone — Used for validating the certificate and routing the domain to CloudFront

We’ll also use a few tools:

  • CloudFormation — Lets us describe our infrastructure as code
  • AWS CLI — For interacting with AWS from the terminal
  • AWS SAM — A developer tool focused on serverless infrastructure. It extends CloudFormation and provides a very convenient CLI for packaging and deploying our app. Even though it’s serverless focused, we can use it for anything CloudFormation supports
  • Bash — For a small deployment utility script to build and sync our SPA to S3

Setting up the infrastructure files & utility script

We’ll start by creating a samconfig.yml. This file defines how the SAM CLI behaves for this project. It isn't required, but it saves a lot of typing by avoiding repeated CLI arguments.

version: 0.1

default:
  deploy:
    parameters:
      region: us-east-1
      resolve_s3: true # Automatically create SAM S3 bucket if not created yet
      capabilities: CAPABILITY_IAM
      confirm_changeset: true

production:
  deploy:
    parameters:
      stack_name: react-cf-production
      s3_prefix: react-cf-production
      region: us-east-1
      resolve_s3: true
      capabilities: CAPABILITY_IAM
      confirm_changeset: true

The default section applies when we run sam deploy. The production section applies when we run sam deploy --config-env production.

Now let’s create the SAM template. This file (usually template.yml) defines everything AWS needs to deploy our infrastructure.

Here’s the basic structure:

AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31

Description: SAM Template for React SPA, S3, CloudFront and Custom Domain

Parameters:
# Parameters for dynamic configuration
Resources:
# AWS resources like S3 buckets, CloudFront, etc.
Outputs:
# Values exported after deployment

Parameters

Let’s add the parameters we’ll need — by specifying Domain and HostedZoneId, the template is dynamic enough to be re-used across multiple environments:

Parameters:
  Domain:
    Type: String
    Description: Domain to connect with UI - has to be registered on AWS
  DomainHostedZoneId:
    Type: String
    Description: Hosted Zone Id of the Domain - will be used to automatically add DNS Record for Certificate Validation

If your domain was purchased through AWS, then Route53 created the Hosted Zone automatically. You can find the Hosted Zone ID in:

Route53 -> Hosted Zones -> Find your domain in the list

Press enter or click to view image in full size

Resources — ACM Certificate

We’ll start with the certificate — the certificate is later on connected with CloudFront distribution, letting CloudFront know which certificate to use when user tries to access CloudFront thru our custom domain:

# Certificate for Domain
DomainCertificate:
  Type: AWS::CertificateManager::Certificate
  Properties:
    DomainName: !Ref Domain
    ValidationMethod: DNS
    DomainValidationOptions:
      - DomainName: !Ref Domain
        HostedZoneId: !Ref DomainHostedZoneId
    Tags:
      - Key: Name
        Value: !Sub '${AWS::StackName}-domain-certificate'

We’re using CloudFormation intrinsic functions like !Ref and !Sub throughout the template. They help us reference parameters and other resources.

Resources — S3

# UI S3
UiBucket:
  Type: AWS::S3::Bucket
  Properties:
    BucketName: !Sub '${AWS::StackName}-ui-bucket'

# UI S3 Bucket Policy - Allowing our UI CloudFront Distribution to get objects
UiBucketPolicy:
  Type: AWS::S3::BucketPolicy
  Properties:
    Bucket: !Ref UiBucket
    PolicyDocument:
      Id: !Sub '${AWS::StackName}-policy-for-ui-cloudfront'
      Version: 2012-10-17
      Statement:
        - Sid: !Sub '${AWS::StackName}-read-statement-for-ui-cloudfront'
          Effect: Allow
          Principal:
            Service: cloudfront.amazonaws.com
          Action: s3:GetObject
          Resource: !Sub '${UiBucket.Arn}/*'
          Condition:
            StringEquals:
              AWS:SourceArn: !Sub arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${UiCloudFrontDistribution}

The bucket is simple, but the bucket policy is important: it allows only our CloudFront distribution to read the files. We reference our CloudFrount distribution defined below.

Resources — CloudFront

# UI CloudFront
UiCloudFrontDistribution:
  Type: AWS::CloudFront::Distribution
  Properties:
    DistributionConfig:
      Comment: !Sub '${AWS::StackName}-ui-cloudfront'
      PriceClass: PriceClass_All
      Enabled: true
      Origins:
        # Point to our UI S3 Bucket so CloudFront looks for files there
        - DomainName: !GetAtt UiBucket.RegionalDomainName
          Id: !Sub '${AWS::StackName}-ui-origin'
          OriginAccessControlId: !GetAtt UiOriginAccessControl.Id
          S3OriginConfig: {}
      DefaultCacheBehavior:
        AllowedMethods:
          - GET
          - HEAD
          - OPTIONS
        TargetOriginId: !Sub '${AWS::StackName}-ui-origin'
        # Sets AWS Managed Caching Optimized Policy Id - Source: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html#managed-cache-caching-optimized
        CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
        # Sets AWS Managed S3 Cors Origin - Source: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-cors-s3
        OriginRequestPolicyId: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf
        ViewerProtocolPolicy: redirect-to-https
      CustomErrorResponses:
        - ErrorCode: 403
          ResponseCode: 200
          ResponsePagePath: /index.html
        - ErrorCode: 404
          ResponseCode: 200
          ResponsePagePath: /index.html
      Aliases:
        - !Ref Domain
      ViewerCertificate:
        AcmCertificateArn: !Ref DomainCertificate
        SslSupportMethod: sni-only
        MinimumProtocolVersion: TLSv1
    Tags:
      - Key: Name
        Value: !Sub '${AWS::StackName}-ui-cloudfront'

# CloudFront Origin Access Control that secures S3 origin access to CloudFront only
UiOriginAccessControl:
  Type: AWS::CloudFront::OriginAccessControl
  Properties:
    OriginAccessControlConfig:
      Name: Sub '${AWS::StackName}-ui-origin-access-control'
      OriginAccessControlOriginType: s3
      SigningBehavior: always
      SigningProtocol: sigv4

The key parts here are:

  • Aliases: lets our CloudFront distribution know any other domains we wish to use to reach it apart from the generated one (e.g. d111111abcdef8.cloudfront.net)
  • ViewerCertificate: tells our CloudFront which ACM Certificate to use when viewer (e.g. user) tries to access cloudfront thru our custom domain - we connect it to the Certificate we defined at the start
  • CustomErrorResponses: needed for SPA routing (fallback to index.html which is our SPA entrypoint)

If you wish to learn more about all the options for CloudFront you can check out the official documentation.

Resources — Route53 Records

# DNS Records that point towards UI CloudFront Distribution
UiRecordSetIpV4:
  Type: AWS::Route53::RecordSet
  Properties:
    HostedZoneId: !Ref DomainHostedZoneId
    Type: A
    Name: !Ref Domain
    AliasTarget:
      DNSName: !GetAtt UiCloudFrontDistribution.DomainName
      HostedZoneId: Z2FDTNDATAQYW2 # CloudFront always has this hosted zone id

UiRecordSetIpV6:
  Type: AWS::Route53::RecordSet
  Properties:
    HostedZoneId: !Ref DomainHostedZoneId
    Type: AAAA
    Name: !Ref Domain
    AliasTarget:
      DNSName: !GetAtt UiCloudFrontDistribution.DomainName
      HostedZoneId: Z2FDTNDATAQYW2 # CloudFront always has this hosted zone id

These DNS records point our domain to the CloudFront distribution (A is for IPv4 while AAAA is for IPv6. Our CloudFront distribution has IPv6 support enabled by default.

Outputs

Outputs:
  DomainCertificateArn:
    Description: Arn of the Domain Certificate
    Value: !Ref DomainCertificate
  UiBucketName:
    Description: Name of the UI S3 Bucket
    Value: !Ref UiBucket
  UiCloudFrontDistributionId:
    Description: Id of the UI CloudFront Distribution
    Value: !Ref UiCloudFrontDistribution

Here we simply define the values from this stack that we wish to look up later. We will need the UiBucketName and UiCloudFrontDistributionId in the deployment utility script.

That’s the whole infrastructure. You can also view the file in the demo repository.

Time to deploy

First, authenticate to the AWS CLI (e.g., using aws login, depending on your setup).

Once authenticated to AWS CLI — we are also automatically authenticated to the AWS SAM CLI. Now we need to do the following to go thru deployment process:

Build the SAM Template (this will transform our template.yml file to the required format by AWS):

sam build --config-env production

Deploy the infrastructure (replace the parameter overrides with your own):

sam deploy --config-env production --parameter-overrides Domain=moveworkforward.com DomainHostedZoneId=Z02**********

We will be asked to confirm the changeset:

Press enter or click to view image in full size

When the deployment finishes, SAM prints the outputs (Note: First deployment can take a few minutes. Especially certificate validation):

Press enter or click to view image in full size

The last thing we have to do is deploy the UI:

./deploy-ui.sh --config-env production

This builds the SPA, syncs it to S3, and invalidates CloudFront.

Press enter or click to view image in full size

Now visit your domain and you should see the site live!

Conclusion

And that’s it. Once the infrastructure is in place, deploying a new version of the React app is as simple as running ./deploy-ui.sh. Hopefully this walkthrough and the SAM template save you some of the trial and error we went through along the way.

Feel free to check out our website if you’d like to learn more about what we do.

References:

Articles you might like

All You Need To Know About Microsoft Azure DevOps and Atlassian Jira
July 21, 2023
All You Need To Know About Microsoft Azure DevOps and Atlassian Jira
Microsoft Azure DevOps and Atlassian Jira are very powerful tools with features that help development teams successfully build products and manage their processes. While having different functionalities and strong points, many teams have found that the best way to get the most out of both platforms is to employ the CI/CD features of Azure DevOps while using the extensive project management features of Jira to drive their agile processes successfully.
Read more >
2023 Offsite - Move Work Forward Team in Spain  🇪🇸
October 6, 2023
2023 Offsite - Move Work Forward Team in Spain 🇪🇸
Move Work Forward is a fully-remote team and once in a while, we meet in person to build bonds and have fun and create memories together. This year we went to Spain for a 5-day team offsite. It all started in the sunny Barcelona.
Read more >
Team Offsite in Amsterdam: Moving Forward Across Continents
June 28, 2025
Team Offsite in Amsterdam: Moving Forward Across Continents
Last week, our Move Work Forward team gathered in Amsterdam for a global offsite — with teammates flying in from three continents. It was a rare and energizing opportunity to meet face-to-face, collaborate on big ideas, and, of course, have some fun along the way.
Read more >

Articles you might like

New blog posts

Deploy React Single Page Application to AWS S3 and CloudFront Using AWS SAM
December 5, 2025
Deploy React Single Page Application to AWS S3 and CloudFront Using AWS SAM
Read more >
Feature flags solutions research
November 13, 2025
Feature flags solutions research
A short analysis of feature flag solutions.
Read more >
Integrations tests in CircleCI
November 13, 2025
Integrations tests in CircleCI
Read how to run Cypress and other integrations tests in CircleCI, to make sure that your code was still working fine after a change.
Read more >
Get productivity tips delivered straight to your inbox
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Atlassian Logo
Platinum Marketplace Partner
AICPA Logo
SOC 2 Type II Certified
Gitlab Logo
GitLab Official partner
EU GDPR Logo
EU GDPR Compliant
Google Logo
Google Partner
Microsoft Logo
Microsoft Partner