Every serverless tutorial has the same joyless first act. Install the CLI. Create an IAM role. Write a stack file that provisions a DynamoDB table. Write a second file that grants a Lambda permission to read that table. Write a third file that wires an API Gateway route to the Lambda. Generate a client SDK. Configure the endpoint URL in your frontend .env. Then, forty minutes in, write the six lines of business logic you actually wanted.
AWS Blocks, which AWS put into public preview on 16 June 2026, deletes that first act. It is an open-source TypeScript framework where a single line — new DistributedTable(scope, 'todos', { schema, key }) — simultaneously is the infrastructure definition, the runtime API, and a local implementation that runs in memory with no AWS account attached. The same line becomes a DynamoDB table with GSIs at deploy time and an AWS SDK call inside Lambda at runtime.
The trick is Node.js conditional exports. When you import a Block, the module resolver hands you a different file depending on the execution context: an in-memory mock during npm run dev, a CDK construct during synthesis, an SDK wrapper inside Lambda. You never configure this. You never see it. You just write application code, and the infrastructure is derived from it.
This tutorial builds a real application with it: an authenticated task tracker with structured queries, live WebSocket updates, file attachments, a nightly cleanup job, and an AI assistant backed by Amazon Bedrock. Everything runs on your laptop first. Deployment is the last chapter, and it is three commands.
Prerequisites
Before starting, make sure you have:
- Node.js 22 or later and npm 10 or later. AWS Blocks depends on
Array.fromAsyncand modern conditional-export resolution, so older Node versions will fail in confusing ways. Check withnode --version. - Working TypeScript knowledge. You should be comfortable with generics and async iterators. You do not need to know AWS CDK, CloudFormation, or DynamoDB modelling.
- A code editor with TypeScript support — VS Code, Kiro, or anything with a TypeScript language server. Much of the value here is IntelliSense flowing from backend to frontend.
- Optional, only for the deployment chapter: an AWS account, AWS CLI v2 configured, and CDK bootstrapped. Chapters 1 through 7 need none of this.
Steps 1 through 7 require no AWS account, no credentials, and no internet connection. You can complete the entire application offline. AWS only enters the picture in Step 8.
What You'll Build
A multi-user task tracker where each signed-in user has private tasks, sees updates pushed live across browser tabs, can attach files to a task, gets stale tasks archived automatically every night, and can ask an AI assistant to summarise their workload.
Mapped to Blocks, that is:
| Feature | Block | Local | On AWS |
|---|---|---|---|
| Username/password login | AuthBasic | Local JWT | DynamoDB user records |
| Task storage and queries | DistributedTable | In-memory | DynamoDB + GSI |
| Type-safe API | ApiNamespace | Local HTTP server | API Gateway + Lambda |
| Live updates | Realtime | EventEmitter | API Gateway WebSocket |
| File attachments | FileBucket | .bb-data/ folder | S3 with presigned URLs |
| Nightly archive | CronJob | Node timers | EventBridge Scheduler |
| AI assistant | Agent | Canned provider | Amazon Bedrock |
Seven backend capabilities, one file, zero YAML.
Step 1: Scaffold the Project
AWS Blocks ships a create package. Run it:
npm create @aws-blocks/blocks-app@latest task-tracker
cd task-tracker
npm installYou get a deliberately small tree:
task-tracker/
├── aws-blocks/
│ └── index.ts # the backend: Blocks + API definitions
├── src/
│ └── index.ts # the frontend
├── index.html
└── package.json
Two directories. aws-blocks/index.ts is what the docs call the IFC layer — Infrastructure From Code. It is simultaneously your infrastructure definition and your backend runtime. src/ is a normal frontend that imports the backend directly.
Other templates are available with --template: nextjs, react, auth-cognito, demo, bare, backend, and amplify. Running the command inside an existing project — pass . as the directory — adds an aws-blocks/ backend to it, and the CLI auto-detects an Amplify Gen 2 project if one is present.
Start the dev server:
npm run devOpen http://localhost:3000. A working todo app with sign-up, sign-in, and CRUD is already running — entirely in-process, with no cloud connection. Hot reload is active.
Step 2: Understand Scope and Blocks
Open aws-blocks/index.ts. Everything begins with a Scope:
import { Scope, KVStore } from '@aws-blocks/blocks';
const scope = new Scope('task-tracker');
const cache = new KVStore(scope, 'cache', {});
const sessions = new KVStore(scope, 'sessions', {});
// Full IDs: task-tracker/cache and task-tracker/sessionsA Scope is a namespace container. Every Block must be created inside one, and the Block's full identifier is derived from the scope name plus the ID you pass as the second argument. That identifier is what determines the physical AWS resource name.
Block IDs are permanent. Renaming the second constructor argument after deployment causes CloudFormation to delete and recreate the underlying resource. For stateful Blocks — KVStore, DistributedTable, Database, FileBucket — that means irreversible data loss. Treat Block IDs as immutable once they have shipped to any environment you care about.
The same KVStore instance exposes an identical API in all three contexts:
await cache.put('user:123', { name: 'Alice' });
const user = await cache.get('user:123');Locally, that writes to a .bb-data/ directory at your project root. In Lambda, it is a DynamoDB PutItem. Your code does not know or care.
Step 3: Model Tasks with DistributedTable
KVStore handles lookups when you always know the key. Our task tracker needs to query by user and by status, so we want DistributedTable: schema-validated structured storage with secondary indexes.
Replace the contents of aws-blocks/index.ts:
import { ApiNamespace, Scope, DistributedTable, AuthBasic } from '@aws-blocks/blocks';
import { z } from 'zod';
const scope = new Scope('task-tracker');
const auth = new AuthBasic(scope, 'auth', {
sessionDuration: 86400,
passwordPolicy: { minLength: 8, requireDigits: true },
});
export const authApi = auth.createApi();
const taskSchema = z.object({
userId: z.string(),
taskId: z.string(),
title: z.string().min(1).max(200),
status: z.enum(['open', 'done', 'archived']),
createdAt: z.string(),
attachmentPath: z.string().optional(),
});
const tasks = new DistributedTable(scope, 'tasks', {
schema: taskSchema,
key: { partitionKey: 'userId', sortKey: 'taskId' },
indexes: {
byStatus: { partitionKey: 'status', sortKey: 'createdAt' },
},
});Three things happened. The Zod schema became both compile-time types and runtime validation. The key config became the DynamoDB primary key. The indexes block became a Global Secondary Index — and DynamoDB GSI modelling, normally the hardest part of a serverless project, is now four words.
Never pass a single explicit type argument to DistributedTable. Writing new DistributedTable<Task>(...) pins only the item type and lets the key and index generics fall back to broad defaults, which breaks key inference — get() will then demand every field of your type instead of just the key fields. Let all generics infer, or pass all three. Adding as const does not fix it.
Query semantics
query() returns an AsyncIterable, not an array. This is deliberate: it maps directly onto DynamoDB's paginated query model, so you get automatic pagination without writing a cursor loop.
// Query the primary key — note the type-safe `where` operators
for await (const task of tasks.query({
where: { userId: { equals: 'alice' }, taskId: { beginsWith: '2026-' } },
})) {
console.log(task.title);
}
// Query the GSI
for await (const task of tasks.query({
index: 'byStatus',
where: { status: { equals: 'open' } },
limit: 25,
order: 'desc',
})) {
console.log(task.title);
}To collect results eagerly, use await Array.fromAsync(tasks.query({ ... })). Prefer query() over scan() — a scan reads every item in the table.
Data methods are runtime-only. Calling tasks.put(), tasks.query(), rt.publish() and friends at the top level of aws-blocks/index.ts throws tasks.put is not a function. Top-level code runs during CDK synthesis, where the Block resolves to its infrastructure construct and has no data methods at all. Constructing a Block at module scope is fine — only method calls must live inside a handler. To seed data, do it from inside an API method or a separate runtime script.
Step 4: Expose a Type-Safe API with ApiNamespace
ApiNamespace is the bridge from browser to backend. It is RPC, not REST: you define methods, the frontend calls them, TypeScript checks both sides. There is no code generation step and no OpenAPI file.
Append to aws-blocks/index.ts:
export const api = new ApiNamespace(scope, 'api', (context) => ({
async createTask(title: string) {
const user = await auth.requireAuth(context);
const task = {
userId: user.username,
taskId: crypto.randomUUID(),
title,
status: 'open' as const,
createdAt: new Date().toISOString(),
};
await tasks.put(task);
return task;
},
async listTasks() {
const user = await auth.requireAuth(context);
return await Array.fromAsync(
tasks.query({ where: { userId: { equals: user.username } } })
);
},
async completeTask(taskId: string) {
const user = await auth.requireAuth(context);
const task = await tasks.get({ userId: user.username, taskId });
if (!task) throw new Error('Task not found');
const updated = { ...task, status: 'done' as const };
await tasks.put(updated);
return updated;
},
async deleteTask(taskId: string) {
const user = await auth.requireAuth(context);
await tasks.delete({ userId: user.username, taskId });
},
}));
export { auth };The context parameter is a BlocksContext — the per-request object carrying headers and cookies. You never construct it; the framework supplies one per incoming request. Auth Blocks use it to read the session cookie, which is why auth.requireAuth(context) is the single line that secures a method. It throws a 401 SessionExpiredException when there is no valid session, so an unauthenticated call never reaches your storage layer.
Note the multi-tenancy pattern: user.username is the partition key on every read and write. A user physically cannot query another user's rows, because the partition key is derived from the verified session rather than from a client-supplied argument.
Calling it from the frontend
In src/index.ts:
import { api, authApi } from 'aws-blocks';
const task = await api.createTask('Ship the tutorial');
console.log(task.taskId);
const all = await api.listTasks();That is the whole client integration. No base URL, no fetch wrapper, no generated SDK, no Authorization header plumbing. Change createTask to take a second argument in the backend and your frontend shows a compile error before you ever hit save on the browser.
Step 5: Push Live Updates with Realtime
Two browser tabs should stay in sync. Realtime gives you typed WebSocket pub/sub with Zod-validated payloads.
import { Realtime } from '@aws-blocks/blocks';
const rt = new Realtime(scope, 'live', {
namespaces: {
tasks: Realtime.namespace(
z.object({
event: z.enum(['created', 'completed', 'deleted']),
taskId: z.string(),
title: z.string(),
})
),
},
});Publishing happens server-side, inside an API method — which is exactly where your authorization logic already lives:
async createTask(title: string) {
const user = await auth.requireAuth(context);
const task = { /* ... as above ... */ };
await tasks.put(task);
await rt.publish('tasks', user.username, {
event: 'created',
taskId: task.taskId,
title,
});
return task;
},The second argument is the channel. Using user.username as the channel name means each user gets a private stream inside the shared tasks namespace.
Channel handles are deliberately subscribe-only — they have no publish() method — so a client can never broadcast directly. The recommended pattern is to gate subscription behind an API method that returns the handle only after checking permissions:
async subscribeToMyTasks() {
const user = await auth.requireAuth(context);
return rt.getChannel('tasks', user.username);
},On the client, subscribe and always await established before relying on the connection. That promise resolves once the WebSocket handshake and server-side authorization have both completed, and rejects on auth failure:
const channel = await api.subscribeToMyTasks();
const sub = channel.subscribe((msg) => {
console.log(`${msg.event}: ${msg.title}`);
refreshTaskList();
});
await sub.established;
// later
sub.unsubscribe();Locally, this runs on an in-process EventEmitter with a local WebSocket server. On AWS it becomes an API Gateway WebSocket API with DynamoDB connection management. Realtime is sized for channels with tens to low thousands of concurrent subscribers — publish latency scales roughly linearly, around 100ms for 1,000 subscribers. Past 10,000 subscribers per channel you want an explicit sharded fan-out instead.
Step 6: File Attachments and Scheduled Work
FileBucket
Attachments should never round-trip through your Lambda. FileBucket issues presigned URLs so the browser uploads straight to S3:
import { FileBucket } from '@aws-blocks/blocks';
const attachments = new FileBucket(scope, 'attachments', {
corsRules: [
{
allowedOrigins: ['http://localhost:3000'],
allowedMethods: ['GET', 'PUT'],
allowedHeaders: ['*'],
},
],
lifecycleRules: [{ prefix: 'tmp/', expirationDays: 7 }],
removalPolicy: 'destroy',
});Then inside the API namespace:
async getUploadUrl(taskId: string, fileName: string) {
const user = await auth.requireAuth(context);
const path = `${user.username}/${taskId}/${fileName}`;
const url = await attachments.putUrl(path);
return { url, path };
},
async attachFile(taskId: string, path: string) {
const user = await auth.requireAuth(context);
const task = await tasks.get({ userId: user.username, taskId });
if (!task) throw new Error('Task not found');
await tasks.put({ ...task, attachmentPath: path });
},
async getDownloadUrl(taskId: string) {
const user = await auth.requireAuth(context);
const task = await tasks.get({ userId: user.username, taskId });
if (!task?.attachmentPath) return null;
return attachments.getUrl(task.attachmentPath);
},Note getUrl() and get() return null for a missing file rather than throwing — check for null explicitly. Locally, files land in .bb-data/ on your filesystem, mirroring S3 API behaviour closely enough that presigned-URL flows work identically.
Set removalPolicy: 'destroy' only on sandbox and ephemeral stacks. CDK's default for S3 buckets is RETAIN, which is the behaviour you want in production — it prevents an accidental npm run destroy from deleting user data.
CronJob
Archive anything left open for more than 30 days:
import { CronJob } from '@aws-blocks/blocks';
const archiveStale = new CronJob(scope, 'archive-stale', {
schedule: 'cron(0 3 * * ? *)',
timezone: 'Africa/Tunis',
description: 'Archive tasks left open for over 30 days',
handler: async (event) => {
const cutoff = new Date(Date.now() - 30 * 86400_000).toISOString();
for await (const task of tasks.query({
index: 'byStatus',
where: { status: { equals: 'open' }, createdAt: { lessThan: cutoff } },
})) {
await tasks.put({ ...task, status: 'archived' });
}
},
});EventBridge cron expressions have six fields — cron(minute hour day-of-month month day-of-week year) — and one of day-of-month or day-of-week must be ?. For simple intervals, rate expressions are clearer: rate(5 minutes), rate(1 hour), rate(7 days). Cron handlers must be idempotent; EventBridge guarantees at-least-once delivery, so a double invocation must not corrupt state. The loop above is safe because setting status to 'archived' twice is the same as setting it once.
AsyncJob
For fire-and-forget work triggered by user actions rather than a clock, use AsyncJob:
import { AsyncJob } from '@aws-blocks/blocks';
const notify = new AsyncJob(scope, 'notify', {
schema: z.object({ to: z.string().email(), taskTitle: z.string() }),
maxRetries: 3,
handler: async (payload, ctx) => {
console.log(`Job ${ctx.jobId}, attempt ${ctx.receiveCount}`);
await sendEmail(payload.to, `Task completed: ${payload.taskTitle}`);
},
});
// From an API method — returns immediately
const { jobId } = await notify.submit({ to: user.username, taskTitle: title });submit() returns as soon as the message is enqueued, so the API response is not blocked. On AWS this provisions an SQS queue plus a dead-letter queue; locally it runs in-process via setTimeout, with retries, DLQ behaviour and the 256 KB payload limit enforced identically. Use submitBatch() for up to 10 payloads at once.
Step 7: Add an AI Assistant
The Agent Block is powered by the Strands Agents SDK and gives you streaming, tool calling, human-in-the-loop approval, and conversation persistence.
import { Agent, BedrockModels } from '@aws-blocks/blocks';
const assistant = new Agent(scope, 'assistant', {
model: { deployed: BedrockModels.BALANCED },
systemPrompt:
'You help users manage their task list. Be concise. ' +
'Use the listOpenTasks tool before answering questions about workload.',
streamingMode: 'token',
tools: (tool) => ({
listOpenTasks: tool({
description: 'List the current user open tasks',
parameters: z.object({ userId: z.string() }),
execute: async ({ userId }) =>
Array.fromAsync(
tasks.query({
where: { userId: { equals: userId } },
})
),
}),
}),
});BedrockModels.BALANCED currently maps to Claude Sonnet 4.6 and is the recommended default; BedrockModels.SMART maps to Claude Opus 4.8 for harder reasoning. These are capability-named presets, so the underlying model can be upgraded without changing your code. They use global inference profiles, meaning requests may route to any supported region — if you have data-residency requirements, specify a region-scoped inference profile explicitly instead.
Locally, the Agent uses a canned keyword-based provider: predictable canned responses, no API key, no cost, no network. That makes agent code testable in CI. To test against a real model locally, point it at Ollama or any OpenAI-compatible endpoint:
model: {
deployed: BedrockModels.BALANCED,
local: {
provider: 'openai-api',
modelId: 'llama3.1:8b',
endpoint: 'http://localhost:11434/v1',
apiKey: 'ollama',
},
},Streaming correctly
stream() submits the message through AsyncJob and returns immediately — there is no API Gateway timeout risk on long agent runs — then publishes chunks to a Realtime channel. Subscribe before you send, or you will drop the opening tokens:
const conversationId = await assistant.createConversationId(userId);
const channel = await assistant.getChannel(conversationId);
const sub = channel.subscribe((chunk) => {
if (chunk.type === 'text-delta') appendToUI(chunk.text);
if (chunk.type === 'tool-call') showSpinner(chunk.toolName);
if (chunk.type === 'done') finish(chunk.text, chunk.usage);
});
await sub.established;
const result = await assistant.stream('What should I work on today?', {
conversationId,
userId,
});
const done = await result.complete();The Agent Block does not authorize reads. getConversation(id) and getPendingInterrupts(id) take only an ID — any caller with a valid conversation UUID gets the messages back. Authorization is your responsibility in the API handler: derive userId from the session, call listConversations(userId), and confirm the conversation belongs to that user before returning anything. deleteConversation(id, userId) is owner-scoped internally and is safe.
Step 8: Deploy to AWS
Now, and only now, do you need an AWS account.
One-time setup — configure the AWS CLI, verify it, and bootstrap CDK for your account and region:
aws sts get-caller-identity
npx cdk bootstrap aws://123456789012/eu-west-1Bootstrapping is required once per account-and-region pair.
Then deploy to a sandbox — a fast, ephemeral, per-developer environment that uses Lambda hot-swapping rather than full CloudFormation updates:
npm run sandboxThis takes seconds, not minutes, and every Block now resolves to real AWS services: DynamoDB tables, an API Gateway endpoint, a Lambda function, an SQS queue, an EventBridge schedule, an S3 bucket. Your application code is byte-for-byte identical to what ran locally.
Sandboxes matter because local implementations are faithful but not perfect. Things worth testing against real services include DynamoDB query performance on realistic data volumes, IAM permission boundaries, S3 CORS behaviour from a real browser origin, and actual Bedrock model output versus the canned provider.
For staging or production, run a full CloudFormation deployment:
npm run deployTear-down commands:
npm run sandbox:destroy # remove your ephemeral sandbox
npm run destroy # remove the full deploymentStep 9: Escape to CDK When You Need To
Infrastructure-from-code frameworks usually fail at the boundary: the moment you need one resource the framework does not model, you are stuck. AWS Blocks handles this with an optional CDK layer at aws-blocks/index.cdk.ts. If you never create the file, one is generated for you.
// aws-blocks/index.cdk.ts
import * as cdk from 'aws-cdk-lib';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import { BlocksStack } from '@aws-blocks/blocks/cdk';
const app = new cdk.App();
const stack = await BlocksStack.create(app, 'task-tracker-stack', {
backendHandlerPath: './index.handler.ts',
backendCDKPath: './index.ts',
});
const queue = new sqs.Queue(stack, 'legacy-queue');
queue.grantSendMessages(stack.handler);
stack.handler.addEnvironment('QUEUE_URL', queue.queueUrl);You get the raw CDK Stack and the backend Lambda as stack.handler, so any construct in the CDK ecosystem is available — custom domains, VPC configuration, SNS topics, existing resources. There is no wall to hit.
The same principle applies to adopting existing infrastructure. KVStore.fromExisting(tableName), DistributedTable.fromExisting(tableName) and FileBucket.fromExisting(bucketName) wrap resources you already own instead of provisioning new ones — so you can put a Blocks API in front of a production DynamoDB table without migrating anything.
Testing Your Implementation
Verify locally, in order:
- Auth boundary. Call
api.listTasks()before signing in. You should get a 401SessionExpiredException, not an empty array. If you get an empty array, a handler is missingrequireAuth. - Tenant isolation. Sign up two users, create tasks as each, and confirm neither sees the other's rows.
- Schema validation. Call
api.createTask('')and confirm the Zodmin(1)constraint rejects it. Validation runs on the server, not just in the browser. - Real-time fan-out. Open two tabs signed in as the same user. Creating a task in one should appear in the other without a refresh.
- Type safety. Add a parameter to a backend method and confirm your frontend fails to compile before you run it. This is the property the whole framework exists to give you.
- Persistence. Restart
npm run dev.KVStoreandFileBucketdata survives in.bb-data/;DistributedTableis in-memory locally and does not.
Troubleshooting
tasks.put is not a function — You called a data method at the top level of aws-blocks/index.ts. That code runs during CDK synthesis, where the Block is an infrastructure construct with no data methods. Move the call inside an API method, job handler, or runtime script.
get() demands every field of my type — You passed a single explicit generic: new DistributedTable<Task>(...). Remove it and let inference do its job, or supply all three generics.
ZodType missing properties — The Agent Block requires Zod 4 as a peer dependency. Check for a duplicated or older Zod in your lockfile.
Early agent tokens are missing — You called stream() before subscribing. Subscribe, await sub.established, then send.
Resource recreated and data gone after deploy — A Block ID changed. Block IDs are the resource identity; renaming one is a delete-and-recreate. Restore from a backup and revert the ID.
Node version errors — AWS Blocks requires Node 22+. Run nvm use 22.
AWS Blocks vs Amplify, SST, and Encore
Blocks is not Amplify Gen 2 rebranded. Amplify is a hosting-plus-backend platform with a console; Blocks is a library that generates CDK, with no console and no managed control plane — and the CLI explicitly integrates with an existing Amplify Gen 2 project rather than replacing it.
Compared with SST Ion, Blocks trades breadth for a much tighter local story: SST's dev mode proxies to real deployed AWS resources, while Blocks runs genuine local implementations with no cloud account at all. Compared with Encore.ts, the RPC-and-inferred-infrastructure philosophy is close, but Blocks emits standard CDK you can read, extend, and eventually walk away from. Compared with Alchemy, the two answer the same question on different clouds.
The framing AWS chose at launch is that Blocks is designed so AI coding agents produce correct backends on the first attempt: the surface area is small, the naming is unambiguous, and there is no way to write a Lambda that lacks permission to read its own table. Whether or not you write code with an agent, that constraint produces a pleasant framework for humans too.
Next Steps
- Swap
AuthBasicforAuthCognitobefore production — you get MFA, SAML, social sign-in and passkeys, andAuthBasicis explicitly scoped to prototypes and internal tools. - Replace
DistributedTablewithDatabaseif your access patterns need JOINs. It runs PGlite locally and Aurora Serverless v2 on AWS, with Kysely for typed SQL. - Add
KnowledgeBasealongsideAgentfor a RAG pipeline over your own documents. - Wire up
Logger,MetricsandTracer, then addDashboardto auto-generate a CloudWatch dashboard from your metric definitions. - Generate native clients. A
blocks.spec.jsonproduces type-safe Kotlin Multiplatform, Swift and Dart clients that call the same backend over JSON-RPC. - Related reading: Building MCP servers in TypeScript, Cloudflare Workflows for durable execution, and Temporal durable workflows.
Conclusion
AWS Blocks is the first infrastructure-from-code framework from AWS itself that does not feel like a trap. The local story is genuinely local — no account, no credentials, no network — which changes how a team onboards and how CI runs. The type safety is real end-to-end type inference, not codegen you have to remember to re-run. And the CDK escape hatch means the framework's opinions are a starting point rather than a ceiling.
The preview caveats are real: Block IDs are unforgiving, AuthBasic is not production auth, the Agent Block leaves read authorization to you, and the API surface will shift before GA. But the core bet — that the line of code creating a table should be the table — is one AWS has been circling for a decade, and this is the closest anyone has come to landing it.
Start with npm create @aws-blocks/blocks-app@latest. You will have a working authenticated backend before you have finished deciding whether you like it.