Deploying Next.js to AWS Lambda
Hosting a serverless Next.js app using AWS Lambda and Terraform.
Next.js is a React framework for creating full-stack applications, implementing server-side rendering (SSR), static site generation, and API routes. In this article, we will use Next.js to create an SSR app that can be hosted using the serverless components of AWS.
Building the Next.js App
When we build the Next.js app, we create a payload to compress and send to the Lambda function. It will consist of specific Next.js build artefacts and the Lambda's run script: a simple bash script that runs the Next.js server.
#!/bin/bash [ ! -d '/tmp/cache' ] && mkdir -p /tmp/cache # Setting `HOSTNAME=0.0.0.0` makes the Next.js app bind to all available network # interfaces inside the Lambda execution environment, not just localhost. HOSTNAME=0.0.0.0 exec node server.js
We need to build the Next.js app, then copy the contents of .next/standalone
into the Lambda payload directory, along with the lambda_entry.sh
script.
next build --turbopack && \ rm -rf dist || true && \ mkdir dist && \ cp -R .next/standalone/ dist/ && \ cp lambda_entry.sh dist/
The dist
directory should now contain all the required artefacts.
dist ├── lambda_entry.sh ├── node_modules ├── package.json └── server.js
Deploying Serverless Infrastructure
The deployment has three main components:
- A Lambda function API that serves SSR content
- An S3 bucket that serves static content
- A CloudFront distribution to manage incoming client requests.
Beginning with the Lambda function, we first declare the resources required to build the Next.js app, and then define the function itself. The Lambda function implements the AWS Lambda Web Adapter, so we include its ARN in the function's layers
array.
locals { working_dir = "${path.module}/path/to/dist" } resource "null_resource" "build_nextjs_app" { provisioner "local-exec" { working_dir = local.working_dir command = "npm ci && npm run build" } triggers = { always_run = "${timestamp()}" } } data "null_data_source" "wait_for_build_nextjs_app" { inputs = { id = "${null_resource.build_nextjs_app.id}" } } data "archive_file" "nextjs_app_payload" { type = "zip" source_dir = "${local.working_dir}/dist" output_file_mode = "0666" output_path = "${local.working_dir}/dist.zip" depends_on = [null_resource.build_nextjs_app] } module "nextjs_app_lambda_function" { source = "gitlab.com/ben_goodman/lambda-function/aws" version = "4.0.0" lambda_payload = data.archive_file.nextjs_app_payload function_name = "${var.project_name}-${var.service_name}-nextjs-app-${terraform.workspace}" function_handler = "lambda_entry.sh" publish = true memory_size = 2048 role = aws_iam_role.api_lambda_role runtime = "nodejs20.x" timeout = 10 layers = [ "arn:aws:lambda:us-east-1:753240598075:layer:LambdaAdapterLayerX86:25", ] environment_variables = { AWS_LAMBDA_EXEC_WRAPPER = "/opt/bootstrap" AWS_LWA_ENABLE_COMPRESSION = "true" RUST_LOG = "info" PORT = "8000" } }
We finish the SSR API by attaching an API Gateway to the Lambda function. The gateway forwards all requests to Lambda; thus, we use $default
for the stage name and route key.
resource "aws_apigatewayv2_api" "http" { name = "${var.project_name}-${var.service_name}-http-gateway-${terraform.workspace}" protocol_type = "HTTP" } resource "aws_apigatewayv2_stage" "http" { api_id = aws_apigatewayv2_api.http.id name = "$default" auto_deploy = true } resource "aws_lambda_permission" "allow_gateway_invoke" { statement_id = "${var.project_name}-${var.service_name}-ssr-lambda-permission-${terraform.workspace}" action = "lambda:InvokeFunction" function_name = module.nextjs_app_lambda_function.name principal = "apigateway.amazonaws.com" source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*" } // create a default route that forwards all requests to // the client SSR Lambda function resource "aws_apigatewayv2_integration" "default" { api_id = aws_apigatewayv2_api.http.id integration_uri = module.nextjs_app_lambda_function.invoke_arn integration_type = "AWS_PROXY" integration_method = "POST" } resource "aws_apigatewayv2_route" "default" { api_id = aws_apigatewayv2_api.http.id route_key = "$default" target = "integrations/${aws_apigatewayv2_integration.default.id}" }
The next step is to create a static assets bucket and access permissions for CloudFront.
resource "aws_s3_bucket" "static_assets_bucket" { bucket = "${var.project_name}-${var.service_name}-static-assets-${terraform.workspace}" force_destroy = true } resource "aws_cloudfront_origin_access_control" "static_assets_source_bucket_oac" { name = "${var.project_name}-${var.service_name}-static-assets-oac" description = "${var.project_name} ${var.service_name} static assets OAC." origin_access_control_origin_type = "s3" signing_behavior = "always" signing_protocol = "sigv4" }
We must also sync the static build artefacts with the bucket. Since asset URLs begin with _next/static
, each artefact object key must be prefixed with _next/static
. Alternatively, all static artifacts can be synced to the bucket's root, and a CloudFront function can be used to rewrite the viewer request path before it goes to S3.
resource "null_resource" "sync_static_assets" { depends_on = [null_resource.build_nextjs_app, aws_s3_bucket.static_assets_bucket] provisioner "local-exec" { command = <<-EOT aws s3 sync \ --delete \ ${local.working_dir}/.next/static s3://${aws_s3_bucket.static_assets_bucket.bucket}/_next/static EOT } triggers = { always_run = "${timestamp()}" }
AWS CloudFront will serve requests for both the SSR Lambda and static S3 assets using different cache behaviors:
- Requests for
_next/static/*
go to the S3 assets origin. - Everything else goes to the SSR Lambda.
resource "aws_cloudfront_distribution" "client_distribution" { enabled = true origin { domain_name = trimsuffix(trimprefix(aws_apigatewayv2_api.http.api_endpoint, "https://"), "/") origin_id = "nextjs-app-origin" custom_origin_config { http_port = 80 https_port = 443 origin_protocol_policy = "https-only" origin_ssl_protocols = ["TLSv1.2"] } } origin { domain_name = aws_s3_bucket.static_assets_bucket.bucket_domain_name origin_id = "static-assets-s3-origin" origin_access_control_id = aws_cloudfront_origin_access_control.static_assets_source_bucket_oac.id } default_cache_behavior { allowed_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"] target_origin_id = "nextjs-app-origin" cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # disable caching for API viewer_protocol_policy = "redirect-to-https" } ordered_cache_behavior { path_pattern = "_next/static/*" allowed_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"] target_origin_id = "static-assets-s3-origin" cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # CachingOptimized viewer_protocol_policy = "redirect-to-https" } restrictions { geo_restriction { restriction_type = "none" } } viewer_certificate { cloudfront_default_certificate = true } }
The final step is to define Terarform's output values. We simply need the domain of the CloudFront distribution.
output "cloudfront_default_domain" { value = aws_cloudfront_distribution.client_distribution.domain_name }
Once deployed, navigating to the CloudFront distribution will load the Next.js app.