MervCodes

Tech Reviews From A Programmer

How to Build a SaaS App with Next.js and AWS — Complete Guide 2026

15 min read

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

  1. Set up Next.js project with TypeScript and Tailwind
  2. Deploy CDK stack (Cognito + DynamoDB)
  3. Implement signup/login/logout with Cognito
  4. Build dashboard layout with protected routes
  5. Design DynamoDB schema for your domain entities
  6. Build CRUD API routes for core features
  7. Add Stripe billing (checkout, webhook, customer portal)
  8. Implement plan-gated features (middleware or component-level)
  9. Connect Amplify for deployment
  10. Add custom domain, SSL, and monitoring
  11. Set up error tracking (Sentry) and analytics (PostHog)
  12. 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