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

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

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-app

Select 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 init

When 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 provider
  • removal: "retain" keeps resources when removing a production stage (safety net)
  • protect prevents accidental deletion of production resources
  • async run() defines your infrastructure — currently just a Next.js site
  • sst.aws.Nextjs deploys your app using OpenNext (CloudFront + S3 + Lambda)

SST also creates a .sst/ directory for types and state. Add it to .gitignore:

echo ".sst" >> .gitignore

Step 3: Deploy to AWS (First Deploy)

Deploy the app to a development stage:

npx sst deploy --stage dev

The 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 sst

4.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-dynamodb

5.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 dev

SST does three things simultaneously:

  1. Deploys infrastructure to AWS (S3, DynamoDB, CloudFront)
  2. Proxies Lambda requests to your local machine (sub-second hot reload)
  3. 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 production

Each 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 production

10.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 dev

Production 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 sst

First 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

FeatureSST IonVercelAWS CDKTerraform
LanguageTypeScriptN/A (UI)TypeScriptHCL
Live devYes (sub-second)NoNoNo
Resource linkingAutomaticManual env varsManualManual
CostAWS pricing onlyPer-seat + usageAWS pricingAWS pricing
Next.js supportFull (OpenNext)NativeManualManual
Multi-provider150+ providersVercel onlyAWS only150+

Next Steps

Now that you have a full-stack Next.js app on AWS, consider:

  • Add authentication with sst.aws.Cognito or 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.ApiGatewayV2 for standalone APIs
  • Explore SST Console at console.sst.dev for a visual dashboard of your resources
  • Add a queue with sst.aws.Queue for 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.


Want to read more tutorials? Check out our latest tutorial on 9 Laravel 11 Basics: Blade Templates.

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 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.

28 min read·