expressjs-rest-api
Purpose
Use this skill to design and implement Express 5 REST APIs in a way that fits the target repository first.
When local patterns exist, use them as the source of truth. When they do not, use the references in this skill as portable patterns to generate a coherent structure rather than one-off route code.
Portable references live in references/. Load only the patterns needed for the task:
references/app-pattern.mdfor Express app setup and middleware compositionreferences/route-pattern.mdfor route definitions with validation and auth middlewarereferences/controller-pattern.mdfor request handlers, error flow, and response shapingreferences/middleware-pattern.mdfor auth, validation, and global error middlewarereferences/repository-pattern.mdfor DynamoDB repository classesreferences/postgres-pattern.mdfor Postgres repository classes using Drizzle ORMreferences/model-pattern.mdfor TypeScript types and Zod v4 validation schemasreferences/error-pattern.mdfor typed error classes andErrorResponseBuilderreferences/status-code-pattern.mdfor theStatusCodeenumreferences/pagination-pattern.mdfor cursor pagination interfaces and encoding
Express 5 Key Behaviors
Express 5 requires Node.js 18 or higher.
For new projects, this skill's default baseline is Node.js 24 unless the target repository already uses another supported version.
Async error propagation is automatic — rejected promises and thrown errors inside async route handlers forward to the error handler without an explicit next(error). Controllers still use try/catch to handle named error types before that fallback.
Built-in body parsing — no separate body-parser package needed:
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
Error-handling middleware must declare all four parameters (err, req, res, next) or Express treats it as regular middleware.
Route path syntax changed — review wildcard and optional segment syntax carefully when migrating Express 4 routes.
req.query should not be treated as the destination for coerced values — store validated query output on res.locals instead of mutating req.query.
req.body is undefined until a body parser runs — do not assume JSON payloads exist before express.json().
Environment Variable Strategy
New projects use Node.js's built-in process.loadEnvFile() instead of dotenv. Three environment files are created at project root:
| File | Purpose | Committed to git |
|---|---|---|
.env.example |
Documents all required variables with empty or example values | Yes |
.env.development |
Local development values | No |
.env.production |
Production values | No |
.gitignore must include:
.env.development
.env.production
In a composition or bootstrap module such as app.ts, import first, then load the correct file based on NODE_ENV immediately after imports and before application setup:
import express from 'express';
if (process.env.NODE_ENV !== 'production') {
process.loadEnvFile(`.env.${process.env.NODE_ENV || 'development'}`);
}
In production (e.g. ECS, Lambda, Fly.io), environment variables are injected directly into the process — no file is loaded. Do not attempt to load a file in production.
Avoid reading process.env at module top level inside imported files before loadEnvFile() runs. If a dependency needs env values during initialization, move that env read into a function, a factory, or the app bootstrap path.
Portability Rule
This skill works across repositories. Do not assume a specific folder layout exists.
Source of truth order:
- The target repository's existing patterns and conventions
- The skill's documented patterns in
references/ - The inline examples in this skill
If local code differs from the skill examples, follow local code and use the examples only as design guidance.
Repository Discovery
Start every use of this skill with a short discovery pass before proposing code.
Inspect only the parts of the repository that matter for the request. Common places include:
- the Express app entry point or bootstrap module
- router modules
- controllers or route handlers
- middleware modules
- services
- repositories or data-access modules
- models and Zod schemas
- dependency wiring or composition modules
- error class hierarchy and shared response/status utilities
- Datastore indicators: DynamoDB SDK imports, Drizzle schema files,
DATABASE_URLenv var
After discovery, choose one mode and state it:
Existing pattern mode: extend the repository's existing abstractionsPattern generation mode: generate the missing reusable pattern because no relevant abstraction exists
Existing Pattern Mode
Use this mode when the repo already has patterns such as:
- an app bootstrap with helmet, cors, trust proxy, and global error middleware
- router-based route definitions with inline middleware
RequestHandler-style async controllers or equivalent handlers- auth middleware for Cognito JWT or another existing auth strategy
- validation middleware for body, query, and params
- a 4-parameter global error handler
- services for business logic and external integrations
- repository classes for DynamoDB or Postgres
- model modules with TypeScript types and Zod schemas
- typed error classes and a shared status-code utility
- dependency injection or composition root modules
Preserve local patterns even when they reflect older Express 4-era conventions. Only generate new patterns when the repository does not already establish a coherent approach.
Pattern Generation Mode
Use this mode when the target repository does not already provide a reusable abstraction for one or more layers.
In this mode:
- use the references as guidance for the shape of the generated code, not as a requirement that exact files or names exist
- introduce the smallest reusable abstraction that makes future routes easier to add and maintain
- prefer generating a reusable app bootstrap, router, middleware, service, repository, or utility pattern over embedding everything in a single file
- keep generated names and folders aligned with the target repository's conventions
Preferred Project Shape
Unless the target repository already has a different established layout, use a structure like:
src/
├── app.ts
├── controllers/
├── data/
│ ├── repository.interface.ts
│ ├── pagination.ts
│ ├── client.ts ← Drizzle client (Postgres projects only)
│ └── schema.ts ← Drizzle schema (Postgres projects only)
├── dependencies/
│ ├── aws.deps.ts ← AWS clients: Cognito, DynamoDB, etc.
│ └── project.deps.ts ← shared singletons: logger, repositories
├── middlewares/
├── models/
├── routes/
├── services/
└── utilities/
├── errors.ts
├── status-code.ts
└── mappers/
Treat this as a conceptual layout, not a hard requirement.
When adding a new resource, a good flow is:
- model in a model or schema module
- route in a router module
- controller in a controller or handler module
- service method if logic is non-trivial
- repository in a data-access layer
- wire dependencies in a composition layer
- mount the route in the app bootstrap
Default Assumptions
- Node.js version baseline for new projects: 24
- Express version: 5.x (
express ~5.1.0) - Language: TypeScript with
commonjsmodule output - Validation: Zod v4 (
zod/v4) - Auth: Cognito JWT validation via
aws-jwt-verify - Datastore: ask the user — DynamoDB or Postgres are both supported
- DynamoDB:
@aws-sdk/lib-dynamodb, single-table design withPK/SKkeys - Postgres: Drizzle ORM (
drizzle-orm) withpostgresdriver for new projects; follow the existing ORM if one is already in use
- DynamoDB:
- Security headers:
helmet - CORS:
corswithCORS_ORIGINenv var,*fallback - Environment loading: Node.js
process.loadEnvFile()in the app bootstrap after imports when the repository does not already use another approach
If the user gives constraints that conflict with these defaults, adapt and state the change.
Modern Pattern Preferences
When the target repository does not already establish a conflicting pattern, prefer:
- validated request data stored on
res.locals.validatedinstead of mutatingreq.query - thin controllers that throw typed domain errors and let global error middleware map them to HTTP responses
- pagination ordered by stable monotonic keys, not random UUID values
- DynamoDB
QueryoverScanwhen an access pattern is known - structured logging and graceful shutdown hooks for new apps
- the repo's existing dev runner first; otherwise use
tsx watchfor new TypeScript projects,node --watchfor compiled JavaScript, andnodemononly when custom watch behavior is already part of the project
Working Rules
- Read the relevant local app bootstrap, router, controller, middleware, service, and repository code before editing.
- If the repository already has an app bootstrap pattern, extend it. If not, generate one reusable bootstrap path instead of wiring Express ad hoc in multiple files.
- If the repository already has router modules, extend them. If not, generate reusable
Routermodules instead of putting all endpoints directly in the app bootstrap. - Keep controllers thin. Put validation in middleware, orchestration in controllers, and persistence in repositories or services.
- Reuse an existing composition pattern for dependencies when it exists. If it does not, generate a lightweight composition module rather than constructing shared clients in every controller.
- Reuse a shared error and status-code pattern when one exists. If it does not, generate one shared pattern instead of repeating inline HTTP error payloads.
- Choose the datastore pattern deliberately: DynamoDB access patterns should drive key design; Postgres access patterns should drive schema and index design.
- Keep generated names, route prefixes, and response shapes aligned with the target repository's conventions.
Environment File Templates
.env.example (DynamoDB project)
NODE_ENV=
PORT=3000
CORS_ORIGIN=
# AWS
AWS_REGION=
COGNITO_USER_POOL_ID=
COGNITO_APP_CLIENT_IDS=
# DynamoDB
DYNAMODB_TABLE=
# Local development only
AWS_PROFILE=
DYNAMODB_ENDPOINT=
.env.example (Postgres project)
NODE_ENV=
PORT=3000
CORS_ORIGIN=
# AWS (if using Cognito auth)
AWS_REGION=
COGNITO_USER_POOL_ID=
COGNITO_APP_CLIENT_IDS=
# Postgres
DATABASE_URL=
.env.development (Postgres example)
NODE_ENV=development
PORT=3000
CORS_ORIGIN=http://localhost:5173
AWS_REGION=us-east-1
COGNITO_USER_POOL_ID=us-east-1_XXXXXXX
COGNITO_APP_CLIENT_IDS=your-client-id
DATABASE_URL=postgres://postgres:password@localhost:5432/myapp_dev
Response Style
When using this skill, produce:
- A short statement of the route(s) or component being added
- Whether you are in
Existing pattern modeorPattern generation mode - Which datastore is in use (DynamoDB or Postgres)
- The planned implementation shape in order: model/schema → route → controller → service → repository → dependencies/composition → app mount
- Explicit assumptions where auth, table/schema shape, validation, or bootstrap wiring is ambiguous
Avoid restating generic Express or AWS documentation when a repository-specific code snippet would answer better.