Full-stack SPA pattern using TanStack Start (static SPA), AWS CDK v2, API Gateway HTTP API with Lambda-per-route, DynamoDB single-table, and S3+CloudFront
This skill covers building a single-user meal planning SPA with TanStack Start (TypeScript, static build), backed by AWS serverless infrastructure (API Gateway HTTP API, Lambda-per-route, DynamoDB single-table) managed by AWS CDK v2. The frontend is deployed as a static SPA to S3 behind CloudFront with client-side routing support. All services scale to zero for $0 cost at rest.
dist/client/ output for static hosting; file-based routing via @tanstack/react-routerNodejsFunction per API route — independent scaling, isolated bundles, fine-grained IAMPK/SK patterns; GSI enables alternate query access patterns@aws-sdk/lib-dynamodb| Name | Purpose | Maturity | Notes |
|---|---|---|---|
@tanstack/start | Full-stack SPA framework | Stable (v1, late 2024) | Also released as @tanstack/react-start — check npm for current name |
@tanstack/react-router | File-based typed routing | Stable (v1) | Bundled with TanStack Start |
@tanstack/react-query | Server-state management | Stable (v5) | v5 since Oct 2023; use useQuery/useMutation |
@tanstack/react-query-devtools | Dev-time query inspector | Stable (v5) | Import only in development |
aws-cdk-lib | CDK v2 unified package | Stable | No separate @aws-cdk/* packages needed |
aws-cdk-lib/aws-apigatewayv2-integrations | HTTP Lambda integration | Stable | Part of aws-cdk-lib |
@aws-sdk/client-dynamodb | DynamoDB low-level client | Stable (v3) | Use with DocumentClient wrapper |
@aws-sdk/lib-dynamodb | DynamoDB DocumentClient | Stable (v3) | Avoids AttributeValue marshaling |
date-fns | ISO week date formatting | Stable (v3) | format(date, "RRRR-'W'II") for 2026-W10 format |
aws-lambda | TypeScript types for Lambda handlers | Stable | APIGatewayProxyEventV2, APIGatewayProxyResultV2 |
Use @tanstack/start with TanStack Router (file-based) and TanStack Query v5 for the frontend. For infrastructure, use aws-cdk-lib v2 with NodejsFunction (esbuild-bundled TypeScript) for Lambda, HttpApi with HttpLambdaIntegration for API Gateway, TableV2 for DynamoDB, and S3BucketOrigin.withOriginAccessControl for CloudFront. All in a single CDK stack.
When to use: Deploying to S3/CloudFront without server-side rendering.
Trade-offs: No SSR (no SEO for dynamic content), simpler deployment; all API calls go directly to API Gateway from browser.
Configuration (app.config.ts):
import { defineConfig } from '@tanstack/start/config'
export default defineConfig({
server: {
preset: 'static', // Enables static SPA output
},
vite: {
css: {
modules: { localsConvention: 'camelCase' }, // CSS Modules camelCase
},
},
})
Build output: dist/client/ (static files ready for S3 upload)
CloudFront requirement: Configure custom error response: HTTP 404 → /index.html, response 200 to support client-side routing.
When to use: Independent scaling per route; isolated bundles; granular IAM.
Trade-offs: More CDK code than monolithic proxy; cold starts per function (acceptable for single-user).
CDK pattern:
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2'
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'
import { Runtime, Architecture } from 'aws-cdk-lib/aws-lambda'
const listRecipesFn = new NodejsFunction(this, 'ListRecipes', {
entry: 'lambda/recipes/list.ts',
handler: 'handler',
runtime: Runtime.NODEJS_18_X,
architecture: Architecture.ARM_64, // Graviton2: ~20% cheaper, faster cold starts
environment: { TABLE_NAME: table.tableName },
bundling: {
minify: true,
sourceMap: false,
target: 'es2020',
},
})
table.grantReadData(listRecipesFn)
httpApi.addRoutes({
path: '/api/recipes',
methods: [apigwv2.HttpMethod.GET],
integration: new HttpLambdaIntegration('ListRecipesInt', listRecipesFn),
})
When to use: SPA on different origin (CloudFront domain) calling API Gateway.
Trade-offs: HTTP API has built-in CORS (no Lambda response headers needed); simpler than REST API CORS.
const httpApi = new apigwv2.HttpApi(this, 'MealPlannerApi', {
corsPreflight: {
allowHeaders: ['Content-Type', 'Authorization'],
allowMethods: [
apigwv2.CorsHttpMethod.GET,
apigwv2.CorsHttpMethod.POST,
apigwv2.CorsHttpMethod.PUT,
apigwv2.CorsHttpMethod.PATCH,
apigwv2.CorsHttpMethod.DELETE,
apigwv2.CorsHttpMethod.OPTIONS,
],
allowOrigins: ['*'], // Restrict to CloudFront domain in production
maxAge: Duration.days(1),
},
})
When to use: Multiple entity types in one table with distinct access patterns.
Trade-offs: Cost-efficient, supports transactions within item collections; harder ad-hoc queries.
CDK construct (TableV2 is preferred over Table):
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'
const table = new dynamodb.TableV2(this, 'MealPlannerTable', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(),
removalPolicy: RemovalPolicy.DESTROY, // Dev/single-user: destroy on stack delete
globalSecondaryIndexes: [
{
indexName: 'GSI1',
partitionKey: { name: 'GSI1PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'GSI1SK', type: dynamodb.AttributeType.STRING },
},
],
})
Key pattern: PK=RECIPE#<id>, SK=METADATA for single-item fetch; PK=RECIPE#<id>, SK=INGREDIENT#<idx> for collection query by PK.
When to use: All Lambda handlers accessing DynamoDB.
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import {
DynamoDBDocumentClient,
GetCommand, PutCommand, QueryCommand, DeleteCommand,
TransactWriteCommand, BatchGetCommand,
} from '@aws-sdk/lib-dynamodb'
// Initialize outside handler for connection reuse across warm invocations
const ddbClient = new DynamoDBClient({})
const docClient = DynamoDBDocumentClient.from(ddbClient)
export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
const result = await docClient.send(new QueryCommand({
TableName: process.env.TABLE_NAME!,
KeyConditionExpression: 'PK = :pk',
ExpressionAttributeValues: { ':pk': 'RECIPE#<id>' },
}))
return { statusCode: 200, body: JSON.stringify(result.Items) }
}
When to use: Serving TanStack Start static build securely via CloudFront.
import * as s3 from 'aws-cdk-lib/aws-s3'
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'
const siteBucket = new s3.Bucket(this, 'SiteBucket', {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
})
const distribution = new cloudfront.Distribution(this, 'Distribution', {
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
},
defaultRootObject: 'index.html',
errorResponses: [
{
httpStatus: 404,
responsePagePath: '/index.html',
responseHttpStatus: 200,
},
],
})
// Upload build output and invalidate CloudFront cache
new s3deploy.BucketDeployment(this, 'DeployApp', {
sources: [s3deploy.Source.asset('./dist/client')], // TanStack Start static output
destinationBucket: siteBucket,
distribution,
distributionPaths: ['/*'], // Invalidate all paths after deploy
})
When to use: Managing all server state (API Gateway calls) from React components.
// src/routes/__root.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { createRootRoute, Outlet } from '@tanstack/react-router'
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000 } },
})
export const Route = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<Outlet />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
),
})
Query key convention:
['recipes'] — list all['recipes', id] — single recipe['meal-plan', weekId] — meal plan for week['shopping-list', weekId] — shopping list for weekWhen to use: Component-scoped styles without Tailwind.
*.module.css file is auto-processed by Viteimport styles from './Component.module.css'vite.css.modules.localsConvention: 'camelCase' in app.config.tsany by default (acceptable for most projects); add typed-css-modules CLI for strict typingsrc/routes/
__root.tsx # Root layout + QueryClientProvider
index.tsx # / → Dashboard
recipes/
index.tsx # /recipes → Recipe List
new.tsx # /recipes/new → Add Recipe
$id/
index.tsx # /recipes/:id → Recipe Detail
edit.tsx # /recipes/:id/edit → Edit Recipe
meal-plan.tsx # /meal-plan → Weekly Planner
shopping-list.tsx # /shopping-list → Shopping List
Each file uses createFileRoute('/path'):
export const Route = createFileRoute('/recipes')({
component: RecipeListPage,
})
infra/
bin/app.ts # CDK app entry: new App(); new MealPlannerStack(app, 'MealPlanner')
lib/stack.ts # Single stack with all resources
lambda/ # Lambda handler source files
recipes/
list.ts # GET /api/recipes
create.ts # POST /api/recipes
get.ts # GET /api/recipes/:id
update.ts # PUT /api/recipes/:id
delete.ts # DELETE /api/recipes/:id
meal-plans/
get.ts # GET /api/meal-plans/:weekId
upsert.ts # PUT /api/meal-plans/:weekId
generate-shopping.ts # POST /api/meal-plans/:weekId/shopping-list
shopping-lists/
get.ts # GET /api/shopping-lists/:weekId
toggle-item.ts # PATCH /api/shopping-lists/:weekId/items/:name
seed/
index.ts # POST /api/seed
shared/
db.ts # DynamoDB client singleton
types.ts # Shared TypeScript interfaces
.output/public/ for assets| Issue | Impact | Solution |
|---|---|---|
| SPA routing breaks on direct URL access | High | Add CloudFront custom error response: 404 → /index.html, HTTP 200 |
| Lambda cold starts on infrequent use | Low (single user) | Use arm64 (ARM_64) architecture; keep bundles small with minify |
| AWS SDK v3 not bundled = runtime error | High | Always bundle with esbuild (NodejsFunction default); do not use externalModules: ['@aws-sdk/*'] |
| DynamoDB GSI eventual consistency | Low | Single-user app: no concurrent writes; acceptable |
| CloudFront serving stale assets after deploy | Medium | Use BucketDeployment with distributionPaths: ['/*'] for auto-invalidation |
| CORS 403 on preflight in production | High | Use CloudFront domain in allowOrigins (not *) after initial dev |
| CSS Modules class name collisions | Low | Vite scopes by default; use descriptive class names |
| TanStack Start SPA build output directory changed | Medium | Check dist/client/ (current) vs .output/public/ (SSR); verify with npm run build |
| GSI added to existing DynamoDB table via CDK | Medium | CDK replaces GSI on update — can cause data loss; plan GSI upfront |
| NodejsFunction entry path is relative to CDK app root | Medium | Use path.join(__dirname, 'lambda/recipes/list.ts') for portability |
dist/client/ as BucketDeployment source: TanStack Start static SPA builds to dist/client/. Verify after first npm run build (may vary by version).bundleAwsSDK: true or let NodejsFunction bundle by default — never rely on Lambda runtime SDK version.dynamodb.TableV2 (the recommended CDK construct) with Billing.onDemand() — avoids deprecated BillingMode enum.table.grantReadData(fn) for GET-only Lambdas; table.grantReadWriteData(fn) for mutating Lambdas.db.ts in Lambda/shared: Keeps DynamoDB client singleton DRY across handlers (bundled into each Lambda, not a Layer).date-fns for ISO week IDs: format(date, "RRRR-'W'II") produces 2026-W10 format; tree-shakeable v3.# Frontend (TanStack Start SPA)
npm create tanstack@latest
# Or manual:
npm install @tanstack/start @tanstack/react-router @tanstack/react-query @tanstack/react-query-devtools date-fns
# CDK Infrastructure
mkdir infra && cd infra
npm init -y
npm install -D aws-cdk-lib constructs typescript @types/node
# Lambda dependencies (in infra/ or a separate lambda/ package)
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install -D @types/aws-lambda
# CDK CLI (global)
npm install -g aws-cdk
infra/cdk.json:
{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"watch": { "include": ["**"], "exclude": ["README.md", "cdk*.json", "node_modules"] }
}
infra/tsconfig.json (for CDK + Lambda):
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"outDir": "dist"
},
"include": ["bin/**/*", "lib/**/*", "lambda/**/*"]
}
VITE_API_URL=https://<api-id>.execute-api.<region>.amazonaws.comCfnOutput → can be consumed by CI/CD to set frontend envCfnOutput for CORS configurationshared/db.ts (DynamoDB client) and shared/types.ts (TypeScript interfaces) — bundled into each function by esbuild// lambda/recipes/list.ts
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import { DynamoDBDocumentClient, QueryCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}))
const TABLE = process.env.TABLE_NAME!
export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
try {
const tag = event.queryStringParameters?.tag
let items
if (tag) {
// Query via GSI1 for tag filter
const result = await client.send(new QueryCommand({
TableName: TABLE,
IndexName: 'GSI1',
KeyConditionExpression: 'GSI1PK = :tag',
ExpressionAttributeValues: { ':tag': `TAG#${tag}` },
}))
items = result.Items
} else {
// Scan for all recipes (single-user, small dataset — acceptable)
const result = await client.send(new ScanCommand({
TableName: TABLE,
FilterExpression: 'begins_with(PK, :prefix) AND SK = :meta',
ExpressionAttributeValues: { ':prefix': 'RECIPE#', ':meta': 'METADATA' },
}))
items = result.Items
}
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(items ?? []),
}
} catch (err) {
return { statusCode: 500, body: JSON.stringify({ error: 'Internal server error' }) }
}
}
// infra/lib/stack.ts
import { Stack, StackProps, RemovalPolicy, Duration, CfnOutput } from 'aws-cdk-lib'
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2'
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'
import { Runtime, Architecture } from 'aws-cdk-lib/aws-lambda'
import * as s3 from 'aws-cdk-lib/aws-s3'
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'
import * as path from 'path'
import { Construct } from 'constructs'
export class MealPlannerStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props)
// --- DynamoDB ---
const table = new dynamodb.TableV2(this, 'Table', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(),
removalPolicy: RemovalPolicy.DESTROY,
globalSecondaryIndexes: [{
indexName: 'GSI1',
partitionKey: { name: 'GSI1PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'GSI1SK', type: dynamodb.AttributeType.STRING },
}],
})
// --- HTTP API ---
const api = new apigwv2.HttpApi(this, 'Api', {
corsPreflight: {
allowHeaders: ['Content-Type'],
allowMethods: [apigwv2.CorsHttpMethod.ANY],
allowOrigins: ['*'],
},
})
// Helper to create a Lambda function for a route
const lambdaFn = (id: string, entry: string) => {
const fn = new NodejsFunction(this, id, {
entry: path.join(__dirname, '..', 'lambda', entry),
handler: 'handler',
runtime: Runtime.NODEJS_18_X,
architecture: Architecture.ARM_64,
environment: { TABLE_NAME: table.tableName },
bundling: { minify: true, target: 'es2020' },
})
table.grantReadWriteData(fn)
return fn
}
// Routes
api.addRoutes({
path: '/api/recipes',
methods: [apigwv2.HttpMethod.GET],
integration: new HttpLambdaIntegration('ListRecipes', lambdaFn('ListRecipes', 'recipes/list.ts')),
})
// ... repeat for each route ...
// --- S3 + CloudFront ---
const siteBucket = new s3.Bucket(this, 'SiteBucket', {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
})
const distribution = new cloudfront.Distribution(this, 'Distribution', {
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
},
defaultRootObject: 'index.html',
errorResponses: [{
httpStatus: 404,
responsePagePath: '/index.html',
responseHttpStatus: 200,
}],
})
new s3deploy.BucketDeployment(this, 'Deploy', {
sources: [s3deploy.Source.asset(path.join(__dirname, '../../dist/client'))],
destinationBucket: siteBucket,
distribution,
distributionPaths: ['/*'],
})
new CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint })
new CfnOutput(this, 'SiteUrl', { value: `https://${distribution.distributionDomainName}` })
}
}
// lambda/shared/types.ts
export interface RecipeMetadata {
PK: string // RECIPE#<id>
SK: 'METADATA'
GSI1PK?: string // TAG#<tag> (one item per tag)
GSI1SK?: string // RECIPE#<id>
title: string
description: string
servings: number
prepTime: number
cookTime: number
tags: string[]
}
export interface Ingredient {
PK: string // RECIPE#<id>
SK: string // INGREDIENT#<idx> (zero-padded)
name: string
quantity: number
unit: string
category: string
}
export interface MealPlanEntry {
PK: string // MEALPLAN#<weekId>
SK: string // <day>#<meal> e.g. mon#breakfast
recipeId: string
}
| Date | Changes |
|---|---|
| 2026-03-04 | Initial creation for task: implement-meal-planner-app.feature.md |