Skip to content
ExponaBLOG
AWS FOR DEVELOPERS · DAY 088 / 100

A serverless SaaS architecture: best practices explained

An example stack for a small team.

Expona Admin8 min read

Building a SaaS product doesn't mean you need to run your own servers or manage fleet orchestration. In this article, I'll walk you through a serverless SaaS architecture that lets a small team ship fast, scale smoothly, and pay only for what they use. After five years shipping production platforms, I've learned that serverless isn't about avoiding infrastructure—it's about choosing infrastructure that gets out of your way.

This stack combines Next.js, API Gateway, Lambda, and either Aurora Serverless or DynamoDB, tied together with SQS and EventBridge. It's battle-tested across e-commerce, CRM, and SaaS workloads, and it handles the reality of small teams: you need to move fast, monitor closely, and scale without hiring DevOps engineers.

Frontend — Next.js on CloudFront with S3 assets

Your frontend sits at the edge, served globally. Next.js gives you both static generation and server-side rendering out of the box, which matters when you need SEO and performance without extra work.

The pattern is straightforward: build your Next.js app, export static assets to an S3 bucket, and front it with CloudFront. For dynamic pages that need rendering, you can use Next.js API routes (backed by Lambda) or point CloudFront to a Lambda origin with CloudFront Functions for lightweight transformations.

Here's a typical deployment flow:

bash
npm run build
aws s3 sync .next/static s3://my-saas-assets/ --cache-control "public, max-age=31536000"
aws s3 sync public s3://my-saas-assets/ --cache-control "public, max-age=3600"
aws cloudfront create-invalidation --distribution-id E123ABC --paths "/*"

CloudFront caches your assets globally, so users in Singapore and São Paulo both get sub-100ms responses. Static assets get long TTLs; HTML gets shorter TTLs or no cache at all. I typically set max-age=31536000 for hashed .js and .css bundles, and max-age=3600 for HTML. This balance keeps your invalidation bill reasonable while ensuring users get fresh content when they deploy.

The key insight: CloudFront isn't just CDN sugar. It handles gzip, Brotli, image optimization headers, and request coalescing automatically. For a SaaS with global users, this is non-negotiable.

API — API Gateway HTTP API with Lambda functions

Your backend lives in Lambda functions, exposed through API Gateway's HTTP API (not REST API—HTTP API costs less and handles most workloads just fine). Each Lambda is a small, single-purpose function or a monolithic handler split by route.

I've shipped both patterns successfully. For a small team, I lean toward a single Lambda handler per service—say, one Lambda for user accounts, one for billing, one for the core feature domain. This keeps cold starts reasonable and makes local testing simpler than managing thirty tiny functions.

A basic handler looks like this:

bash
import { APIGatewayProxyHandlerV2 } from 'aws-lambda'

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  const method = event.requestContext.http.method
  const path = event.requestContext.http.path

  if (method === 'POST' && path === '/users') {
    const body = JSON.parse(event.body || '{}')
    // Create user in database
    return { statusCode: 201, body: JSON.stringify({ id: 'user-123' }) }
  }

  return { statusCode: 404, body: JSON.stringify({ error: 'Not found' }) }
}

API Gateway HTTP API is generous with concurrency and scales instantly. You don't set target capacity or warm your instances. When traffic spikes, Lambda auto-scales. The billing model—you pay per invocation and GB-second—means scaling costs are predictable and only happen when requests arrive.

Add CloudWatch Logs for debugging and X-Ray for tracing if you need to chase latency in downstream services. For a SaaS, I log every request and every database call; the cost is negligible, and the visibility pays for itself in week one.

Data — Aurora Serverless v2 or DynamoDB depending on access patterns

Here's where most teams stumble: choosing between a relational and non-relational store. The answer isn't dogmatic. It depends on your queries.

Aurora Serverless v2 is your default if you have complex queries, foreign keys, or reporting needs. It's PostgreSQL or MySQL under the hood, but it auto-scales compute and you pay per ACU (Aurora Capacity Unit) per hour. Cold starts don't exist—it's warm all the time. For a SaaS, this is often the right call because you get ACID transactions and SQL without managing instances.

A typical schema might look like:

