Deploy Next.js to AWS with SST Ion: Complete Serverless Guide

Introduction
Deploying a Next.js app to AWS has traditionally meant stitching together CloudFront, Lambda@Edge, S3, and a pile of Terraform or CloudFormation YAML. It works — but it is slow, fragile, and painful to iterate on.
SST Ion (v3) changes the game. It is an open-source framework that lets you define your entire AWS infrastructure in TypeScript, right alongside your application code. One file, one language, zero YAML. It deploys your Next.js app to CloudFront + S3 + Lambda using OpenNext under the hood, giving you a production-grade serverless setup with a single command.
In this tutorial, you will learn how to:
- Initialize SST in a Next.js project
- Deploy to AWS with CloudFront, S3, and Lambda
- Add S3 file uploads with presigned URLs
- Integrate DynamoDB for serverless data storage
- Use live Lambda development for instant feedback
- Set up custom domains and production stages
- Manage multiple environments (dev, staging, production)
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed
- AWS CLI configured with credentials (
aws configure) - An AWS account with admin permissions (or at least IAM, S3, CloudFront, Lambda, DynamoDB access)
- Basic knowledge of Next.js App Router and TypeScript
- A code editor (VS Code recommended)
SST deploys real AWS resources that may incur costs. The resources in this tutorial stay within AWS Free Tier for most use cases, but always monitor your billing dashboard.
What You Will Build
By the end of this tutorial, you will have:
- A Next.js app deployed to AWS via CloudFront (global CDN)
- S3-backed file uploads with presigned URLs
- A DynamoDB table for storing data
- Live Lambda dev environment with sub-second reloads
- Separate staging and production environments
- A custom domain with automatic SSL
Step 1: Create the Next.js Project
Start by creating a fresh Next.js application:
npx create-next-app@latest sst-nextjs-app
cd sst-nextjs-appSelect the following options when prompted:
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: Yes
src/directory: Yes- App Router: Yes
- Import alias: @/*
Step 2: Initialize SST
Install SST in the project:
npx sst@latest initWhen prompted, select aws as your provider. This generates an sst.config.ts file in your project root:
/// <reference path="./.sst/platform/config.d.ts" />
export default $config({
app(input) {
return {
name: "sst-nextjs-app",
removal: input?.stage === "production" ? "retain" : "remove",
protect: ["production"].includes(input?.stage),
home: "aws",
};
},
async run() {
new sst.aws.Nextjs("MyWeb");
},
});Let us break down what is happening here:
app()configures the app name, removal policy, and home providerremoval: "retain"keeps resources when removing a production stage (safety net)protectprevents accidental deletion of production resourcesasync run()defines your infrastructure — currently just a Next.js sitesst.aws.Nextjsdeploys your app using OpenNext (CloudFront + S3 + Lambda)
SST also creates a .sst/ directory for types and state. Add it to .gitignore:
echo ".sst" >> .gitignoreStep 3: Deploy to AWS (First Deploy)
Deploy the app to a development stage:
npx sst deploy --stage devThe first deployment takes 3-5 minutes as it provisions CloudFront, S3, and Lambda. Subsequent deploys are much faster. When complete, SST outputs your CloudFront URL:
✓ Complete
MyWeb: https://d1234abcd.cloudfront.net
Open the URL — your Next.js app is live on AWS.
Each stage gets its own isolated set of AWS resources. You can create as many stages as you need: dev, staging, production, pr-123, etc.
Step 4: Add S3 File Uploads
Let us add an S3 bucket for file uploads and link it to the Next.js app.
4.1 Update the SST Config
Edit sst.config.ts to add a public S3 bucket:
/// <reference path="./.sst/platform/config.d.ts" />
export default $config({
app(input) {
return {
name: "sst-nextjs-app",
removal: input?.stage === "production" ? "retain" : "remove",
protect: ["production"].includes(input?.stage),
home: "aws",
};
},
async run() {
const bucket = new sst.aws.Bucket("Uploads", {
access: "public",
});
new sst.aws.Nextjs("MyWeb", {
link: [bucket],
});
return {
bucket: bucket.name,
};
},
});The link property is the magic of SST. It grants your Next.js server functions IAM permissions to the bucket and injects the bucket name as an environment variable — no manual IAM policies or .env files needed.
4.2 Install the AWS SDK and SST SDK
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner sst4.3 Create the Upload API Route
Create a Server Action that generates a presigned URL for direct-to-S3 uploads:
// src/app/api/upload/route.ts
import { Resource } from "sst";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { NextResponse } from "next/server";
const s3 = new S3Client({});
export async function POST(request: Request) {
const { filename, contentType } = await request.json();
const key = `uploads/${Date.now()}-${filename}`;
const command = new PutObjectCommand({
Bucket: Resource.Uploads.name,
Key: key,
ContentType: contentType,
});
const presignedUrl = await getSignedUrl(s3, command, {
expiresIn: 3600,
});
return NextResponse.json({
presignedUrl,
key,
publicUrl: `https://${Resource.Uploads.name}.s3.amazonaws.com/${key}`,
});
}Notice Resource.Uploads.name — SST automatically injects the linked bucket name. No hardcoded values, no environment variables to manage.
4.4 Create the Upload UI
// src/app/upload/page.tsx
"use client";
import { useState } from "react";
export default function UploadPage() {
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null);
async function handleUpload() {
if (!file) return;
setUploading(true);
try {
// Get presigned URL from our API
const res = await fetch("/api/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
}),
});
const { presignedUrl, publicUrl } = await res.json();
// Upload directly to S3
await fetch(presignedUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
setUploadedUrl(publicUrl);
} catch (error) {
console.error("Upload failed:", error);
} finally {
setUploading(false);
}
}
return (
<div className="max-w-md mx-auto p-8">
<h1 className="text-2xl font-bold mb-4">File Upload</h1>
<input
type="file"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="mb-4 block w-full"
/>
<button
onClick={handleUpload}
disabled={!file || uploading}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{uploading ? "Uploading..." : "Upload to S3"}
</button>
{uploadedUrl && (
<div className="mt-4 p-4 bg-green-50 rounded">
<p className="text-sm text-green-800">Upload successful!</p>
<a
href={uploadedUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline text-sm break-all"
>
{uploadedUrl}
</a>
</div>
)}
</div>
);
}Step 5: Add DynamoDB for Data Storage
5.1 Define the Table in SST Config
Update sst.config.ts to add a DynamoDB table:
/// <reference path="./.sst/platform/config.d.ts" />
export default $config({
app(input) {
return {
name: "sst-nextjs-app",
removal: input?.stage === "production" ? "retain" : "remove",
protect: ["production"].includes(input?.stage),
home: "aws",
};
},
async run() {
const bucket = new sst.aws.Bucket("Uploads", {
access: "public",
});
const table = new sst.aws.Dynamo("Notes", {
fields: {
userId: "string",
noteId: "string",
},
primaryIndex: { hashKey: "userId", rangeKey: "noteId" },
});
new sst.aws.Nextjs("MyWeb", {
link: [bucket, table],
});
return {
bucket: bucket.name,
table: table.name,
};
},
});5.2 Install the DynamoDB Client
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb5.3 Create the Notes API
// src/app/api/notes/route.ts
import { Resource } from "sst";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
PutCommand,
QueryCommand,
} from "@aws-sdk/lib-dynamodb";
import { NextResponse } from "next/server";
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId") || "anonymous";
const result = await client.send(
new QueryCommand({
TableName: Resource.Notes.name,
KeyConditionExpression: "userId = :userId",
ExpressionAttributeValues: { ":userId": userId },
})
);
return NextResponse.json({ notes: result.Items || [] });
}
export async function POST(request: Request) {
const { userId = "anonymous", title, content } = await request.json();
const note = {
userId,
noteId: `note_${Date.now()}`,
title,
content,
createdAt: new Date().toISOString(),
};
await client.send(
new PutCommand({
TableName: Resource.Notes.name,
Item: note,
})
);
return NextResponse.json({ note });
}5.4 Create the Notes Page
// src/app/notes/page.tsx
"use client";
import { useEffect, useState } from "react";
interface Note {
noteId: string;
title: string;
content: string;
createdAt: string;
}
export default function NotesPage() {
const [notes, setNotes] = useState<Note[]>([]);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
async function loadNotes() {
const res = await fetch("/api/notes?userId=anonymous");
const data = await res.json();
setNotes(data.notes);
}
async function createNote() {
if (!title || !content) return;
await fetch("/api/notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content }),
});
setTitle("");
setContent("");
loadNotes();
}
useEffect(() => {
loadNotes();
}, []);
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-6">Serverless Notes</h1>
<div className="mb-8 space-y-3">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Note title"
className="w-full border rounded px-3 py-2"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Note content"
className="w-full border rounded px-3 py-2 h-24"
/>
<button
onClick={createNote}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
Add Note
</button>
</div>
<div className="space-y-4">
{notes.map((note) => (
<div key={note.noteId} className="border rounded p-4">
<h2 className="font-semibold">{note.title}</h2>
<p className="text-gray-600 mt-1">{note.content}</p>
<p className="text-xs text-gray-400 mt-2">{note.createdAt}</p>
</div>
))}
</div>
</div>
);
}Step 6: Live Lambda Development
This is where SST truly shines. Instead of redeploying every time you change server code, sst dev proxies Lambda invocations to your local machine in real time.
Start the dev environment:
npx sst devSST does three things simultaneously:
- Deploys infrastructure to AWS (S3, DynamoDB, CloudFront)
- Proxies Lambda requests to your local machine (sub-second hot reload)
- Starts the Next.js dev server at
localhost:3000
Make a change to an API route, save the file, and refresh — the change is live instantly. No redeploy, no waiting. This is possible because SST replaces the Lambda function with a stub that forwards invocations to your local machine over a WebSocket connection.
Live Lambda dev requires your AWS credentials to be configured. SST creates a lightweight IoT WebSocket connection for proxying — your code runs locally but accesses real AWS resources.
Step 7: Custom Domain Configuration
7.1 Using Route 53
If your domain is managed by Route 53, add the domain option to the Nextjs component:
new sst.aws.Nextjs("MyWeb", {
link: [bucket, table],
domain: {
name: "app.yourdomain.com",
dns: sst.aws.dns(),
},
});SST automatically creates the Route 53 records and provisions an SSL certificate via ACM.
7.2 Using Cloudflare DNS
For domains managed by Cloudflare:
new sst.aws.Nextjs("MyWeb", {
link: [bucket, table],
domain: {
name: "app.yourdomain.com",
dns: sst.cloudflare.dns(),
},
});7.3 Using External DNS
For other providers, SST outputs the required DNS records and waits for you to configure them:
new sst.aws.Nextjs("MyWeb", {
link: [bucket, table],
domain: {
name: "app.yourdomain.com",
dns: false,
},
});Step 8: Environment-Based Configuration
SST stages let you create isolated environments. Use the $app.stage variable to configure resources differently per stage:
async run() {
const isProd = $app.stage === "production";
const bucket = new sst.aws.Bucket("Uploads", {
access: "public",
});
const table = new sst.aws.Dynamo("Notes", {
fields: {
userId: "string",
noteId: "string",
},
primaryIndex: { hashKey: "userId", rangeKey: "noteId" },
// Enable point-in-time recovery for production
pointInTimeRecovery: isProd,
});
new sst.aws.Nextjs("MyWeb", {
link: [bucket, table],
domain: isProd
? { name: "app.yourdomain.com", dns: sst.aws.dns() }
: undefined,
server: {
memory: isProd ? "1024 MB" : "512 MB",
},
});
}Deploy to different stages:
# Development
npx sst deploy --stage dev
# Staging
npx sst deploy --stage staging
# Production
npx sst deploy --stage productionEach stage creates completely isolated resources — separate S3 buckets, DynamoDB tables, CloudFront distributions, and Lambda functions.
Step 9: Add a Cron Job
SST makes it easy to add scheduled tasks. Let us add a cron job that cleans up old uploads:
// Add to sst.config.ts inside async run()
new sst.aws.Cron("CleanupOldUploads", {
schedule: "rate(1 day)",
job: {
handler: "src/functions/cleanup.handler",
link: [bucket],
},
});Create the handler:
// src/functions/cleanup.ts
import { Resource } from "sst";
import {
S3Client,
ListObjectsV2Command,
DeleteObjectsCommand,
} from "@aws-sdk/client-s3";
const s3 = new S3Client({});
const DAYS_TO_KEEP = 30;
export async function handler() {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - DAYS_TO_KEEP);
const objects = await s3.send(
new ListObjectsV2Command({
Bucket: Resource.Uploads.name,
Prefix: "uploads/",
})
);
const toDelete = (objects.Contents || [])
.filter((obj) => obj.LastModified && obj.LastModified < cutoff)
.map((obj) => ({ Key: obj.Key! }));
if (toDelete.length > 0) {
await s3.send(
new DeleteObjectsCommand({
Bucket: Resource.Uploads.name,
Delete: { Objects: toDelete },
})
);
console.log(`Deleted ${toDelete.length} old uploads`);
}
return { deleted: toDelete.length };
}Step 10: Production Deployment
10.1 Final SST Config
Here is the complete sst.config.ts with all resources:
/// <reference path="./.sst/platform/config.d.ts" />
export default $config({
app(input) {
return {
name: "sst-nextjs-app",
removal: input?.stage === "production" ? "retain" : "remove",
protect: ["production"].includes(input?.stage),
home: "aws",
};
},
async run() {
const isProd = $app.stage === "production";
const bucket = new sst.aws.Bucket("Uploads", {
access: "public",
});
const table = new sst.aws.Dynamo("Notes", {
fields: {
userId: "string",
noteId: "string",
},
primaryIndex: { hashKey: "userId", rangeKey: "noteId" },
pointInTimeRecovery: isProd,
});
new sst.aws.Nextjs("MyWeb", {
link: [bucket, table],
domain: isProd
? { name: "app.yourdomain.com", dns: sst.aws.dns() }
: undefined,
server: {
memory: isProd ? "1024 MB" : "512 MB",
architecture: "arm64",
},
});
new sst.aws.Cron("CleanupOldUploads", {
schedule: "rate(1 day)",
job: {
handler: "src/functions/cleanup.handler",
link: [bucket],
},
});
return {
bucket: bucket.name,
table: table.name,
};
},
});10.2 Deploy to Production
npx sst deploy --stage production10.3 Monitor Your Deployment
SST outputs all resource details after deployment. You can also view them in the AWS Console:
- CloudFront — your CDN distribution and domain
- S3 — your upload bucket and static assets
- Lambda — your server functions
- DynamoDB — your data table
10.4 Remove a Stage
To tear down a non-production stage and delete all its resources:
npx sst remove --stage devProduction stages with protect: true cannot be removed accidentally. You must first set protect to false in the config before removing.
Troubleshooting
"Cannot find module 'sst'" in Next.js
Make sure you installed the sst package:
npm install sstFirst deploy is slow
The initial deployment provisions a CloudFront distribution, which takes 3-5 minutes. Subsequent deploys are much faster (typically under 60 seconds).
"Access Denied" errors
Ensure your AWS credentials have sufficient permissions. SST needs access to IAM, S3, CloudFront, Lambda, DynamoDB, CloudWatch, and SSM at minimum. Using an admin role for development is recommended.
Live dev not connecting
Check that your AWS credentials are valid and that port 443 outbound is not blocked by your firewall. SST uses AWS IoT Core for the WebSocket connection.
SST vs Other Deployment Options
| Feature | SST Ion | Vercel | AWS CDK | Terraform |
|---|---|---|---|---|
| Language | TypeScript | N/A (UI) | TypeScript | HCL |
| Live dev | Yes (sub-second) | No | No | No |
| Resource linking | Automatic | Manual env vars | Manual | Manual |
| Cost | AWS pricing only | Per-seat + usage | AWS pricing | AWS pricing |
| Next.js support | Full (OpenNext) | Native | Manual | Manual |
| Multi-provider | 150+ providers | Vercel only | AWS only | 150+ |
Next Steps
Now that you have a full-stack Next.js app on AWS, consider:
- Add authentication with
sst.aws.Cognitoor integrate NextAuth.js - Set up a CI/CD pipeline with GitHub Actions running
npx sst deploy --stage production - Add an API Gateway with
sst.aws.ApiGatewayV2for standalone APIs - Explore SST Console at
console.sst.devfor a visual dashboard of your resources - Add a queue with
sst.aws.Queuefor background processing
Conclusion
SST Ion transforms AWS deployment from a DevOps headache into a developer-friendly experience. By defining infrastructure in TypeScript alongside your application code, you get type safety, resource linking, and live development — all while deploying to your own AWS account with no vendor markup.
The key takeaways:
- Infrastructure as Code in TypeScript — no YAML, no separate config files
- Resource linking eliminates manual IAM policies and environment variables
- Live Lambda dev provides instant feedback without redeployment
- Stages give you isolated environments for free
- OpenNext deploys Next.js to AWS with full feature support
SST lets you own your infrastructure without the traditional AWS complexity. Start with npx sst@latest init and deploy your first app in minutes.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build Durable Functions and Event-Driven Workflows with Inngest and Next.js
Learn how to build reliable, event-driven workflows with Inngest and Next.js. This tutorial covers durable functions, step-based workflows, fan-out patterns, retries, scheduling, and production deployment.

Neon Serverless Postgres with Next.js App Router: Build a Full-Stack App with Database Branching
Learn how to build a full-stack Next.js application powered by Neon serverless Postgres. This tutorial covers the Neon serverless driver, database branching for preview deployments, connection pooling, and production-ready patterns.

Build Production Background Jobs with Trigger.dev v3 and Next.js
Learn how to build reliable background jobs, scheduled tasks, and multi-step workflows using Trigger.dev v3 with Next.js. This tutorial covers task creation, error handling, retries, scheduled cron jobs, and deploying to production.