Host NextJS on Cloudfront and S3 using CDK

Hosting static sites on Cloudfront and S3 is filled with many one-off quirks. Learn the steps we took and the roadblocks we encountered to deploy our NextJS static export site on Cloudfront and S3 using the AWS CDK.

Nick Kang
Consumer Rights Under CCPA

Hosting NextJS on Cloudfront/S3 is a low maintenance and low cost solution for apps that fit NextJS's static export criteria.

At ArticleAsset, I host part of the testing infrastructure using NextJS static exports with Cloudfront and S3 hosting.

Initially, I was able to deploy the site using this great gist. However, I moved all our infrastructure over to CDK and wanted for find a more CDK-native solution.

Unfortunately, I wasn't able to find any useful guides on how to accomplish that. So after a lot of trial and error, I finally was able to get the deploy pipeline working!

This guide details the steps I took and the roadblocks I encountered to deploy the NextJS static export site on Cloudfront and S3 using the AWS CDK.

Setup DNS and Certificate

First, you'll need a Route53 public hosted zone and a certificate from us-east-1. Regardless of what region your infra is located in, Cloudfront will require a certificate from us-east-1. Thankfully, the CDK makes this a piece of cake!

You'll want to deploy this in 2 steps. In the first step, only deploy the PublicHostedZone construct.

// ./infrastructure/src/Application.ts import * as route53 from 'aws-cdk-lib/aws-route53' const hostedZone = new route53.PublicHostedZone(this, 'PublicHostedZone', { zoneName: '', })

Then go to the Route53 console, grab the hosted zone name servers, and update your domain registrar to point to the hosted zone.

Route53 console

After your registrar updates (can take anywhere from 1 min to 24 hours), then create the certificate.

// ./infrastructure/src/Application.ts import * as certificatemanager from 'aws-cdk-lib/aws-certificatemanager' const certificateUSEast1 = new certificatemanager.DnsValidatedCertificate( this, 'CertificateUSEast1', { domainName: `*`, hostedZone, region: 'us-east-1', // it must be us-east-1 regardless of your region subjectAlternativeNames: [''], validation: certificatemanager.CertificateValidation.fromDns(hostedZone), }, )

A quirk I encountered is that if your deploy fails, CDK might not delete the DnsValidatedCertificate during the cleanup phase, so you have to go to the AWS console ACM page and delete it manually before rerunning the deploy. If not, your next deploy will timeout.

Setup NextJS Static Export

We're going to take a quick detour from the CDK and setup our NextJS static export.

Assume our NextJS app is located in the ./web folder.

Update your NextJS build script from next build to next build && next export which is the standard build script for static export.

Also make sure to set compress: false in next.config.js.

We're going to be offloading compression to Cloudfront. NextJS uses gzip compression, whereas Cloudfront uses brotli which has a superior compression ratio.

// ./web/next.config.js /** @type {import('next').NextConfig} */ const nextConfig = { compress: false, // of your config } module.exports = nextConfig

Setup Cloudfront Function

Cloudfront Functions are functions that you can run at Cloudfront's edge locations. They are not the same as Lambda@Edge. Cloudfront Functions are low latency (runs at edge locations), highly scalable (10,000,000 requests/sec), and very cost effective (free tier + 1/6 the price of lambda).

We need to create a Cloudfront Function to append the .html extension to all requests.

Create a folder called ./functions/fn-format-request

// ./functions/fn-format-request/src/index.ts function handler( event: AWSCloudFrontFunction.Event, ): AWSCloudFrontFunction.Request { const request = event.request const uri = request.uri if (uri === '/') { // turns "/" to "/index.html" request.uri += 'index.html' } else if (uri.endsWith('/')) { // turns "/foo/" to "/foo.html" request.uri = uri.slice(0, -1) + '.html' } else if (!uri.includes('.')) { // turns "/foo" to "/foo.html" request.uri += '.html' } return request }

In the above function, we're appending an index.html or .html to the request path so that it matches the object inside S3.

If you've worked with Lambda functions before, you'll notice that we are not export'ing the handler function. This is correct for Cloudfront Functions.

Your package.json should look something like this:

// ./functions/fn-format-request/package.json { "name": "@app/fn-format-request", "version": "0.0.0", "main": "src/index.ts", "scripts": { "build": "run-s 'tsc' 'minify' 'move'", "tsc": "tsc -p tsconfig.json", "minify": "terser --compress --mangle --output build/index.js build/index.js", "move": "mkdir _next && mv out/_next _next/_next" }, "devDependencies": { "@types/aws-cloudfront-function": "^1.0.2", "npm-run-all": "^4.1.5", "terser": "^5.14.2", "typescript": "5.1.6" } }

Our build script performs three tasks:

  1. tsc -p tsconfig.json compiles our typescript code into javascript

  2. terser --compress --mangle --output build/index.js build/index.js minifies the output. We do this because Cloudfront Functions do not currently work with CDK's NodejsFunction construct. Cloudfront Functions uses its own Function construct, which doesn't have the same functionality as the NodejsFunction construct.

  3. mkdir _next && mv out/_next _next/_next creates a _next folder and moves your cacheable static assets to a separate folder. This is so that we can deploy _next and out folders with different cache-control headers.

Make sure to add _next to your .gitignore

