Skip to main content
ASoc
Tutorial

Next.js on AWS: Amplify, Lambda, ECS or EC2 — 8 Routes Decide It

Amplify, Lambda via SST, ECS Fargate, or EC2 — AWS has no framework-aware default. This build's own 428 static and 8 Node-runtime dynamic routes decide which compute layer actually fits.

The ASoc Team10 min read

Deploying Next.js to AWS means picking a compute layer for your dynamic routes — Amplify Hosting, Lambda behind API Gateway (directly or via SST/OpenNext), ECS Fargate, or a plain EC2 box — because AWS itself has no framework-aware default the way Vercel does. This build's own route census makes the decision concrete: 436 static routes need no compute at all, and the 8 dynamic ones all pin the Node.js runtime, which rules out a couple of the cheaper options before you compare pricing.

The route split that decides the compute layer

A fresh production build of this storefront:

Route (app)
┌ ○ /                          (static)
├ ● /templates/[slug]          (static, 111 pages)
├ ● /blog/[slug]                (static, 141 pages)
├ ƒ /dashboard                  (dynamic)
├ ƒ /api/download                (dynamic)
├ ƒ /api/webhooks/lemonsqueezy   (dynamic)
└ ... 436 static, 8 dynamic total

○ and ● routes are HTML generated at build time — any static host or CDN serves them with zero AWS compute. The 8 ƒ routes (the dashboard, auth callback, login/signup/password-reset forms, and two API routes) run per request, and that's the half of the deployment that actually needs a decision.

Two of those eight pin their runtime explicitly, and the reason is the deciding factor for AWS specifically:

// src/app/api/webhooks/lemonsqueezy/route.ts
// Node runtime (default for Route Handlers) — required for `node:crypto`.
export const runtime = "nodejs";

node:crypto verifies the webhook's HMAC signature. That single line rules out any AWS option whose compute layer doesn't run the full Node.js runtime — which matters because two of AWS's own deployment paths (edge-oriented Lambda@Edge and CloudFront Functions) don't.

The AWS options, compared on what they need to replicate

Amplify HostingLambda + API Gateway (SST/OpenNext)ECS FargateEC2
Infers static vs. dynamic per routeYes — supports Next.js SSR nativelyNo — OpenNext's build step does the split, you deploy its outputNo — you decide what runs in the containerNo — you decide what runs on the box
Runs runtime = "nodejs" routesYesYes (standard Lambda, not Lambda@Edge)Yes — it's a container, full NodeYes — full Node
Cold starts on the 8 dynamic routesManaged, not eliminatedReal factor — first request after idle pays a cold-start penaltyNone once the task is runningNone once the box is running
Bundled backend on offerCognito, AppSync, DynamoDB — see Vercel vs. AWS Amplify for the full comparisonNone — bring your ownNone — bring your ownNone — bring your own
Ops surfaceAmplify console + amplify.ymlIaC (SST config or raw CDK/Terraform)Task definitions, a cluster, a load balancerEverything — patching, scaling, TLS
Fits this site's traffic on the free/cheap tierYesYes — pay-per-invocation suits low-traffic dynamic routesNo — Fargate bills for running tasks regardless of trafficNo — an idle box still costs its hourly rate

Amplify Hosting is the only one on this list that infers the static/dynamic split the way Vercel does; the rest require you to either run a build adapter (OpenNext for Lambda) or decide the split yourself (a container runs whatever you put in it, static or not). None of that changes which routes need Node — it changes how much configuration you write to get them there.

Deploying with SST (Lambda + API Gateway)

The shape that best fits a low-traffic dynamic set like this one — 8 routes, mostly auth and one webhook — is pay-per-invocation Lambda rather than an always-on container:

npx create-sst@latest
# select the Next.js template, point it at this repo
npx sst deploy --stage production

SST's Next.js construct runs OpenNext's build step, which splits the .next output into: static assets to S3 behind CloudFront, and each dynamic route to its own Lambda function behind API Gateway. The webhook route's runtime = "nodejs" pin needs no extra configuration here — standard Lambda already runs full Node; the pin only matters if you'd reached for an edge-optimized option instead.

Deploying to ECS Fargate

For traffic where cold starts are unacceptable — this site's 8 dynamic routes don't have that requirement, but many do — a container sidesteps Lambda's cold-start question entirely:

FROM node:20-alpine AS runner
WORKDIR /app
COPY .next/standalone ./
COPY .next/static ./.next/static
COPY public ./public
EXPOSE 3000
CMD ["node", "server.js"]

output: "standalone" in next.config.ts produces a self-contained server bundle sized for exactly this — the same eight dynamic routes run inside the container, and static assets still route through CloudFront in front of it rather than through the app server. The tradeoff against Lambda is direct: Fargate bills for the task running, not per request, so it costs more at this traffic level and less once traffic is high and steady.

What the choice actually costs at this traffic level

