@ajentify/routed-api
A reusable CDK construct that takes a routes.json + a folder of TypeScript
handler files and gives you:
- Lambdas per route or a single-lambda router (configurable via
deploymentMode) - DynamoDB permissions wired up declaratively
- An HTTP API (API Gateway v2) with CORS
- An optional Route53 + ACM custom domain
- Everything else in your CDK stack stays exactly as you write it
No build step, no codegen, no separate deploy tool. Drop it into any CDK stack.
Why HTTP API v2?
HTTP APIs are ~70% cheaper than REST APIs, have native CORS, and are perfectly
adequate for typical app backends. The construct hides API-Gateway-isms behind a
single routes.json.
Install
npm install @ajentify/routed-apiPeer dependencies (your CDK app must already have these):
npm install aws-cdk-lib constructsMinimum usage
import * as cdk from 'aws-cdk-lib';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as path from 'node:path';
import { RoutedApi } from '@ajentify/routed-api';
export class MyStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const todosTable = new dynamodb.Table(this, 'TodosTable', {
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
});
const api = new RoutedApi(this, 'TodoApi', {
routesFile: path.join(__dirname, 'routes.json'),
lambdaSrcDir: path.join(__dirname, 'lambda'),
tables: { todos: todosTable },
cors: { origins: ['http://localhost:3000'] },
});
new cdk.CfnOutput(this, 'ApiUrl', { value: api.url });
}
}routes.json
{
"defaultMemoryMb": 256,
"defaultTimeoutSeconds": 15,
"defaultEnvironment": { "LOG_LEVEL": "info" },
"defaultTableAccess": { "todos": "readWrite" },
"routes": [
{
"path": "/todos",
"method": "GET",
"handler": "todos/list.ts",
"tables": { "todos": "read" }
},
{
"path": "/todos",
"method": "POST",
"handler": "todos/create.ts"
},
{
"path": "/todos/{id}",
"method": "DELETE",
"handler": "todos/delete.ts"
}
]
}Route fields
| Field | Type | Default |
|---|---|---|
path |
string (e.g. "/todos", "/todos/{id}") |
required |
method |
"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | ... | "ANY" |
required |
handler |
path to .ts file, relative to lambdaSrcDir |
required |
export |
exported function name in that file | "handler" |
memoryMb |
number | defaultMemoryMb |
timeoutSeconds |
number | defaultTimeoutSeconds |
environment |
Record<string,string> |
{} |
tables |
string[] or Record<string, "read"|"write"|"readWrite"> |
inherits defaultTableAccess from props |
Default table access
Pass one of:
"allReadWrite"— every route can read & write every table you pass in (default)"allRead"— every route gets read-only on every table"none"— no table access unless the route asksRecord<string, "read"|"write"|"readWrite">— explicit per-table defaults
For every grant, the lambda also receives ${NAME}_TABLE_NAME as an env var
(e.g. TODOS_TABLE_NAME).
Custom IAM policies
Use the policies prop to attach additional IAM permissions beyond DynamoDB
table access — for example SES, S3, SQS, or any other AWS service:
import * as iam from 'aws-cdk-lib/aws-iam';
new RoutedApi(this, 'MyApi', {
// ...
policies: [
new iam.PolicyStatement({
actions: ['ses:SendEmail', 'ses:SendRawEmail'],
resources: ['*'],
}),
new iam.PolicyStatement({
actions: ['s3:GetObject'],
resources: ['arn:aws:s3:::my-bucket/*'],
}),
],
});In single-lambda mode, policies are attached to the single router function.
In lambda-per-route mode, policies are attached to every function.
Environment variables from a .env file
Instead of passing a large defaultEnvironment object inline, point to a
.env file with the envFile prop:
new RoutedApi(this, 'MyApi', {
envFile: path.join(__dirname, '.env'),
// ...
});The file uses standard KEY=VALUE syntax with build-time variable substitution:
# Static values
COOKIE_NAME=my_app_refresh
# Substitute from CDK build-time process.env, with fallback defaults
JWT_SECRET=${JWT_SECRET:-dev-secret-change-in-production}
API_BASE_URL=${API_BASE_URL:-http://localhost:3000}
# No fallback — empty string if unset
GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
Variables from the env file are merged with (and overridden by)
defaultEnvironment and per-route environment settings.
CORS
cors: {
origins: ['http://localhost:3000', 'https://app.example.com'],
// methods, headers, allowCredentials, maxAgeSeconds are all optional
}
// or pass false to disable preflight entirelySensible defaults are applied — Content-Type, Authorization, X-Api-Key,
X-Amz-Date, X-Amz-Security-Token, X-Requested-With.
Custom domain (Route53 + ACM)
domain: {
recordName: 'api.example.com',
hostedZoneName: 'example.com',
certificateArn: 'arn:aws:acm:us-east-1:123456789012:certificate/abcd',
// hostedZoneId optional — if omitted the zone is looked up by name
}ACM cert must be in the same region as the API (HTTP API uses regional endpoints, not edge-optimized). If you need an edge cert, you'd typically front the API with CloudFront instead.
Handlers
Each handler is a normal APIGatewayProxyHandlerV2. The construct uses CDK's
NodejsFunction, so esbuild compiles your TypeScript automatically — no build
step needed.
// lambda/todos/list.ts
import { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb';
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const handler: APIGatewayProxyHandlerV2 = async () => {
const out = await ddb.send(new ScanCommand({ TableName: process.env.TODOS_TABLE_NAME! }));
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ items: out.Items ?? [] }),
};
};Your CDK app should have @aws-sdk/* packages and aws-lambda types installed:
npm install --save-dev @types/aws-lambda
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb(@aws-sdk/* is external in the bundle — Lambda's Node 20 runtime already
includes it.)
What about non-API stuff?
Just keep writing CDK normally. RoutedApi is one block in your stack — add
SES identities, processing lambdas, EventBridge rules, S3 buckets, whatever,
before or after it.
new RoutedApi(this, 'Api', { ... });
new lambda.Function(this, 'BackgroundProcessor', { ... }); // totally fine
new ses.EmailIdentity(this, 'EmailIdentity', { ... }); // also fineWorked example
See example/ in this folder:
example/routes.json— the routes fileexample/lambda/todos/{list,create,delete}.ts— handler filesexample/example-stack.ts— the CDK stack