# .gitignore _next # of your .gitignore

Deploy files to S3

We're creating an S3 Bucket and BucketDeployments to populate the S3 Bucket with our NextJS static export from our local disk.

// ./infrastructure/src/Application.ts import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment' import * as s3 from 'aws-cdk-lib/aws-s3' const bucket = new s3.Bucket(this, 'StaticFilesBucket', { enforceSSL: true, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, }) new s3deploy.BucketDeployment(this, 'NoCacheFilesDeployment', { sources: [s3deploy.Source.asset('../web/out')], destinationBucket: bucket, cacheControl: [ s3deploy.CacheControl.fromString('public, max-age=0, must-revalidate'), ], prune: false, }) new s3deploy.BucketDeployment(this, 'StaticFilesDeployment', { sources: [s3deploy.Source.asset('../web/_next')], destinationBucket: bucket, cacheControl: [ s3deploy.CacheControl.fromString('public, max-age=31536000, immutable'), ], prune: false, })

We have two BucketDeployments:

  • NoCacheFilesDeployment - Uploads files we don't want cached. This could be *.html files, favicons, robots.txt, or sitemap.xml.

  • StaticFilesDeployment - Uploads NextJS cachable static assets. This includes our .js and .css files. We enable aggressive caching for these assets since their path is content-hashed.

We're setting prune: false for all the deployments because we want to keep older versions of our site up to handle the case that a user hasn't refreshed their site and is on an older version.

Setup Cloudfront Distribution

Create a Cloudfront Distribution and point it to your S3 Bucket. We're going to setup a default behavior and one additional behavior.

// ./infrastructure/src/Application.ts import * as cf from 'aws-cdk-lib/aws-cloudfront' import * as origins from 'aws-cdk-lib/aws-cloudfront-origins' const distribution = new cf.Distribution(this, 'Distribution', { defaultBehavior: { origin: new origins.S3Origin(bucket), allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, cachePolicy: new cf.CachePolicy(this, 'DefaultCachePolicy', { enableAcceptEncodingGzip: true, enableAcceptEncodingBrotli: true, minTtl: cdk.Duration.seconds(2), maxTtl: cdk.Duration.seconds(600), defaultTtl: cdk.Duration.seconds(2), comment: 'Managed-Amplify policy without cache keys', }), responseHeadersPolicy: cf.ResponseHeadersPolicy.SECURITY_HEADERS, originRequestPolicy: cf.OriginRequestPolicy.CORS_S3_ORIGIN, functionAssociations: [ { eventType: cf.FunctionEventType.VIEWER_REQUEST, function: new cf.Function(this, 'FormatRequestFunction', { comment: 'Formats path for S3', code: cf.FunctionCode.fromFile({ filePath: '../../functions/fn-format-request/build/index.js', }), }), }, ], }, additionalBehaviors: { '/_next/*': { origin: new origins.S3Origin(bucket), allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, cachePolicy: cf.CachePolicy.CACHING_OPTIMIZED, responseHeadersPolicy: cf.ResponseHeadersPolicy.SECURITY_HEADERS, originRequestPolicy: cf.OriginRequestPolicy.CORS_S3_ORIGIN, }, // Part 2 on connecting an API coming soon! // '/api/*': { // origin: new origins.HttpOrigin(apiEndPointDomainName), // allowedMethods: cf.AllowedMethods.ALLOW_ALL, // viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, // cachePolicy: cf.CachePolicy.CACHING_DISABLED, // responseHeadersPolicy: cf.ResponseHeadersPolicy.SECURITY_HEADERS, // // // originRequestPolicy: // cf.OriginRequestPolicy.USER_AGENT_REFERER_HEADERS, // }, }, priceClass: cf.PriceClass.PRICE_CLASS_100, domainNames: [''], certificate: certificateUSEast1, })

  • defaultBehavior is used for your .html files and any files in your public folder. We're adding a tiny bit of caching so we can use Cloudfront's compression features (which doesn't work when there's no caching).

  • /_next/* additional behavior is for the route which corresponds to all of NextJS's static files (ie, .js, .css, .json). We set aggressive caching here as these files never become stale since their filenames are content hashed.

One important detail to get Cloudfront / S3 communication working is to set originRequestPolicy: cf.OriginRequestPolicy.CORS_S3_ORIGIN. If you don't do this, you'll get a 403: Forbidden error.

I believe it's because S3 expects a certain set of headers and if you add any headers that are not expected, it'll deny the request.

FormatRequestFunction is the Cloudfront Function we created in the above step.

We're not setting defaultRootObject because our Cloudfront Function already handles formatting the request uri.

Once you follow these steps, you should be able to deploy your NextJS static export to Cloudfront and S3!


This guide showed you have to deploy your NextJS static export to Cloudfront and S3 using AWS CDK. The steps to do so are

  1. Create a Route 53 hosted zone and ACM certificate
  2. Setup NextJS static export
  3. Setup a Cloudfront Function to transform requests
  4. Deploy files to S3 using CDK
  5. Create a Cloudfront Distribution and point it at the S3 bucket using CDK

At ArticleAsset, I'm using this in conjunction with CDK Pipeline, so whenever a PR gets merged, it'll kick off this CDK deployment process. It's been a great low cost and low maintenance solution.