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

> Source: <https://dev.to/coa00/i-tried-aws-blocks-on-a-real-amplify-gen2-project-local-dynamodb-no-aws-account-1-second-loops-4gm3>
> Published: 2026-06-24 10:00:06+00:00

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:

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.

The official line is refreshingly blunt: **"Blocks is not replacing Amplify. It's additive."** There are two ways to use it:

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.

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.

``` js
-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.

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).

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.I want to hit DynamoDB head-on, so `DistributedTable`

it is. The schema is written in zod.

``` js
// 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.

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..." }
```

`default`

) → in-process mock`cdk synth`

(`cdk`

) → CDK construct (i.e. CloudFormation)`browser`

) → typed RPC clientSo 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.

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.

``` js
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'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.

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.

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 Blocksfor all persistence — never local files, in-memory arrays, or local databases.Read block docsat`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.

Conclusions from trying it on a real project.

`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.`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.`UpdateTable`

. `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.
