Intro
I've been running Amplify Gen2 in production for a while now, and the single most annoying part is this: every time I want to check something, I have to deploy. Tweak the backend, push it up with ampx sandbox, wait for it to apply, check, tweak again. Do that a dozen times a day and it quietly grinds you down.
Then on 2026-06-16, AWS Blocks showed up in public preview. It's a set of backend "Building Blocks" — auth, database, file storage, real-time, async jobs, AI agents — that you compose together, and the headline pitch is that you can develop entirely locally, with no AWS account.
The thing is, I read the preview announcement and it didn't actually answer the questions I cared about. Three of them, specifically:
- Is this something I migrate to from Amplify? Or something I add to my existing Amplify Gen2 app?
- Can I create a DynamoDB table locally? (this is the one I really wanted to know)
- How much faster does the dev loop actually get?
Staring at the announcement wasn't going to tell me, so I dropped it into a live production project — an Amplify Gen2 app with 40+ models — inside an isolated git worktree and actually tried it. I ran DynamoDB locally and pushed it all the way through cdk synth to verify.
Spoiler for the impatient: it's additive, not a migration, DynamoDB does run locally, and my verify loop went from "minutes of deploy round-trips" to "a 1-second local refresh." Below is the log. It's a preview, so I'm leaving the parts where I got stuck in.
Versions at the time of testing:
@aws-blocks/create-blocks-app@0.1.7/@aws-blocks/blocks@0.1.5. It's a preview, so expect this to move.
First question: is this a different thing from Amplify?
The official line is refreshingly blunt: "Blocks is not replacing Amplify. It's additive." There are two ways to use it:
- Add individual Blocks to an Amplify Gen2 app → you keep deploying through Amplify like always, you just get more parts.
- Use it standalone → full ownership, local-first, develop with no AWS account at all.
Same Blocks in both cases, no rewriting when you move between paths. It's CDK constructs all the way down, so when you run out of road you can drop to raw CDK.
The easy mistake here — and I made it at first — is assuming "there's a standalone mode, therefore this is a thing you move off Amplify onto." Nope. Standalone is just another option; it's not about throwing away your existing Amplify setup. But I don't trust marketing copy on faith, so let's put it on a real project and check.
Trying it on a real project (auto-detect)
The guinea pig: a pnpm workspace monorepo with apps/* and packages/*. The Amplify Gen2 backend lives in packages/gen2-shared-backend/amplify/backend.ts, and amplify/data/resource.ts has 40+ DynamoDB models defined with a.model() (User / Workspace / Project / BusinessModel / LeanCanvas …). This thing runs in production, so obviously I can't have it mangled.
To keep the real tree clean, I cut an isolated worktree before touching anything.
git worktree add ./<repo>.worktrees/aws-blocks-test --detach staging
cd ./<repo>.worktrees/aws-blocks-test/packages/gen2-shared-backend
Then I ran the CLI from where amplify/backend.ts lives. The AWS Blocks CLI looks at the directory it's run in and auto-detects the mode (Amplify detected / existing project / empty directory).
pnpm dlx @aws-blocks/create-blocks-app@latest . -y
Output:
🔍 Detected Amplify Gen 2 project (amplify/backend.ts found)
CREATE aws-blocks/ (Blocks backend workspace)
CREATE amplify/blocks.ts (wires Blocks into Amplify backend)
MODIFY amplify/backend.ts (adds import for blocks.ts)
MODIFY package.json (adds workspace, deps, scripts)
MODIFY .gitignore (adds Blocks entries)
Correctly recognized as Amplify Gen2. What I was nervous about was what happens to my existing 40 models, auth, storage, and pile of Lambda resolvers. I looked at the backend.ts diff and exhaled.
-const backend = defineBackend({
+export const backend = defineBackend({
auth,
data,
storage,
// ... existing resolvers, untouched ...
});
backend.addOutput({ /* existing */ });
+
+// Blocks integration — adds Building Blocks to your Amplify backend
+import { initBlocks } from './blocks.js';
+await initBlocks(backend);
This is an append, not a rewrite. It changed const to export const and added one initBlocks(backend) line at the end. Not a single line of the existing definitions in that 548-line backend.ts changed. Looking at the generated amplify/blocks.ts, Blocks gets wired in as a separate nested stack, and it passes Amplify's Cognito config (User Pool ID / Client ID) into the Blocks-side Lambda as environment variables.
// amplify/blocks.ts (generated, excerpt)
export async function initBlocks(backend: any) {
const blocksStack = backend.createStack('blocks');
const blocks = await createBlocksBackend(blocksStack, sandboxMode);
// Reuse Amplify's Cognito for Blocks' bearer-token verification
if (backend.auth?.resources?.cfnResources) {
const { cfnUserPool, cfnUserPoolClient } = backend.auth.resources.cfnResources;
blocks.handler.addEnvironment('COGNITO_USER_POOL_ID', cfnUserPool.ref);
blocks.handler.addEnvironment('COGNITO_CLIENT_ID', cfnUserPoolClient.ref);
}
// ...
}
So "additive, not a migration" checks out. The existing Amplify Data (i.e. your existing DynamoDB tables) stays exactly where it is, and the Blocks stack sits next to it. Auth reuses Amplify's Cognito. No moving day.
Gotcha: install blows up on a pnpm monorepo
It wasn't all smooth, though. After generating files, the CLI runs npm install internally on its own — and that fails.
npm error code EUNSUPPORTEDPROTOCOL
npm error Unsupported URL Type "workspace:": workspace:*
The cause: my test project is a pnpm workspace and its dependencies use pnpm's own workspace:* protocol. The AWS Blocks CLI is hardcoded to npm install, and on top of that it adds an npm-style workspaces array to the generated package.json. That's a double conflict with a pnpm monorepo.
The scaffold itself succeeds, so if you take dependency resolution into your own hands and do it the pnpm way, you can keep going. But it's not the "one npx command and you're done" experience. If you're dropping this into a pnpm monorepo, plan on owning the install yourself. It's a preview, so I'll give it a pass here.
Because of this collision, I decided to do the "actually touch DynamoDB locally" part in a separate standalone clean project. That let me verify the two things independently: the Amplify integration behavior (above) and the local execution proof (below).
The main event: creating DynamoDB locally
I scaffolded a standalone backend template. This one assumes npm, so it goes through cleanly.
pnpm dlx @aws-blocks/create-blocks-app@latest standalone --template backend -y
# → installs 284 packages, done in ~29 seconds
The backend entry point is aws-blocks/index.ts. That's where I add the DynamoDB Block. The AWS Blocks data Blocks are split by use case:
-
KVStore(@aws-blocks/bb-kv-store) = simple key-value (DynamoDB-backed) -
DistributedTable(@aws-blocks/blocks) = DynamoDB itself. Composite keys, GSIs, range queries — the docs call it "the default for most data."
I want to hit DynamoDB head-on, so DistributedTable it is. The schema is written in zod.
// aws-blocks/index.ts
import { ApiNamespace, Scope, DistributedTable } from '@aws-blocks/blocks';
import { z } from 'zod';
const scope = new Scope('my-app');
// DistributedTable = DynamoDB.
// Local dev: in-process mock / synth & deploy: real DynamoDB. Same code.
const noteSchema = z.object({
userId: z.string(),
noteId: z.string(),
body: z.string(),
createdAt: z.number(),
});
const notes = new DistributedTable(scope, 'notes', {
schema: noteSchema,
key: { partitionKey: 'userId', sortKey: 'noteId' },
});
export const api = new ApiNamespace(scope, 'api', (context) => ({
async putNote(userId: string, noteId: string, body: string) {
await notes.put({ userId, noteId, body, createdAt: Date.now() });
return { ok: true };
},
async getNote(userId: string, noteId: string) {
return await notes.get({ userId, noteId });
},
async listNotes(userId: string) {
return await Array.fromAsync(
notes.query({ where: { userId: { equals: userId } } }),
);
},
}));
Types hold all the way through (tsc --noEmit in ~2.7s). The key types are inferred from the schema, so TypeScript even enforces the key shape on get({ userId, noteId }). No code-gen step. Small thing, but a nice one.
Start the local server:
npm run dev
# Loading backend...
# Deploying local resources...
# 📝 Generating client code...
# AWS Blocks local server running on http://localhost:3001
It says Deploying local resources, but it isn't talking to AWS at all. Look at .blocks-sandbox/config.json and it's "environment": "local", with the API pointed at the local http://localhost:3001/aws-blocks/api (port 3000 was taken by another process, so it helpfully fell back to 3001 on its own).
From here, I hit the DynamoDB Block through the typed client.
getNote(alice,n1): {"userId":"alice","noteId":"n1","body":"first note","createdAt":1782292043870}
listNotes(alice) count: 2
listNotes(bob): [{"userId":"bob","noteId":"n1","body":"bob note",...}]
put×3 + get + query×2 round-trip: 31ms
It works. No AWS account, no deploy, and DynamoDB put / get / partition-key queries all run, with alice and bob properly partition-isolated. Three writes + a read + two queries, a 31ms round-trip. That's the part I'd otherwise be waiting minutes on after a deploy.
"It's just a local mock though, right?" — proving it with synth
The fair thing to be suspicious about: "OK, it runs locally. But if I actually deploy, does it really become DynamoDB?" This is the heart of AWS Blocks — @aws-blocks/blocks's package.json uses conditional exports to resolve the same import to a different thing depending on context.
".": { "browser": "...client...", "cdk": "...construct...", "default": "...local/lambda..." }
- Local dev (
default) → in-process mock -
cdk synth(cdk) → CDK construct (i.e. CloudFormation) - Browser (
browser) → typed RPC client
So I ran cdk synth on the exact same index.ts (it's synth, not deploy, so no AWS credentials needed).
npx cdk synth --quiet
Peeking into the generated CloudFormation template, there it was.
AWS::DynamoDB::Table count: 1
logicalId: myappnotestable...
BillingMode: PAY_PER_REQUEST
KeySchema: userId:HASH, noteId:RANGE
That one line, new DistributedTable(..., { key: { partitionKey: 'userId', sortKey: 'noteId' } }), resolves to an instant mock locally and a real AWS::DynamoDB::Table (HASH=userId / RANGE=noteId, on-demand billing) at synth. I didn't change a single character of the code. That's what "create DynamoDB locally" actually means under the hood.
What about GSIs and LSIs? (where I got tripped up once)
If you're going to use DynamoDB, indexes are obviously next on your mind. DistributedTable has an indexes option, so I tried adding two GSIs — and while I was at it, see if I could make an LSI too.
const notes = new DistributedTable(scope, 'notes', {
schema: noteSchema, // userId, noteId, status, createdAt, body
key: { partitionKey: 'userId', sortKey: 'noteId' },
indexes: {
// same PK(userId) + different SK(createdAt) → in raw DynamoDB this is an LSI candidate
byCreatedAt: { partitionKey: 'userId', sortKey: 'createdAt' },
// different PK(status) → clearly a GSI
byStatus: { partitionKey: 'status', sortKey: 'createdAt' },
},
});
Locally it all just worked. byCreatedAt returned createdAt in ascending order (100, 200, 300), and byStatus pulled across statuses fine.
But then I looked at the cdk synth'd CloudFormation and went "wait, huh?" for a second.
GlobalSecondaryIndexes: []
LocalSecondaryIndexes: []
AttributeDefinitions: [userId, noteId] # no createdAt, no status
The table itself has no GSI and no LSI. My first reaction was "oh no, the indexes don't make it into synth…" — too quick. Digging through the whole template, they were elsewhere. A Custom::CloudFormation::CustomResource (my-app-notes-gsi-resource) had byCreatedAt and byStatus sitting right there in its Indexes property. And there was a whole BlocksGsiManager Lambda set generated alongside it (the Provider framework's onEvent / isComplete / Step Functions waiter).
So GSIs aren't inline CFN — they're managed by a dedicated custom resource plus a GSI Manager Lambda. The reason is in DESIGN.md, and it made sense once I read it:
DynamoDB only allows one GSI change per
UpdateTable. Standard CDK'sTablecan't express multiple GSI changes in a single deploy. So we manage them declaratively with a custom resource.
For production deploys it applies GSIs one at a time, in sequence (just as DynamoDB requires); for sandbox it uses a fast path that drops the whole table and recreates it with all GSIs attached. Given that adding a single GSI can take minutes to hours in production, this is a design that took that reality seriously.
LSIs, on the other hand, aren't supported. indexes is exactly what the name says — Global secondary index only — so even my byCreatedAt (same PK + different SK), which I'd written thinking of it as an LSI candidate, is treated as a GSI. DESIGN.md only ever says "partition key + optional sort key + GSIs," and the GSI Manager only ever calls GlobalSecondaryIndexUpdates. DynamoDB's LSIs can only be created at table-creation time, and since the whole thing commits to the after-the-fact UpdateTable path, there's simply no way to create an LSI. That's the reality.
| Supported | At deploy time | Locally | |
|---|---|---|---|
| GSI | ✅ Yes | Custom Resource + GSI Manager Lambda (sequential UpdateTable) |
Queries work (in-memory filtering) |
| LSI | ❌ Effectively no | No way to generate one (same PK+SK is treated as a GSI too) | The index-specified query runs, but it's not an LSI |
One caveat. The local indexes are implemented as "filter all records in-memory," so the eventual consistency of a real GSI (local is immediately consistent) and throughput throttling are not reproduced. You can verify access-pattern correctness locally, but check consistency and performance in sandbox — DESIGN.md spells this out too. Worth just following their advice here.
Speed: how the loop actually changed
Measured numbers (standalone, dependencies already installed).
| Metric | Measured |
|---|---|
| scaffold + dependency install (cold) | ~29s (284 packages) |
tsc --noEmit (typecheck) |
~2.7s |
npm run dev start → local ready |
~1.23s |
| edit one handler line → re-apply (HMR) | ~1.03s |
| DynamoDB local round-trip (put×3+get+query×2) | 31ms |
What matters isn't the numbers themselves — it's that verifying doesn't need AWS. With normal Amplify Gen2 development, every small backend change means waiting on an ampx sandbox deploy round-trip (tens of seconds to minutes). That's exactly the "annoying" I described up top. AWS Blocks' local-first development swaps that round-trip for a 1-second file refresh.
To be fair: this isn't an apples-to-apples, head-to-head comparison. I didn't benchmark Amplify's
ampx sandboxreal deploy here (it changes cloud state). The premise of the comparison is the structural difference — "local, seconds" vs "deploy, minutes." For an actual Amplify full-build measurement, my other post ("Speeding up Amplify Gen2 builds with a custom Docker image") has the 9m43s→8m45s numbers.
Aside: it plays well with AI agents
This is off the main thread, but it was honestly my favorite part. AWS Blocks calls itself "agent-native," and the npm packages ship with steering files. Scaffolding drops an AGENTS.md, and per-Block usage docs live at node_modules/@aws-blocks/blocks/docs/<package>.md.
AGENTS.md has rules like:
- Use Building Blocks for all persistence — never local files, in-memory arrays, or local databases.
- Read block docs at
node_modules/@aws-blocks/blocks/docs/<package-name>.mdbefore using a block.- The JSON-RPC transport is invisible — do not construct RPC payloads manually.
Basically it's pre-empting the agent: "always use a Block for persistence, don't fake it with local files or in-memory arrays." And in the GSI story above, the DistributedTable docs even warn about a trap AI tends to fall into: "call data methods inside the handler. At the top level you're in a synth context, so you'll crash with table.get is not a function." Because this etiquette is right there in your node_modules before you write a line, an agent is much less likely to wander off in a weird direction. Combine that with instant local verification and the write-with-AI → run-it-immediately cycle tightens up nicely.
Wrap-up
Conclusions from trying it on a real project.
-
Additive, not a migration. Your Amplify Gen2
backend.tsgets exactly oneinitBlocks(backend)line, and the existing 40 models / auth / storage / resolvers are untouched. Blocks coexists as a separate nested stack and reuses Amplify's Cognito. Standalone is "another option," not a move. -
You can create DynamoDB locally. One
DistributedTablegives you an in-process mock for local dev (put/get/query in 31ms, no AWS account) and a realAWS::DynamoDB::Tableatcdk synth. Same code. -
GSIs work, but via a custom resource. Not inline CFN — a GSI Manager Lambda applies them sequentially with
UpdateTable. LSIs aren't supported. If your table design leans on indexes, knowing this up front saves you a nasty surprise. - The loop changes. Dev start 1.2s, handler-edit re-apply 1s. "Can't verify without deploying" becomes "instant local refresh."
-
But there's preview-grade friction. On a pnpm monorepo, the CLI's internal
npm installdies onworkspace:*, so plan on owning the install.
It's not "ditch Amplify and switch." It's "add local development to Amplify Gen2 and pull verification back from the cloud to your laptop." If I had to describe AWS Blocks in one line, that's it. If heavy Amplify development sounds familiar to you, the fastest thing is probably to scaffold the standalone template and run npm run dev once. And if you've got "here's how I used it" stories around GSI/LSI, I'd love to hear them.
Top comments (0)