NextJs - Static Export Routing Issue on S3
Published: 2023-07-16 10:23:15

# The Goal

We plan to host NextJs frontend assets on S3 bucket, using NextJS static export, which will generate a HTML file per route, such as index.html, portfolio.html etc. This is to reduce first load bundle size.

NextJs static export is achieved with this:

```javascript
/**
 * @type {import('next').NextConfig}
 */
const nextConfig = {
  output: 'export'
}
 
module.exports = nextConfig
```

# The Problem

Discovered routing issue when trying to navigate to pages directly.
- For direct routes like "`/portfolios`", we need to append a "`.html`" to the url in order to get to the page and it is not ideal. 
- For nextjs dynamic routes such as "`/portfolios/[portfolio_id]`", it is a bigger issue, as we do not expect user to search with "`https://www.coolweb/portfolios/[portfolio_id].html`" and it does not go to the desired `portfolio_id` anyway.

# The Solution
## Cloudfront with Lambda@Edge
We will create a Lambda@Edge that will have logic to resolve the routing issue and then we associate this Lambda to the origin request of the CloudFront Distribution. 

Origin request refers to a request made by CloudFront to the origin server for content that is not currently cached or needs to be refreshed in the cache. In our case the origin server is the S3 bucket where we upload nextjs static assets.

The association will look like this in the aws console.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0je67ae96zpwecgbzejw.png)

Sample Yaml file that creates the Lambda Edge Function and the CloudFront Distribution.

```yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: Lambda Edge Function - Rewrite Nextjs Static Export Routing

Parameters:
  ProjectName:
    Description: The Project name
    Type: String
    Default: awesome
  LambdaRole:
    Type: String
    Description: Lambda IAM Role Arn
  LambdaEdgeName:
    Type: String
    Description: "Lambda Edge Name"
    Default: "router-lambda-edge"

Resources:
  LambdaEdgeRedirectFunction:
    Type: AWS::Lambda::Function
    Properties:
      Description: Lambda to rewrite Nextjs Static Export Routing
      Runtime: nodejs18.x
      # lambda edge does not support arm64 yet
      Architectures: 
        - x86_64
      Role: !Ref LambdaRole
      FunctionName: !Sub "${ProjectName}-${LambdaEdgeName}"
      Handler: index.handler
      Code:
        ZipFile: |
          // check if url has extension like .html
          const hasExtension = /(.+)\.[a-zA-Z0-9]{2,5}$/;
          // check if url end with '/'
          const hasSlash = /\/$/;
          // check for dynamic routes
          const dynamicPortfolioRoutes = /\/portfolios\/\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/

          exports.handler = async(event,context, callback) => {
              const { request } = event.Records[0].cf;
              console.log('req',request)
              const url = request.uri

              if (url){
                  // check for dynamic portfolios route
                  if (url.match(dynamicPortfolioRoutes) && !url.match(hasExtension) && !url.match(hasSlash)) {
                      request.uri = `${url.substr(0, url.lastIndexOf('/'))}/[portfolio_id].html`;
                      return callback(null, request);
                  }

                  // check for fixed route
                  if (!url.match(hasExtension) && !url.match(hasSlash)) { 
                      request.uri = `${url}.html`; 
                      return callback(null, request);
                  }
              
              }
              // If nothing matches, return request unchanged
              return callback(null, request);
          }

  LambdaEdgeRedirectFunctionVersion:
    Type: AWS::Lambda::Version
    Properties: 
      FunctionName: !GetAtt LambdaEdgeRedirectFunction.Arn

  CloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: S3 Origin Access Identity

  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        CustomErrorResponses: 
        - ErrorCachingMinTTL: 10
          ErrorCode: 403
          ResponseCode: 403
          ResponsePagePath: "/index.html"
        - ErrorCachingMinTTL: 10
          ErrorCode: 404
          ResponseCode: 404
          ResponsePagePath: "/index.html"
        DefaultCacheBehavior:
          TargetOriginId: bucket
          DefaultTTL: 86400
          Compress: true
          ForwardedValues:
            QueryString: false
          ViewerProtocolPolicy: redirect-to-https
          PriceClass: "PriceClass_All"
          LambdaFunctionAssociations: # associate lambda edge at us-east-1 for nextjs fe routing rewrite
            - EventType: origin-request
              LambdaFunctionARN: !Join
                - ':'
                - -  !GetAtt [LambdaEdgeRedirectFunction, Arn]
                  -  !GetAtt [LambdaEdgeRedirectFunctionVersion, Version]
        DefaultRootObject: "index.html"
        Enabled: true
        HttpVersion: http2
        Origins:
          - Id: bucket
            DomainName: !Sub: "${BucketName}.s3-${AWS::Region}.amazonaws.com"
            S3OriginConfig:
              OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}"
```
The above yaml sample will not work out of the box, you need to also create the following resources:
- Lambda Role should have the following Trust Relationship:
```yaml
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
                - edgelambda.amazonaws.com
            Action: sts:AssumeRole
```
- The Lambda Role should also allowed lambda invocations and s3 read access:
```yaml
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - lambda:InvokeFunction
              - lambda:InvokeAsync
          Resource: 
              - !Sub "arn:aws:lambda: ..."
          - Effect: Allow
            Action:
              - S3:Get*
              - S3:List*
            Resource:
              - !Sub "arn:aws:s3: ..."
```
- Create a S3 resource with Bucket Policy that allows `s3:GetObject` for the Cloudfront distribution using an Origin Access Identity (OAI):
```yaml
MyS3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: <your-s3-bucket>
      AccessControl: Private
      BucketPolicy:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowCloudFrontAccess
            Effect: Allow
            Principal:
              CanonicalUser: !GetAtt MyCloudFrontOriginAccessIdentity.S3CanonicalUserId
            Action: s3:GetObject
            Resource: !Sub "arn:aws:s3:::${MyS3Bucket}/*"
```
Thanks for Reading!