How to Build a SaaS App with Next.js and AWS — Complete Guide 2026
Building a SaaS application from scratch is one of the most common ambitions among developers. The challenge is not writing the code — it is making the right architectural decisions early so you do not have to rewrite everything at 1,000 users.
TL;DR: Use Next.js (App Router) for the frontend and API routes. AWS Cognito for authentication. DynamoDB for the database (single-table design). Stripe for billing. AWS Amplify for deployment. This stack scales from 0 to 100K users without re-architecture, costs under $50/month at low traffic, and lets a solo developer ship a production SaaS in 4-8 weeks.
This guide covers the complete architecture I use for SaaS projects. Every component is battle-tested in production.
The Stack
Here is what we are building with and why:
- Next.js 16 (App Router) — Frontend, API routes, server-side rendering. One codebase for everything.
- AWS Cognito — Authentication (signup, login, password reset, MFA). Free for the first 50,000 MAUs.
- DynamoDB — NoSQL database. Pay-per-request pricing means $0 at zero traffic. Scales infinitely.
- Stripe — Billing and subscriptions. Industry standard, excellent developer experience.
- AWS Amplify — Deployment. Git-push deploys, preview environments, custom domains, SSL.
- AWS CDK — Infrastructure as code. Define all AWS resources in TypeScript.
Why This Stack?
Cost efficiency at low traffic. A SaaS with 100 users might generate $500-$2,000/month in revenue. Your infrastructure should not cost $200/month. This stack costs under $20/month for low-traffic apps.
Scales without re-architecture. DynamoDB and Cognito handle millions of users. Amplify auto-scales. You will not need to migrate databases or rewrite auth at 10K users.
Solo-developer friendly. One language (TypeScript) across the entire stack. No separate backend server to maintain. API routes in Next.js handle everything.
Project Setup
Initialise the Next.js Application
npx create-next-app@latest my-saas --typescript --tailwind --app --src-dir
cd my-saas
Install Core Dependencies
# Auth
npm install amazon-cognito-identity-js jose
# Database
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
# Billing
npm install stripe @stripe/stripe-js
# Utilities
npm install zod nanoid
Environment Variables
Create .env.local:
# Cognito
NEXT_PUBLIC_COGNITO_USER_POOL_ID=ap-southeast-1_xxxxx
NEXT_PUBLIC_COGNITO_CLIENT_ID=xxxxx
COGNITO_CLIENT_SECRET=xxxxx
# DynamoDB
DYNAMODB_TABLE_NAME=my-saas-table
# Stripe
STRIPE_SECRET_KEY=sk_live_xxxxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
# App
NEXT_PUBLIC_APP_URL=https://myapp.com
Authentication with Cognito
Why Cognito Over Auth0, Clerk, or Supabase Auth?
- Free tier: 50,000 MAUs free (Auth0 caps at 7,500, Clerk at 10,000)
- AWS-native: Direct integration with DynamoDB, S3, Lambda, and IAM
- No vendor lock for auth data: User pool data is exportable
- MFA built-in: SMS and TOTP at no additional cost
The trade-off is developer experience — Cognito's API is verbose compared to Clerk or Supabase. But for a SaaS that might grow to 50K+ users, the cost savings are significant.
Client-Side Auth Helper
// src/lib/auth.ts
import {
CognitoUserPool,
CognitoUser,
AuthenticationDetails,
CognitoUserAttribute,
} from 'amazon-cognito-identity-js';
const userPool = new CognitoUserPool({
UserPoolId: process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID!,
ClientId: process.env.NEXT_PUBLIC_COGNITO_CLIENT_ID!,
});
export async function signUp(
email: string,
password: string,
name: string
): Promise<void> {
return new Promise((resolve, reject) => {
const attributes = [
new CognitoUserAttribute({ Name: 'email', Value: email }),
new CognitoUserAttribute({ Name: 'name', Value: name }),
];
userPool.signUp(email, password, attributes, [], (err) => {
if (err) reject(err);
else resolve();
});
});
}
export async function signIn(
email: string,
password: string
): Promise<{ idToken: string; accessToken: string }> {
return new Promise((resolve, reject) => {
const user = new CognitoUser({ Username: email, Pool: userPool });
const authDetails = new AuthenticationDetails({
Username: email,
Password: password,
});
user.authenticateUser(authDetails, {
onSuccess: (session) => {
resolve({
idToken: session.getIdToken().getJwtToken(),
accessToken: session.getAccessToken().getJwtToken(),
});
},
onFailure: reject,
});
});
}
Server-Side JWT Validation
// src/lib/auth-validate.ts
import { jwtVerify, createRemoteJWKSSet } from 'jose';
const JWKS = createRemoteJWKSSet(
new URL(
`https://cognito-idp.ap-southeast-1.amazonaws.com/${process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID}/.well-known/jwks.json`
)
);
export async function validateToken(token: string) {
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: `https://cognito-idp.ap-southeast-1.amazonaws.com/${process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID}`,
});
return {
userId: payload.sub as string,
email: payload.email as string,
name: payload.name as string,
};
} catch {
return null;
}
}
Database Design with DynamoDB
Single-Table Design
DynamoDB works best with a single-table design where all entity types share one table. This is counterintuitive if you come from SQL, but it enables all your access patterns to be served by a single table with 1-2 GSIs.
// src/lib/dynamodb.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
DynamoDBDocumentClient,
PutCommand,
GetCommand,
QueryCommand,
} from '@aws-sdk/lib-dynamodb';
const client = new DynamoDBClient({ region: 'ap-southeast-1' });
const docClient = DynamoDBDocumentClient.from(client);
const TABLE = process.env.DYNAMODB_TABLE_NAME!;
// Entity key patterns:
// User: PK=USER#<userId> SK=PROFILE
// Team: PK=TEAM#<teamId> SK=METADATA
// Member: PK=TEAM#<teamId> SK=MEMBER#<userId>
// Project: PK=TEAM#<teamId> SK=PROJECT#<projectId>
export async function createUser(userId: string, email: string, name: string) {
await docClient.send(
new PutCommand({
TableName: TABLE,
Item: {
PK: `USER#${userId}`,
SK: 'PROFILE',
email,
name,
plan: 'free',
createdAt: new Date().toISOString(),
GSI1PK: `EMAIL#${email}`,
GSI1SK: `USER#${userId}`,
},
})
);
}
export async function getUser(userId: string) {
const result = await docClient.send(
new GetCommand({
TableName: TABLE,
Key: { PK: `USER#${userId}`, SK: 'PROFILE' },
})
);
return result.Item;
}
export async function getTeamMembers(teamId: string) {
const result = await docClient.send(
new QueryCommand({
TableName: TABLE,
KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
ExpressionAttributeValues: {
':pk': `TEAM#${teamId}`,
':sk': 'MEMBER#',
},
})
);
return result.Items || [];
}
Why DynamoDB Over PostgreSQL (RDS/Supabase)?
For a SaaS, DynamoDB wins on:
- Cost at low scale: Pay-per-request means $0 at zero traffic. RDS costs $15-$50/month minimum even with zero queries.
- Scaling: No connection limits, no read replica management, no vacuum operations.
- Serverless-native: Works perfectly with Next.js API routes (no connection pooling needed).
The trade-off is that you need to model your data around your access patterns upfront. Ad-hoc queries and joins are not possible. For most SaaS applications with well-defined access patterns, this is fine.
Billing with Stripe
Subscription Setup
// src/app/api/billing/create-checkout/route.ts
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { validateToken } from '@/lib/auth-validate';
import { cookies } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const PRICE_IDS = {
starter: 'price_xxxxx',
pro: 'price_xxxxx',
enterprise: 'price_xxxxx',
};
export async function POST(request: Request) {
const cookieStore = await cookies();
const token = cookieStore.get('idToken')?.value;
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const user = await validateToken(token);
if (!user) return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
const { plan } = await request.json();
const priceId = PRICE_IDS[plan as keyof typeof PRICE_IDS];
if (!priceId) {
return NextResponse.json({ error: 'Invalid plan' }, { status: 400 });
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer_email: user.email,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?billing=success`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?billing=cancelled`,
metadata: { userId: user.userId },
});
return NextResponse.json({ url: session.url });
}
Webhook Handler
// src/app/api/billing/webhook/route.ts
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { updateUserPlan } from '@/lib/dynamodb';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text();
const sig = request.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
if (userId) {
await updateUserPlan(userId, 'pro', session.subscription as string);
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
const userId = subscription.metadata?.userId;
if (userId) {
await updateUserPlan(userId, 'free', null);
}
break;
}
}
return NextResponse.json({ received: true });
}
Infrastructure as Code with AWS CDK
CDK Stack
// cdk/lib/saas-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
export class SaasStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Cognito User Pool
const userPool = new cognito.UserPool(this, 'UserPool', {
selfSignUpEnabled: true,
signInAliases: { email: true },
autoVerify: { email: true },
passwordPolicy: {
minLength: 8,
requireUppercase: true,
requireDigits: true,
requireSymbols: false,
},
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
const userPoolClient = userPool.addClient('WebClient', {
authFlows: { userPassword: true, userSrp: true },
generateSecret: true,
});
// DynamoDB Table
const table = new dynamodb.Table(this, 'MainTable', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.RETAIN,
pointInTimeRecoveryEnabled: true,
});
table.addGlobalSecondaryIndex({
indexName: 'GSI1',
partitionKey: { name: 'GSI1PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'GSI1SK', type: dynamodb.AttributeType.STRING },
});
// Outputs
new cdk.CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId });
new cdk.CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId });
new cdk.CfnOutput(this, 'TableName', { value: table.tableName });
}
}
Deployment with AWS Amplify
Deploy with a single git push:
# Install Amplify CLI
npm install -g @aws-amplify/cli
# Connect your repo
npx ampx pipeline-deploy --branch main --app-id YOUR_APP_ID
Amplify gives you:
- Automatic builds on every push to main
- Preview environments for pull requests
- Custom domains with free SSL
- Server-side rendering support for Next.js
The Checklist: From Zero to Production
- Set up Next.js project with TypeScript and Tailwind
- Deploy CDK stack (Cognito + DynamoDB)
- Implement signup/login/logout with Cognito
- Build dashboard layout with protected routes
- Design DynamoDB schema for your domain entities
- Build CRUD API routes for core features
- Add Stripe billing (checkout, webhook, customer portal)
- Implement plan-gated features (middleware or component-level)
- Connect Amplify for deployment
- Add custom domain, SSL, and monitoring
- Set up error tracking (Sentry) and analytics (PostHog)
- Launch
Cost Breakdown at Different Scales
0-100 users (pre-launch):
- Cognito: $0 (free tier)
- DynamoDB: $0-$2/month
- Amplify: $0-$5/month
- Stripe: 2.9% + 30c per transaction
- Total infrastructure: ~$5/month
100-1,000 users:
- Cognito: $0 (under 50K MAU free tier)
- DynamoDB: $5-$20/month
- Amplify: $10-$30/month
- Total infrastructure: ~$30/month
1,000-10,000 users:
- Cognito: $0-$50/month
- DynamoDB: $20-$100/month
- Amplify: $30-$100/month
- Total infrastructure: ~$150/month
At every scale, this stack costs a fraction of what you would pay for Vercel Pro + Supabase Pro + Auth0 equivalent.
Sources and References
- AWS CDK documentation — Cognito, DynamoDB, Amplify constructs
- Stripe API documentation — Checkout, Webhooks, Customer Portal
- Next.js documentation — App Router, API routes, middleware
- Personal production experience — multiple SaaS apps on this stack (2024-2026)
Related reads: Best Cloud Hosting for Singapore Startups | Deploy Next.js to AWS Amplify | AWS Lambda Cold Start Optimization