Build a Production-Ready Serverless API With AWS SAM and TypeScript

Build a Production-Ready Serverless API With AWS SAM and TypeScript

Most serverless tutorials leave you with a toy: a single "hello world" Lambda that teaches you nothing about IAM, a DynamoDB table you never actually touch, and zero thought about what happens when a real request goes sideways. I wanted the opposite — a starting point that is small enough to read in one sitting but shaped like production: least-privilege IAM, typed data access, fail-closed error handling, CORS that actually works, and the gotchas that cost people real hours written down where you'll see them before they bite. This article walks through the starter kit I ended up with: four TypeScript Lambda functions, one DynamoDB table, and one SAM template — no CDK, no bundler, no opinionated framework. By the end you'll have the whole architecture in your head, and a repo you can clone, deploy, and grow into a real product. The 30-second mental model Browser -> API Gateway -> Lambda (TypeScript) -> DynamoDB ^ | (IAM: least privilege) +-- CORS headers on every response That's the whole system. Everything else in this article is detail — and the details are where the hours go. Why SAM and plain tsc (and not CDK, no bundler) Two deliberate choices deserve explanation before the code: SAM over CDK. For a starter kit, AWS SAM gives you the smallest surface area between your intent and a deployed stack. One template.yml declares API routes, functions, the table, and IAM policies; sam build && sam deploy handles packaging, staging, and CloudFormation. CDK is more powerful, but it drags in a full programming model, constructs, and a bigger conceptual load — bad for a template people are supposed to read entirely before building on it. Plain tsc over a bundler. Lambda Node.js runtimes can run CommonJS straight out of dist/. A bundler (esbuild/webpack) adds config, plugins, and failure modes for zero benefit at this scale. Our npm run build is literally tsc: { "scripts": { "build": "tsc", "typecheck": "tsc --noEmit" } } The template: one table, four functions, least privilege Everything lives in template.yml. Three patterns here are worth copying into whatever you build next: BillingMode: PAY_PER_REQUEST — no provisioned capacity to guess, no throttling surprises, near-free at hobby scale. Per-function IAM — each function gets exactly the permissions it needs, scoped to its own table. The hello function gets no AWS permissions at all. Environment wiring via !Ref — the table name is injected into the Lambda's environment, so code never hardcodes resource names. ItemsTable: Type: AWS::DynamoDB::Table Properties: BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: id AttributeType: S KeySchema: - AttributeName: id KeyType: HASH SSESpecification: SSEEnabled: true CreateItemFunction: Type: AWS::Serverless::Function Properties: CodeUri: . Handler: dist/handlers/create-item.handler Environment: Variables: ITEMS_TABLE: !Ref ItemsTable Policies: - DynamoDBCrudPolicy: TableName: !Ref ItemsTable Events: Api: Type: Api Properties: Path: /items Method: post The DynamoDBCrudPolicy scoped with TableName: !Ref ItemsTable is the pattern that matters. It's the difference between "a leaked key is a mild inconvenience" and "a leaked key is catastrophic." Never use AdministratorAccess or * resources on a Lambda role — least privilege isn't security theater, it's containment. A typed DynamoDB layer The data layer is the part most tutorials skip, and it's where type safety pays off fastest. The SDK's document client (@aws-sdk/lib-dynamodb) maps your items to and from plain TypeScript objects — no manual marshalling. Two details here are load-bearing: The client is module-scope, not created inside the handler. AWS SDK clients cache connections and credentials across invocations; if you construct the client per request, every invocation pays the connection setup cost and you throw away the warm-start benefit. The table name comes from the environment, injected by the template — never from a hardcoded string or a config file that drifts from the deployed stack. import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, GetCommand, PutCommand, ScanCommand, } from "@aws-sdk/lib-dynamodb"; const client = DynamoDBDocumentClient.from(new DynamoDBClient({})); export interface Item { id: string; name: string; createdAt: string; } export function tableName(): string { const table = process.env.ITEMS_TABLE; if (!table) { throw new Error("ITEMS_TABLE environment variable is not set"); } return table; } export async function createItem(item: Item): Promise { await client.send(new PutCommand({ TableName: tableName(), Item: item })); } export async function getItem(id: string): Promise { const result = await client.send( new GetCommand({ TableName: tableName(), Key: { id } }) ); return result.Item as Item | undefined; } One honest caveat in the code: list-items uses a Scan, which reads every item in the table. That's fine for a starter and for lists under a few thousand rows — and a trap in production. The fix is a Query on a real partition key (typically a userId/tenantId plus a createdAt sort key), paginated with LastEvaluatedKey. The starter's docs/PRO_TIPS.md spells this out because it's the single most common way a demo API becomes a slow, expensive production API. The handler pattern: validate, fail closed, never leak Here's the create-item handler. Three habits are worth internalizing: Validate before you touch anything. Bad input gets a clean 400, not a 500. Fail closed. An unexpected error becomes a generic 500 to the client, with the real detail logged server-side. Never leak stack traces or internal state. Centralize responses. One responses.ts module owns status codes, CORS headers, and JSON serialization, so every handler behaves identically. import { randomUUID } from "crypto"; import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; import { badRequest, created, serverError } from "../lib/responses"; import { createItem, Item } from "../lib/items"; export async function handler( event: APIGatewayProxyEvent ): Promise { try { if (!event.body) { return badRequest("Request body is required"); } let body: { name?: unknown }; try { body = JSON.parse(event.body) as { name?: unknown }; } catch { return badRequest("Request body must be valid JSON"); } const name = typeof body.name === "string" ? body.name.trim() : ""; if (!name) { return badRequest("Field 'name' (non-empty string) is required"); } const item: Item = { id: randomUUID(), name, createdAt: new Date().toISOString(), }; await createItem(item); return created(item); } catch (err) { console.error("create-item failed:", err); return serverError(); } } Note the typeof body.name === "string" guard. The request body arrives as unknown shaped data from the internet; treating it as a typed object is how you get runtime crashes. Parse defensively, validate explicitly, and the type system protects the rest of your codebase. CORS: the gotcha that makes browsers hate your API If you're calling your API from a browser (and with a frontend, you are), CORS is the first thing that breaks. The subtle part: for the classic Api event type, SAM does not configure CORS on the gateway for you — the headers have to come from the Lambda responses. That's why responses.ts stamps Access-Control-Allow-* on every response: const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS", "Access-Control-Allow-Headers": "Content-Type,Authorization", }; export function ok(body: unknown): APIGatewayProxyResult { return json(body, 200); } function json(body: unknown, statusCode: number): APIGatewayProxyResult { return { statusCode, headers: { "Content-Type": "application/json", ...CORS_HEADERS }, body: JSON.stringify(body), }; } Before you go live, restrict Access-Control-Allow-Origin to your real frontend domain. (If you switch to HttpApi instead of Api, CORS is configured at the gateway level — a different mental model, easy to get wrong when mixing the two.) The local dev loop The best part of SAM for a template like this: you can run the entire API on your laptop before touching AWS. npm install npm run build # tsc: src/ -> dist/ sam local start-api # http://127.0.0.1:3000 sam local start-api uses your local AWS credentials for DynamoDB, so the item endpoints work out of the box — no deployed stack required. When you're ready for real infrastructure, deployment is three commands: sam build # runs install + build in a staging area sam deploy --guided # first time: stack name, region, confirm IAM roles sam deploy # every time after: incremental updates And when you're done: sam delete tears the whole stack down. No orphaned resources, no surprise bills. The gotchas that cost real hours These are the things I've hit building real serverless systems, now written down in the kit so you don't hit them fresh: Cold starts are real — and mostly fine. First request after idle: 100–800 ms on Node 20. The cheapest mitigations are architectural: keep functions small, and initialize SDK clients at module scope (the kit does both). ProvisionedConcurrency and SnapStart exist for latency-critical endpoints — both cost money, so don't add them preemptively. Ping-to-keep-warm hacks are an anti-pattern. Scan vs Query is a money question, not a style question. A Scan reads and charges for every item. Under a few thousand rows, nobody notices. At production scale, it's the difference between a $2 bill and a $200 bill — and a slow API. Design your table around query patterns (partition key + sort key) from day one. CloudWatch Logs is the sneaky line item. Lambda is nearly free; log storage isn't. Set log retention explicitly (RetentionInDays: 7 for dev, 30–90 for prod), or your logs accumulate forever. Memory size is a latency lever. CPU scales with memory on Lambda. A 128 MB function doing JSON-heavy work can be slower and more expensive than a 256–512 MB one, because you pay wall-clock time. Bump memory deliberately for compute-y handlers. Secrets never go in environment variables. The template injects non-secret wiring (ITEMS_TABLE) via env vars — that's fine. Actual secrets (API keys, DB passwords) go in SSM Parameter Store or Secrets Manager, referenced by name. What's deliberately missing (and why) Auth. The kit has none — by design. Adding an authorizer changes the shape of every handler (and of your frontend), so it's a decision you should make with real requirements in hand. When you need it: API Gateway JWT authorizers with Cognito for the common case, or a Lambda authorizer for custom logic. A frontend. The kit is an API. Pair it with whatever frontend you like — the CORS headers are already there. Deploy it, break it, grow it The whole point of this template is that you can read every line, deploy it, curl the endpoints, break things deliberately, and then grow it into your product — new route, new handler, new table, same patterns. It's MIT-licensed, and the full source (plus a PRO_TIPS.md with the extended gotcha list) is on my site: dozie.dev. The architecture is four boxes. The patterns are five. Everything else is documentation — and now you know where the documentation bites.

Original Source

Read the full article at Hackernoon →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.