Building a Modern Fullstack SaaS Application with Next.js, AWS Lambda, RDS, and CDK

Benjamin TempleBenjamin Temple
4 min read

Forget the typical SaaS setup headaches. I'll show you how to combine Next.js, AWS Lambda, Amazon RDS, and AWS CDK into a monorepo that actually makes sense.

TL;DR - What You'll Learn:

  • How to structure a modern SaaS monorepo that scales

  • Setting up Next.js for a production-ready frontend

  • Building serverless APIs with AWS Lambda and RDS

  • Managing infrastructure as code with AWS CDK

  • Tips and gotchas I learned the hard way

Prerequisites:

  • Basic knowledge of TypeScript and React

  • AWS account with admin access

  • Node.js and npm installed

Why a Monorepo?

Instead of juggling multiple repositories, you can keep your frontend, backend, and infrastructure code in perfect harmony under one roof. Read more about monorepos.

Here's what we're cooking up:

my-saas/
├── frontend/         # Your Next.js app
├── backend/          # Lambda functions + RDS magic
├── infrastructure/   # CDK goodness
└── package.json      # Workspace conductor

Step-by-Step Guide:

🚀 Step 1: Setting Up Your Monorepo Foundation

First, let's scaffold our workspace.

mkdir my-saas
cd my-saas
npm init -y

Transform your vanilla package.json into a workspace commander:

{
  "name": "my-saas",
  "private": true,
  "workspaces": [
    "frontend",
    "backend",
    "infrastructure"
  ]
}

🎨 Step 2: Frontend Magic with Next.js

Time to craft your frontend. Next.js gives us everything we need for a modern web app without the setup headaches.

mkdir frontend
cd frontend
npx create-next-app@latest . --typescript

⚡ Step 3: Serverless Backend with Lambda + RDS

Here's where things get spicy. We'll create Lambda functions that talk to RDS without breaking a sweat.

mkdir backend
cd backend
npm init -y

Install your backend essentials:

npm install aws-sdk pg @types/pg typescript ts-node

Here's a battle-tested Lambda setup that won't fall apart in production:

// backend/src/index.ts
import { Client } from 'pg';
import { APIGatewayProxyHandler } from 'aws-lambda';

export const handler: APIGatewayProxyHandler = async (event) => {
  const client = new Client({
    host: process.env.DB_HOST,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_NAME,
    ssl: { rejectUnauthorized: false } // Important for RDS
  });

  try {
    await client.connect();
    const result = await client.query('SELECT NOW()');

    return {
      statusCode: 200,
      body: JSON.stringify({
        message: 'Database connected!',
        timestamp: result.rows[0].now
      })
    };
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify({
        message: 'Database connection failed',
        error: error.message
      })
    };
  } finally {
    await client.end();
  }
};

🏗️ Step 4: Infrastructure as Code with CDK

This is where the magic happens. We'll use CDK to create our cloud infrastructure without clicking around the AWS console like a lost tourist.

mkdir infrastructure
cd infrastructure
npx aws-cdk init app --language typescript

Here's a production-ready stack that won't fall apart when you need it most:

// infrastructure/lib/stack.ts
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as rds from 'aws-cdk-lib/aws-rds';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';

export class SaasStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Create a VPC with public and private subnets
    const vpc = new ec2.Vpc(this, 'SaasVPC', {
      maxAzs: 2,
      natGateways: 1  // Save some 💰 in dev
    });

    // Database that won't break the bank
    const database = new rds.DatabaseInstance(this, 'Database', {
      engine: rds.DatabaseInstanceEngine.postgres({
        version: rds.PostgresEngineVersion.VER_13
      }),
      vpc,
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T3,
        ec2.InstanceSize.MICRO
      ),
      allocatedStorage: 20,
      multiAz: false,  // Toggle to true for prod
      deletionProtection: false,  // Toggle to true for prod
    });

    // Lambda that actually works in a VPC
    const api = new lambda.Function(this, 'ApiHandler', {
      runtime: lambda.Runtime.NODEJS_16_X,
      code: lambda.Code.fromAsset('../backend/dist'),
      handler: 'index.handler',
      vpc,
      environment: {
        DB_HOST: database.instanceEndpoint.hostname,
        DB_NAME: 'saas_db',
        DB_USER: 'postgres'  // Use secrets manager in prod!
      }
    });

    // API Gateway that's not a pain to debug
    new apigateway.LambdaRestApi(this, 'ApiGateway', {
      handler: api,
      proxy: true
    });
  }
}

🔐 Step 5: Environment Management That Won't Get You Fired

Never commit secrets. Here's how to manage them properly:

# .env.example (commit this)
DB_HOST=
DB_USER=
DB_PASSWORD=
DB_NAME=

# .env (don't commit this!)
DB_HOST=my-db.cluster-123.region.rds.amazonaws.com
DB_USER=admin
DB_PASSWORD=super-secret
DB_NAME=saas_db
💡
Pro tip: Use AWS Secrets Manager for production credentials. Your security team will love you.

🚦 Step 6: Local Development That Actually Works

Add these scripts to your root package.json:

{
  "scripts": {
    "dev": "npm-run-all --parallel dev:*",
    "dev:frontend": "npm run dev --workspace=frontend",
    "dev:backend": "npm run dev --workspace=backend",
    "build": "npm-run-all build:*",
    "build:frontend": "npm run build --workspace=frontend",
    "build:backend": "npm run build --workspace=backend",
    "deploy": "npm run deploy --workspace=infrastructure"
  }
}

🎯 What's Next?

Once you've got this foundation, you can:

  • Add authentication with AWS Cognito

  • Set up CI/CD with GitHub Actions

  • Add monitoring with CloudWatch

  • Scale up your RDS instance when you hit the big time

Common Gotchas to Watch Out For:

  • VPC Lambda functions need internet access via NAT Gateway

  • RDS connection pools are your friend

  • Always use --all-stacks when deploying CDK changes

  • Keep your Lambda functions warm

💡
Remember: This setup might look like overkill for a small project, but it's designed to scale. Start simple and add complexity only when you need it.

Questions? Let me know in the comments! 🚀 Happy shipping!

0
Subscribe to my newsletter

Read articles from Benjamin Temple directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Benjamin Temple
Benjamin Temple