Deploy Nextjs on AWS Cloudfront and S3

Introduction

Recently, I undertook the task of rebuilding my personal website using Next.js. My goal was to build a basic static blog using next.js and host it on AWS Cloudfront and S3. Despite finding several tutorials, none seemed to fit my specific requirements, leading me to experiment through trial and error. In this blog post, I aim to share the effective solution I discovered, hoping to streamline this process for others.

Prerequisites

Before diving into the details, here are the prerequisites:

  • A Next.js application ready for deployment.
  • An active AWS account with necessary permissions.

Preparing Your Next.js Application

The first step involves preparing your Next.js application for deployment. Ensure your application is production-ready by running:

npm run build

and you have next.config.js configured in export mode

const nextConfig = {
    output: 'export',
    // other configs
}

This command generates a out folder containing optimized production builds of your application.

Issues with Next.js and AWS Cloudfront

While setting up my Next.js application on AWS Cloudfront using a modified dev-kit static build template, I encountered a significant challenge with path routing. This issue arises from the mismatch between the URL path on the website (like site.com/contact) and the corresponding file in the S3 bucket (contact.html).

Path Routing Problem

In a typical Next.js setup, navigating to site.com/contact expects a contact.html at the root. Most web servers can handle this by serving the contact.html file when the request URI is /contact. However, s3 is not a web server, and only understands key paths.

Using Next.js's trailingSlash option creates nested files like contact/index.html, but this still doesn't solve the problem due to the .html extension mismatch.

Solution: Cloudfront Edge Function

To overcome this, I implemented a Cloudfront edge function to rewrite incoming request URIs. The function modifies the incoming URI to point to the correct HTML file in the S3 bucket. Below is the TypeScript code for the edge function:

exports.handler = async (event) => {
    const request = event.Records[0].cf.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;
};

Integration with AWS CDK

For those using the AWS Cloud Development Kit (CDK), integrating this solution is straightforward. Below is a CDK stack example that includes the edge function:

import {Certificate} from 'aws-cdk-lib/aws-certificatemanager';
import {
    CloudFrontWebDistribution,
    CloudFrontWebDistributionProps,
    OriginAccessIdentity,
    ViewerCertificate,
} from 'aws-cdk-lib/aws-cloudfront';
import {BlockPublicAccess, Bucket} from 'aws-cdk-lib/aws-s3';
import {BucketDeployment, ISource} from 'aws-cdk-lib/aws-s3-deployment';
import {Construct} from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import {Runtime, Code} from 'aws-cdk-lib/aws-lambda';
import {LambdaEdgeEventType} from 'aws-cdk-lib/aws-cloudfront';
 
 
export interface StaticSiteProps {
    readonly certificateArn?: string;
    readonly domainNames?: string[];
    readonly sourceAsset: ISource;
}
 
export class StaticSiteWithRewrite extends Construct {
    constructor(scope: Construct, id: string, props: StaticSiteProps) {
        super(scope, id);
 
        if ((props.certificateArn && !props.domainNames) || (!props.certificateArn && props.domainNames)) {
            throw new Error('Both certificateArn and domainNames must be provided if one is provided.');
        }
 
        // Lambda@Edge Function
        const edgeLambda = new lambda.Function(this, `${id}EdgeLambda`, {
            runtime: Runtime.NODEJS_LATEST,
            handler: 'index.handler',
            code: Code.fromInline(`
exports.handler = async (event) => {
    const request = event.Records[0].cf.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;
};
      `),
        });
 
        const bucket = new Bucket(this, `${id}WebsiteBucket`, {
            websiteIndexDocument: 'index.html',
            blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
        });
 
        const originAccessIdentity = new OriginAccessIdentity(this, 'OAI');
        bucket.grantRead(originAccessIdentity);
 
        new BucketDeployment(this, `${id}DeployWebsite`, {
            sources: [props.sourceAsset],
            destinationBucket: bucket,
        });
 
        let distributionProps: CloudFrontWebDistributionProps = {
            originConfigs: [
                {
                    s3OriginSource: {
                        s3BucketSource: bucket,
                        originAccessIdentity: originAccessIdentity,
                    },
                    behaviors: [
                        {
                            isDefaultBehavior: true,
                            lambdaFunctionAssociations: [{
                                eventType: LambdaEdgeEventType.ORIGIN_REQUEST,
                                lambdaFunction: edgeLambda.currentVersion,
                            }],
                        }
                    ],
                },
            ],
        };
 
        if (props.certificateArn && props.domainNames) {
            distributionProps = {
                ...distributionProps,
                viewerCertificate: ViewerCertificate.fromAcmCertificate(
                    Certificate.fromCertificateArn(this, `${id}certificate`, props.certificateArn),
                    {aliases: props.domainNames},
                ),
            };
        }
 
        new CloudFrontWebDistribution(this, `${id}WebsiteDistribution`, distributionProps);
    }
}

In this CDK stack, we define a Lambda@Edge function that rewrites the request URI. This ensures that the path routing issue is resolved, making the Next.js application function correctly when hosted on AWS Cloudfront.

By implementing these solutions, I was able to successfully deploy my Next.js application on AWS Cloudfront and overcome the routing challenges. This approach can be beneficial for anyone facing similar issues with Next.js and AWS Cloudfront integration.