How Shipworthy runs Next.js 16 on AWS with SST
The serverless stack behind this site: Next.js 16 on Lambda and CloudFront via SST and OpenNext, plus the four deploy gotchas that cost me the most time.
By Shaheer Q. · · 3 min read
Shipworthy is a Next.js 16 site that runs entirely on AWS: articles are prerendered HTML on CloudFront, and the few dynamic parts run on Lambda. SST deploys all of it from one config file. Getting there took one afternoon of setup and four gotchas that each cost more time than the setup itself. Here is the stack, the config, and the gotchas, so you can skip them.
The stack
| Layer | Choice |
|---|---|
| Framework | Next.js 16.4.3, React 19.3, App Router |
| Infrastructure as code | SST 4.17.2 (sst.aws.Nextjs) |
| Adapter | OpenNext 4.1.12 (@opennextjs/aws) |
| Runtime | Node 24 on Lambda, behind CloudFront |
| Data | DynamoDB, on-demand, one table |
| DNS | Cloudflare (the registrar), managed by SST |
| CI/CD | GitHub Actions with an OIDC role, no stored AWS keys |
The goal is a site that costs close to nothing when nobody is reading it. Everything here scales to zero: no VPC, no NAT gateway, no database server, no provisioned concurrency. I also set an AWS Budget at $10 a month with alerts at 50%, 100% and the 100% forecast, so a surprise shows up in my inbox rather than on my card.
So far there has been no surprise. The site went up at the end of October, and Cost Explorer shows $0.83 for October, $0.70 of it S3 and $0.11 CloudFront, then $0.16 for the first eight days of November. That is before launch, with next to no readers, so treat it as the idle floor rather than a forecast. I will update this with the first full month.
The whole infrastructure in one file
SST turns a Next.js app into a CloudFront distribution, S3 buckets for assets and the cache, and Lambda functions for server rendering and image optimisation. On top of that I add one DynamoDB table for reader state:
const table = new sst.aws.Dynamo("Table", {
fields: { pk: "string", sk: "string", gsi1pk: "string", gsi1sk: "string" },
primaryIndex: { hashKey: "pk", rangeKey: "sk" },
globalIndexes: { gsi1: { hashKey: "gsi1pk", rangeKey: "gsi1sk" } },
ttl: "expiresAt",
deletionProtection: $app.stage === "production",
});
const site = new sst.aws.Nextjs("Site", {
link: [table],
openNextVersion: "4.1.12",
domain: productionDomain($app.stage),
});
link is what makes this pleasant. Server code reads Resource.Table.name, with permissions and environment wiring generated for you, and nothing gets copied into .env files.
Gotcha 1: SST picked an OpenNext that cannot build Next.js 16
The first deploy failed to build. SST auto-detects which OpenNext version to use, and the version it chose, 3.9.14, does not support Next.js 16. The fix is one line, pinning a 4.x release:
new sst.aws.Nextjs("Site", { openNextVersion: "4.1.12" });
Treat that pin as part of your Next.js version. When you upgrade Next.js, check the OpenNext release notes and bump both together.
Gotcha 2: Windows cannot build the image optimizer
I bootstrapped the first production deploy from my Windows machine. It worked, and it took about eight minutes, most of which was waiting for CloudFront. It also shipped a broken image optimizer: @opennextjs/aws 4.1.12 cannot install the optimizer's native sharp dependency on Windows because of a temp-path bug.
Two changes followed:
- Production deploys only run in CI on Linux. The same deploy on a GitHub Actions runner takes about two minutes and builds
sharpcorrectly. - pnpm uses a hoisted
node_modules. OpenNext's standalone file tracing failed on pnpm's symlinks under Windows, so local builds need this:
nodeLinker: hoisted
Gotcha 3: the OIDC subject claim was not the one in the docs
CI deploys assume an IAM role through GitHub's OIDC provider, so there are no long-lived AWS keys in GitHub. The trust policy matches the token's sub claim, and most examples show it as repo:<owner>/<repo>:ref:refs/heads/main.
My deploys were rejected until I printed the claims of a real token. For this repository the subject was ID-qualified:
repo:<owner>@<owner_id>/<repo>@<repo_id>:ref:refs/heads/main
The setup script now resolves both IDs with gh api and writes them into the trust policy. If your role assumption fails with a valid-looking policy, decode the token and look at the sub it actually carries before changing anything else.
Gotcha 4: Cloudflare DNS needs your account ID
The domain is registered with Cloudflare, and Cloudflare Registrar requires Cloudflare's nameservers, so Route 53 was never an option. SST handles this natively:
function productionDomain(stage: string) {
if (stage !== "production") return undefined;
const zone = process.env.CLOUDFLARE_ZONE_ID;
if (!zone || !process.env.CLOUDFLARE_API_TOKEN) {
throw new Error("Production needs CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN");
}
return {
name: "shipworthy.net",
redirects: ["www.shipworthy.net"],
dns: sst.cloudflare.dns({ zone }),
};
}
Two details matter. First, I gave CI a token scoped to the one zone with DNS edit rights, and a zone-scoped token cannot list accounts, so the Cloudflare provider refused to start until I also set CLOUDFLARE_DEFAULT_ACCOUNT_ID. Second, the function throws rather than returning undefined when the variables are missing. Deploying production without the domain block would delete the certificate and DNS records, so failing fast is the safer default.
Keep the records DNS-only (the grey cloud). Proxying through Cloudflare in front of CloudFront adds a second CDN, breaks certificate validation and hides real client IPs.
What is next
This post is the first in a build log. Next up are the content engine and SEO foundations, then the newsletter and the reading rewards that give this site its name. The building in public hub collects every update.
Frequently asked questions
Does SST support Next.js 16?
Yes, through OpenNext 4. In SST 4.17.2 the auto-detected OpenNext version was 3.9.14, which does not support Next.js 16, so pin openNextVersion to a 4.x release in sst.config.ts.
Can you deploy SST to production from Windows?
Personal stages work, but with @opennextjs/aws 4.1.12 the image optimizer's native sharp dependency fails to install on Windows. Deploy production from Linux, for example GitHub Actions or WSL.
Do you need Route 53 to use a custom domain with SST?
No. SST's Cloudflare DNS adapter, sst.cloudflare.dns(), creates the certificate validation records and CNAMEs in a Cloudflare zone. Keep the records DNS-only, not proxied.