April 24, 2026 · 3 min read
Wiring Up SES for a Contact Form on Amplify + Next.js
Wiring Up SES for a Contact Form on Amplify + Next.js
A portfolio contact form sounds like a twenty-minute task. It took longer. Here's what actually tripped me up deploying Amazon SES with a Next.js Server Action on Amplify Hosting (WEB_COMPUTE).
The Setup
A simple Server Action calling @aws-sdk/client-ses: no Lambda, no API route, just a form posting to a server-side function that calls SendEmailCommand. The goal: visitor submits the form, SES sends the message to my personal Proton Mail inbox (bmajeske@proton.me).
const ses = new SESClient({ region: 'us-west-2' })
export async function sendContactEmail(_prev: State, formData: FormData) {
await ses.send(new SendEmailCommand({
Source: 'contact@brandanmajeske.com',
Destination: { ToAddresses: ['bmajeske@proton.me'] },
Message: { ... },
}))
}
Looks fine. It isn't.
Gotcha 1: Amplify SSR Has No AWS Credentials
Amplify Hosting's SSR compute (WEB_COMPUTE) does not inherit an IAM execution role the way a Lambda function does. The AWS SDK credential chain finds nothing and throws:
CredentialsProviderError: Could not load credentials from any providers
Fix: Create a dedicated IAM user with the minimum required policy:
{
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
"Resource": "*",
"Condition": {
"StringEquals": { "ses:FromAddress": "contact@yourdomain.com" }
}
}
Generate access keys for that user, then pass them explicitly to the SDK client:
const ses = new SESClient({
region: 'us-west-2',
credentials: {
accessKeyId: process.env.SES_ACCESS_KEY_ID!,
secretAccessKey: process.env.SES_SECRET_ACCESS_KEY!,
},
})
Gotcha 2: Amplify Env Vars Don't Reach the SSR Runtime
You add SES_ACCESS_KEY_ID in the Amplify console. You redeploy. Still broken:
Error: Resolved credential object is not valid
Environment variables set in Amplify Hosting are available at build time, not automatically injected into the SSR Lambda runtime. The workaround is to write them to .env.production during the build phase in amplify.yml:
build:
commands:
- env | grep -e SES_ACCESS_KEY_ID >> .env.production || true
- env | grep -e SES_SECRET_ACCESS_KEY >> .env.production || true
- npm run build
The || true is necessary. grep exits with code 1 on no match, which kills the build.
Security caveat: This approach bakes credential values into the compiled JS bundle in .next/server/, which lives in Amplify's S3 deployment bucket as plaintext. It is only appropriate when the credentials are tightly scoped. In this case, a dedicated IAM user with a single ses:SendEmail permission conditioned on a specific from-address. Do not use this pattern with broad IAM credentials. For production systems, fetch secrets from AWS Secrets Manager or SSM Parameter Store at runtime instead.
Gotcha 3: Initialize the Client Inside the Handler
Module-level SDK initialization runs at cold start, before environment variables are injected. Move the SESClient constructor inside the handler function so it reads process.env at request time.
Gotcha 4: SES Sandbox Requires Verified Recipients
SES starts in sandbox mode. You can only send to verified email addresses. For a contact form where you're always the recipient, this is easy to fix. Just verify your own inbox:
aws sesv2 create-email-identity --email-identity bmajeske@proton.me
Click the verification link. Done. No production access request needed for a personal contact form.
The Working Configuration
Four things have to be true simultaneously: an IAM user scoped to ses:SendEmail on your from-address, Amplify env vars written to .env.production during the build phase, the SES client constructed inside the handler (not at module level), and a verified destination address for sandbox mode.
Get all four right and it works reliably. Miss any one and the error message won't tell you which.