Building a Modern Fullstack SaaS Application with Next.js, AWS Lambda, RDS, and CDK
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
🚦 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 changesKeep your Lambda functions warm
Questions? Let me know in the comments! 🚀 Happy shipping!
Subscribe to my newsletter
Read articles from Benjamin Temple directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by