cd /news/developer-tools/i-tried-aws-blocks-on-a-real-amplify… · home topics developer-tools article
[ARTICLE · art-37545] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

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

A developer tested AWS Blocks, a new public preview from AWS, on a production Amplify Gen2 project with 40+ models. They found Blocks is additive, not a migration, and allows local DynamoDB development with 1-second refresh loops instead of minute-long deploy round-trips. The integration appended to the existing backend without rewriting any code.

read13 min views1 publishedJun 24, 2026

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.

-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

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.

// 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

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

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'sTable

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

── more in #developer-tools 4 stories · sorted by recency
── more on @aws 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-tried-aws-blocks-o…] indexed:0 read:13min 2026-06-24 ·