bash
CREATE TABLE users (
  id UUID PRIMARY KEY,
  email VARCHAR(255) UNIQUE NOT NULL,
  created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE subscriptions (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES users(id),
  plan VARCHAR(50),
  expires_at TIMESTAMPTZ
);

CREATE INDEX idx_user_subscriptions ON subscriptions(user_id);

A query from your Lambda to fetch a user with their active subscriptions is straightforward SQL, and Aurora handles the indexing and optimization:

bash
const user = await db.query(`
  SELECT u.id, u.email, s.plan
  FROM users u
  LEFT JOIN subscriptions s ON u.id = s.user_id AND s.expires_at > NOW()
  WHERE u.id = $1
`, [userId])

DynamoDB makes sense if your access patterns are simple and predictable: fetch by ID, query by partition key, scan items by date range. DynamoDB is fast (single-digit millisecond p99), and on-demand pricing means you scale automatically without thinking about capacity. But DynamoDB isn't SQL—it's key-value with range queries, and modeling requires discipline.

For a SaaS, I use DynamoDB for high-volume, write-heavy tables like events, logs, or analytics feeds. I use Aurora for everything else. The hybrid approach gives you the best of both worlds.

One practical rule: if you need to JOIN tables or write complex WHERE clauses, use Aurora. If your queries are single-table key lookups with simple range filters, DynamoDB works. Your team's comfort with SQL also matters—if you're all strong with SQL but new to DynamoDB's query model, stick with Aurora and sleep better.

Async work — SQS and EventBridge for background processing

Not every request deserves a synchronous response. Sending emails, processing uploads, or generating reports should happen in the background. SQS and EventBridge handle this elegantly.

SQS is your queue for straightforward point-to-point work. A Lambda in your API receives a request to send an invoice email, pushes a message to SQS, and returns immediately. A second Lambda polls SQS and sends the email. If the email service is slow or down, the message stays in the queue and retries automatically. You get decoupling and resilience with minimal code.

Example: User signs up, API Lambda writes to SQS.

bash
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs'

const sqs = new SQSClient({})

await sqs.send(new SendMessageCommand({
  QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789/send-welcome-email',
  MessageBody: JSON.stringify({ userId: 'user-123', email: 'user@example.com' })
}))

Another Lambda consumes from the queue:

bash
export const emailWorker: SQSHandler = async (event) => {
  for (const record of event.Records) {
    const { userId, email } = JSON.parse(record.body)
    await sendWelcomeEmail(email)
  }
}

EventBridge is your event bus for reactive, multi-target workflows. When a user upgrades their subscription, you publish an event to EventBridge. Multiple targets listen: one charges the credit card, one sends a confirmation email, one updates analytics. Each target is decoupled; failures don't cascade.

EventBridge rules are defined declaratively. You write:

bash
{
  "Name": "on-subscription-upgrade",
  "EventPattern": {
    "source": ["saas.subscriptions"],
    "detail-type": ["SubscriptionUpgraded"]
  },
  "Targets": [
    { "Arn": "arn:aws:lambda:us-east-1:123456789:function:charge-card" },
    { "Arn": "arn:aws:lambda:us-east-1:123456789:function:send-email" },
    { "Arn": "arn:aws:events:us-east-1:123456789:event-bus/analytics" }
  ]
}

From your API Lambda, you emit the event:

bash
await eventBridge.putEvents({
  Entries: [
    {
      Source: 'saas.subscriptions',
      DetailType: 'SubscriptionUpgraded',
      Detail: JSON.stringify({ userId, newPlan: 'pro' })
    }
  ]
})

EventBridge guarantees at-least-once delivery and retries for up to 24 hours. For SaaS, this is critical—you can't lose a subscription upgrade or a payment event. SQS plus EventBridge together give you reliable, scalable async processing without managing any infrastructure.

Common mistakes

Ignoring cold starts. Lambda cold starts are real, but you can minimize them. Keep your Lambda bundles under 50 MB, avoid heavy libraries at import time, and consider provisioned concurrency for your most-called endpoints. For a SaaS, a 500 ms cold start on your sign-up endpoint is unacceptable. A 500 ms cold start on your nightly batch job is fine.

Choosing the wrong database. I've seen teams model every table as DynamoDB because it's "fast," then spend weeks debugging N+1 query patterns they could have avoided with SQL and proper indexes. Start with Aurora, move to DynamoDB only when you have a specific reason.

Not monitoring async work. Your SQS queue is silently failing, and you have no idea. Set up CloudWatch alarms for queue depth, Lambda error rates, and SQS message age. If your queue depth climbs above zero and stays there, your workers are broken.

Forgetting VPC. If your Lambda needs to call Aurora, it must run inside a VPC. This adds a few seconds to cold starts (ENI attachment time) but is unavoidable if you're not using RDS Proxy or a public database endpoint (which you shouldn't).

Key takeaways

  • Next.js plus CloudFront gives you a globally distributed frontend with caching built in. Push static assets to S3, let CloudFront handle the edge distribution.
  • API Gateway HTTP API with Lambda is the path of least resistance for SaaS APIs. Auto-scaling is free; you pay only for traffic.
  • Aurora Serverless v2 covers most SaaS data needs. Use DynamoDB for high-volume, single-table workloads where you know your access patterns up front.
  • SQS and EventBridge decouple your critical paths. Send emails, process payments, and run reports in the background without slowing down user-facing requests.
  • Monitor cold starts, database queries, and async queue depth. A SaaS lives and dies by observability; invest in logging and alarms from day one.
  • This stack scales from 10 users to 10 million. You don't need to redesign; you tune knobs (Aurora ACUs, Lambda memory, SQS batch size) as you grow.

That's the serverless SaaS architecture I'd build today for a small team shipping a production platform. It's boring, it works, and it lets you focus on product instead of infrastructure.

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles