3 min read

Host a Next.js site on S3 and CloudFront for pennies

Serve a static Next.js export from a private S3 bucket behind CloudFront. You get HTTPS, edge caching, and a Lambda API on one domain for close to nothing.

Terraform architecture and deployment safetyAWSNext.jsCloudFrontServerless

Static sites are the sweet spot for a portfolio or blog. They load fast, they cache well, and they cost almost nothing to run. This site runs exactly this way. Here is how you serve a Next.js export from AWS without paying for a server that sits idle.

Why you put CloudFront in front of S3

You can point people straight at an S3 bucket. You should not. CloudFront gives you three things that matter.

HTTPS and a private bucket

S3 static website hosting serves plain HTTP. To get HTTPS on your own domain, you put CloudFront in front and attach a certificate. You also keep the bucket private with Origin Access Control, so only the distribution can read it. The bucket never faces the public internet. See the AWS guide on restricting access to an S3 origin with OAC.

One domain for the site and the API

CloudFront routes by path. You send most requests to S3 and send /api/* to a separate origin, like an API Gateway endpoint backed by Lambda. The browser talks to one domain, so you skip CORS entirely. I use this to attach the Lambda backend that powers the dynamic parts of the site.

What it costs

CloudFront has an always free tier. As of 2024 it covers 1 TB of data transfer out and 10 million HTTPS requests each month, and it does not expire after your first year. Transfer from S3 into CloudFront costs nothing. Confirm the current numbers on the CloudFront pricing page.

For a personal site you will likely pay nothing. Traffic has to grow a lot before you leave the free tier. That is the whole appeal.

How the pieces fit

You build the site as static files. In next.config.ts you set output: "export", and next build writes plain HTML, CSS, and JavaScript into an out folder. The Next.js static export guide covers the details and the features you give up.

You upload that folder to the private bucket. CloudFront reads it through OAC. One piece is easy to miss. A static export writes blog.html, not blog, so clean URLs need a small rewrite at the edge. A CloudFront Function on the viewer request handles it.

function handler(event) {
  var request = event.request;
  var uri = request.uri;
  if (uri === "/" || uri.includes(".")) {
    return request;
  }
  request.uri = uri.endsWith("/") ? uri + "index.html" : uri + ".html";
  return request;
}

How you deploy it

Three commands ship a change. You build, you sync, and you clear the cache so visitors see the new version right away.

next build
aws s3 sync out s3://your-bucket --delete
aws cloudfront create-invalidation --distribution-id ABCDEF --paths "/*"

The tradeoffs

A static export is fully static. You give up server-side rendering and incremental static regeneration. For a portfolio and a blog that is a fair trade, because the content changes when you deploy, not on every request. If you need per-request rendering, host Next.js on a server instead.

Frequently asked questions

Do I still need CloudFront if my bucket is public?

Yes. A public bucket cannot serve HTTPS on your own domain, and it exposes your files directly. CloudFront gives you TLS, caching, and a private origin through OAC.

How much does this setup really cost?

For a low traffic personal site, usually nothing. The CloudFront free tier covers 1 TB out and 10 million requests a month, and S3 to CloudFront transfer is free.

Can I run server-side rendering this way?

No. output: "export" produces static files only. Pages that use server-side data functions will fail the export. Host on a server runtime if you need them.

How do I serve clean URLs without a .html suffix?

Add a CloudFront Function on the viewer request that appends .html to extensionless paths and index.html to directory paths. The example above does this.

How do I put my API on the same domain?

Add a second CloudFront origin and a cache behavior for /api/* that points at API Gateway. Same domain means no CORS. This site runs that pattern in production, deployed via GitHub Actions with OIDC.

Comments

    Leave a comment