close

DEV Community

Kohei Aoki
Kohei Aoki

Posted on

I Tried AWS Blocks on a Real Amplify Gen2 Project — Local DynamoDB, No AWS Account, 1-Second Loops

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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:*
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 } } }),
    );
  },
}));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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..." }
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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' },
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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's Table can'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 sandbox real 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>.md before 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.ts gets exactly one initBlocks(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 DistributedTable gives you an in-process mock for local dev (put/get/query in 31ms, no AWS account) and a real AWS::DynamoDB::Table at cdk 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 install dies on workspace:*, 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.

References

Top comments (0)