None of the options above are free once real usage starts, but they fail toward different costs. Lambda and Amplify Hosting both bill close to zero when a dynamic route isn't being hit — the 8 dynamic routes here (auth forms, the dashboard, two API routes) see nowhere near the traffic the 436 static routes do, so pay-per-invocation pricing matches the actual shape of the load. Fargate and EC2 invert that: a task or a box costs its hourly rate whether a dynamic route was called once or a thousand times in that hour, which only pays off once dynamic traffic is high and steady enough to keep the compute busy.

The static half of the site is nearly free everywhere. S3 storage and CloudFront bandwidth for 436 prerendered routes cost fractions of a cent at this site's traffic; the number that actually varies between the four options is what happens to the other 8 routes when nobody is calling them.

What each option asks you to own

Vercel's zero-config deploy is also zero-config in the other direction — there's nothing to lock down beyond the platform's own account security, because there's no infrastructure surface exposed. Every AWS option above adds one: Amplify still runs your build in an IAM role you control the permissions of; SST-deployed Lambda functions each get their own execution role, scoped by whatever the SST config grants; ECS Fargate needs a task role, a security group, and — if it's reachable from the internet — a load balancer with its own listener rules; EC2 needs all of that plus OS patching, since there's no managed runtime underneath it. None of that is a reason to avoid AWS — it's the tradeoff for the pricing and control the platform-native options can't offer — but it's real ongoing ownership a Vercel deploy doesn't carry, and it belongs in the same comparison as the pricing table above rather than as a footnote.

Troubleshooting

SymptomCauseFix
A Route Handler works locally, 500s on LambdaDeployed to an edge-optimized Lambda variant that doesn't bundle node:crypto or another Node built-inDeploy as standard (regional) Lambda, not Lambda@Edge; SST's default Next.js construct already does this
Static pages serve stale content after a deployS3 objects updated but the CloudFront distribution wasn't invalidatedIssue a CloudFront invalidation as part of the deploy step, or use SST's built-in invalidation
The webhook route times out under loadA cold Lambda start plus a slow HMAC verification path exceeds API Gateway's default timeoutProvisioned concurrency for that one function, or raise the API Gateway integration timeout
output: "standalone" build is missing node_modules at runtimeThe Docker build only copied .next/standalone, which excludes some native deps by designCopy .next/standalone's own bundled node_modules, not the project root's
Env vars work in next dev but not on Lambda/Fargate.env.local never leaves your machineSet each var in the platform's own config (SST's Config, ECS task definition env, or Amplify's console)
Static and dynamic routes disagree on which host serves themNo single entry point was configured to route between S3/CloudFront (static) and the compute layer (dynamic)Amplify and SST's OpenNext construct both wire this automatically; a hand-rolled ECS/EC2 setup needs a CloudFront behavior per path pattern

FAQ

Is Amplify Hosting the same thing as "AWS," or is it one option among several? One option among several. Amplify infers the static/dynamic split the way Vercel does; Lambda, ECS and EC2 all require you to configure that split yourself, in exchange for more control over cost and runtime.

Do I need Lambda@Edge for a Next.js app on AWS? Usually not, and it's a real trap: Lambda@Edge and CloudFront Functions run restricted JavaScript runtimes that don't support every Node built-in. Standard (regional) Lambda behind API Gateway runs the full runtime this app's webhook route needs.

What's cheapest for a site with mostly static routes and a handful of dynamic ones? Pay-per-invocation Lambda (via SST/OpenNext) or Amplify Hosting — both scale cost with actual dynamic-route traffic. ECS Fargate and EC2 bill for uptime regardless of traffic, which is the wrong shape for a site where 436 of 444 routes never hit compute at all.

How does this compare to just using Vercel? Vercel infers the same static/dynamic split with zero configuration, because it's built by the team that builds Next.js. The AWS options above can match that, but each one trades some setup work for AWS-specific pricing, IAM, or backend bundling — see Vercel vs. AWS Amplify for that specific tradeoff measured against this build.

Templates in this post

ASoc Hearth is a smart-home landing page, ASoc Ignite an AI-app template, and ASoc Iris a computer-vision AI landing page — three of the 66 landing templates this same route split applies to unchanged, whichever AWS option you deploy to.

Browse the full set: Next.js landing page templates and Tailwind landing page templates. For the platform-level version of this decision, see Vercel vs. AWS Amplify and GitHub Pages alternative.

Keep reading

Tutorial10 min read

The Next.js Bundle Analyzer Doesn't Run on Turbopack. Here's What Does.

ANALYZE=true writes nothing in Next 16, and the route table no longer prints First Load JS. The replacement, where it hides its output, and the 42 KB one constant was costing twelve routes.

Read more
Tutorial7 min read

A Next.js Calendar Grid, and the Timezone Bug It Has to Avoid

No calendar UI here, but this codebase already fixed the timezone bug that breaks most of them, twice. The UTC-safe date pattern, applied to a month grid.

Read more
Tutorial8 min read

Next.js Charts: The Bill Isn't the Library, It's the Boundary

Every chart library is a client component. What that actually costs, measured here: 42 KB gzipped across twelve routes, from a five-line constant nobody suspected.

Read more