"Connect your repo and click Deploy."
That's the entire AWS Amplify pitch, and it's a lie of omission. The five-minute demo works great right up until you bring a real app. Server-side rendering, a package manager that isn't npm, a Node version that's slightly too new, environment variables that quietly disappear at runtime. Then you watch a build go green on your laptop and red in the cloud, and the error message links you to a troubleshooting doc that 404s.
We shipped a production Next.js 16 SSR app to Amplify across development, staging, and production. Between the demo and a working deploy, we hit most of the edge cases Amplify has to offer: a Node version it flatly refused to run, SSR environment variables that vanished at runtime, and a one-line pnpm error that took down a production deploy.
None of it is in the tutorial. So here's the whole map, happy path and every landmine, written as the post I wish I'd had open in the other tab.
What we're actually shipping
Quick cast of characters, because every problem below traces back to one of them:
Next.js 16 (App Router) and React 19, running in SSR mode, not a static export. That one distinction matters more than you'd expect.
pnpm for package management. Fast, strict, and the source of two separate landmines.
next-intl for localized routes like
/en/...and/fr/....Sentry for error tracking and readable production stack traces.
Three environments (dev, staging, production), each wired to its own git branch.
First, the fork in the road: SSR or static
Before anything else, get honest about which Next.js you're running. We're on SSR, not a static export. Amplify hosts both, but they behave like completely different products. Static is just files sitting on a CDN. SSR provisions real server-side compute that runs your app on every request.
Almost everything that bit us traces back to that fact. If you're doing a static export, you can close the tab after the next section. If you're on SSR, stick around, because the environment variable handling alone will save you an afternoon.
The one file that runs everything: amplify.yml
Drop an amplify.yml at your repo root and it becomes the brain of every build. Here's ours, trimmed:
version: 1
frontend:
phases:
preBuild:
commands:
- npm install -g pnpm
- pnpm install
- pnpm run lint
- pnpm run typecheck
build:
commands:
- env | grep -e NEXT_PUBLIC_ >> .env.production
# ...more env piping, see below
- pnpm run build
artifacts:
baseDirectory: .next
files:
- '**/*'
cache:
paths:
- .next/cache/**/*
- node_modules/**/*
Three deliberate choices are hiding in there:
Lint and typecheck run as build gates. Both happen in
preBuild, before the build itself. A type error or a lint failure kills the deploy instead of shipping broken code. It's cheap insurance and it pays out constantly.We cache
.next/cacheandnode_modulesto shave real minutes off every build after the first.baseDirectory: .nextbecause that's where Next.js drops its output.
Now the landmines.
Gotcha #1: the Node version nvm can't save you from
Our repo targets Node 24 (.nvmrc says v24). Amplify, as of today, only supports Node 16, 18, 20, and 22. Push a build expecting 24 and you get this:
CustomerError: Unsupported NodeJS version: v24.6.0.
Supported versions are 16, 18, 20 and 22.
The obvious move is to install Node 24 with nvm in preBuild. It doesn't work. Amplify validates the Node version itself and rejects it no matter what nvm pulls down. I lost the better part of an afternoon to this before I accepted it.
The real fix isn't in your code at all. It's a checkbox in the console:
Amplify Console → Hosting → Build settings → Build image settings → Edit → Live package updates → Node.js → set to 22.
Pin Node 22 there and the app runs fine. This is pure platform knowledge, the kind nothing in your repo will ever hint at, so we left a fat comment block in amplify.yml pointing the next person straight at it. If you want to follow the upstream thread, it's aws-amplify/amplify-hosting#4073.
Gotcha #2: the env vars that show up to build and ghost at runtime
⚠️ This is the one that cost us the most time: close to four hours, because the build kept succeeding and the app failed anyway.
On a static build, your NEXT_PUBLIC_* vars get inlined at build time and that's the end of it. On SSR, the server runtime needs them too, and Amplify's build-time environment variables don't automatically flow into the running Next.js server. You set them in the console, the build reads them fine, and then your live app reaches for them and finds nothing.
The workaround is a little ugly and completely reliable. In the build phase, grep the vars you need out of the environment and append them to the .env files Next bakes into the build:
build:
commands:
- env | grep -e NEXT_PUBLIC_ >> .env.production
- env | grep -e SENTRY_AUTH_TOKEN >> .env.production
- env | grep -e INTERNAL_API_SECRET >> .env.production
# ...same pattern for each prefix you need, repeated for .env.dev / .env.staging
- pnpm run build
In plain terms: pull everything matching those prefixes out of the live build environment, write it into the env files for each target, then build. Now both the client bundle and the SSR server have what they need.
Two things worth keeping straight:
List each prefix on purpose.
NEXT_PUBLIC_*is safe to ship to the browser. Secrets likeSENTRY_AUTH_TOKENand your internal API secret are server-side only, so never give one aNEXT_PUBLIC_prefix.AWS documents this under SSR environment variables. Worth reading, because "present at build, missing at runtime" is a genuinely baffling failure until you've seen it once.
Make missing config fail loud, not late
To dodge the "deployed fine, crashed in prod because one var was blank" trap, we validate the whole environment at build time with @t3-oss/env-nextjs and Zod:
export const env = createEnv({
server: {
SENTRY_AUTH_TOKEN: z.string().min(1),
WIDGET_SCRIPT: z.string(),
INTERNAL_API_SECRET: z.string().min(1),
// ...
},
client: {
NEXT_PUBLIC_API_END_POINT: z.string().url(),
NEXT_PUBLIC_APP_ENV: z.union([/* production | staging | development | localhost */]),
// ...
},
});
A missing or malformed var now fails the build with a clear message instead of blowing up in production. Put that on top of the lint and typecheck gates and broken config simply can't reach a live URL.
One default, surgical overrides
In the Amplify console (Hosting → Environment Variables → Manage Variables) you set vars per environment. We use "All branches" as the default, which doubles as production, then add per-branch overrides for develop and staging only where they actually differ. One source of truth, with a scalpel for the exceptions.
Replacing Amplify's auto-builds with GitHub Actions
The first two were platform landmines. This one is a deliberate choice, and it's worth explaining because it shaped everything downstream.
Amplify can auto-build on every push. We turned that off. Instead, GitHub Actions triggers each deploy explicitly through the AWS CLI. That buys us three things: control over when a branch ships, real pass/fail exit codes in CI, and somewhere to bolt on automation.
Every environment gets its own workflow (.github/workflows/deploy-{dev,staging,main}.yml). The spine of it:
on:
push:
branches: [main] # or develop / staging
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_BUCKET_REGION }}
- uses: actions/checkout@v5
- name: Deploy
env:
APP_ID: ${{ secrets.AWS_APP_ID }}
run: ./scripts/amplify-deploy.sh $APP_ID main
And the script it calls. This is the part that makes CI actually wait for the deploy and report the truth instead of firing and forgetting:
JOB_ID=$(aws amplify start-job --app-id $1 --branch-name $2 --job-type RELEASE \
| jq -r '.jobSummary.jobId')
# poll until the job leaves PENDING/RUNNING
while [[ "$(aws amplify get-job --app-id $1 --branch-name $2 --job-id $JOB_ID \
| jq -r '.job.summary.status')" =~ ^(PENDING|RUNNING)$ ]]; do sleep 1; done
JOB_STATUS="$(aws amplify get-job --app-id $1 --branch-name $2 --job-id $JOB_ID \
| jq -r '.job.summary.status')"
if [ "$JOB_STATUS" != "SUCCEED" ]; then exit 1; fi
Why bother instead of flipping the auto-build toggle?
Real exit codes. The script polls
get-jobuntil the release leavesPENDINGorRUNNING, then exits non-zero unless the final status isSUCCEED. A failed deploy shows up as a red X in GitHub instead of a surprise you find later.You own the trigger. Deploys fire on pushes to specific branches, on your terms.
Somewhere to hang automation, which is exactly where the next two pieces plug in.
Gotcha #3: the one-line pnpm error that killed a production deploy
This one actually took prod down, and the error buries the lede. Mid-build, the logs coughed up:
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: @parcel/watcher@2.5.6,
@sentry/cli@2.58.4, @swc/core@1.15.11, esbuild@0.27.3, sharp@0.34.5, unrs-resolver@1.11.1
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
...
!!! Build failed
!!! Error: Command failed with exit code 1
Here's the trap. As of pnpm 10, and still in 11, pnpm no longer runs the postinstall and build scripts of your dependencies by default. It's a supply-chain hardening move. Packages with native build steps (sharp, esbuild, @swc/core, @sentry/cli, @parcel/watcher, unrs-resolver) get installed but never built. On your laptop you don't notice, because you approved them interactively months ago. In a clean CI box like Amplify, the build face-plants.
The interactive fix, pnpm approve-builds, is useless in CI because there's no TTY to answer the prompt. The durable fix is to commit the approval to the repo by declaring the allowed builds in pnpm-workspace.yaml:
allowBuilds:
'@parcel/watcher': true
'@sentry/cli': true
'@swc/core': true
esbuild: true
sharp: true
unrs-resolver: true
Now pnpm runs exactly those build scripts everywhere, laptops and CI alike, with no prompt. The pnpm migration guide covers the wider set of behavior changes if you're jumping a major version, but this is the one most likely to surface as a green-locally, red-in-CI deploy.
Heads up: the field name has drifted across pnpm versions (
onlyBuiltDependenciesin some,allowBuildsin others). Check what your exact version expects. Paste the wrong key and it quietly does nothing, and you're back to the same failed build.
Bonus: readable stack traces instead of minified soup
Our next.config.ts wraps the config in withSentryConfig. During the Amplify build, source maps upload to Sentry using SENTRY_AUTH_TOKEN, and only when CI is set, so local builds stay quiet:
export default withSentryConfig(nextWithIntl, {
org: 'your-org',
project: 'your-project',
authToken: env.SENTRY_AUTH_TOKEN,
silent: !process.env.CI,
widenClientFileUpload: true,
});
The payoff is production stack traces that point at real source lines instead of minified gibberish. It's also why SENTRY_AUTH_TOKEN is one of the vars we pipe into the .env files back in Gotcha #2. The whole thing is connected.
The cheat sheet
Putting a Next.js SSR app on Amplify? Tape this above your monitor:
Know whether you're on SSR or static. It explains everything else.
Pin Node 22 via Live Package Updates in the console, not the build spec. Amplify won't run 24, and nvm can't force it.
Pipe your env vars into
.env.*files in the build phase. The SSR runtime won't get them otherwise.Validate env vars at build with Zod, so missing config fails loud instead of in prod.
Declare
allowBuildsinpnpm-workspace.yaml. pnpm 10+ won't run dependency build scripts in CI, andpnpm approve-buildscan't help a non-interactive box.Gate the build on lint and typecheck so broken code can't ship.
Drive deploys from GitHub Actions and the AWS CLI, polling
get-jobfor an honest pass/fail.
Amplify is a perfectly capable home for a Next.js SSR app. The catch is that the official onboarding stops at the happy path, and real workloads live well past it: Node versions it won't run, an SSR runtime that wants its own env vars, a package manager with opinions, and deploy orchestration you have to build yourself. We already paid for those debugging sessions.
Hopefully now they cost you nothing.
Top comments (0)