{"slug": "i-tried-aws-blocks-on-a-real-amplify-gen2-project-local-dynamodb-no-aws-account", "title": "I Tried AWS Blocks on a Real Amplify Gen2 Project — Local DynamoDB, No AWS Account, 1-Second Loops", "summary": "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.", "body_md": "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`\n\n, wait for it to apply, check, tweak again. Do that a dozen times a day and it quietly grinds you down.\n\nThen 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.**\n\nThe thing is, I read the preview announcement and it didn't actually answer the questions I cared about. Three of them, specifically:\n\nStaring 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`\n\nto verify.\n\nSpoiler 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.\n\nVersions at the time of testing:\n\n`@aws-blocks/create-blocks-app@0.1.7`\n\n/`@aws-blocks/blocks@0.1.5`\n\n. It's a preview, so expect this to move.\n\nThe official line is refreshingly blunt: **\"Blocks is not replacing Amplify. It's additive.\"** There are two ways to use it:\n\nSame 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.\n\nThe 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.\n\nThe guinea pig: a pnpm workspace monorepo with `apps/*`\n\nand `packages/*`\n\n. The Amplify Gen2 backend lives in `packages/gen2-shared-backend/amplify/backend.ts`\n\n, and `amplify/data/resource.ts`\n\nhas 40+ DynamoDB models defined with `a.model()`\n\n(User / Workspace / Project / BusinessModel / LeanCanvas …). This thing runs in production, so obviously I can't have it mangled.\n\nTo keep the real tree clean, I cut an isolated worktree before touching anything.\n\n```\ngit worktree add ./<repo>.worktrees/aws-blocks-test --detach staging\ncd ./<repo>.worktrees/aws-blocks-test/packages/gen2-shared-backend\n```\n\nThen I ran the CLI from where `amplify/backend.ts`\n\nlives. The AWS Blocks CLI looks at the directory it's run in and auto-detects the mode (Amplify detected / existing project / empty directory).\n\n```\npnpm dlx @aws-blocks/create-blocks-app@latest . -y\n```\n\nOutput:\n\n```\n🔍 Detected Amplify Gen 2 project (amplify/backend.ts found)\n\n  CREATE  aws-blocks/           (Blocks backend workspace)\n  CREATE  amplify/blocks.ts     (wires Blocks into Amplify backend)\n  MODIFY  amplify/backend.ts    (adds import for blocks.ts)\n  MODIFY  package.json          (adds workspace, deps, scripts)\n  MODIFY  .gitignore            (adds Blocks entries)\n```\n\nCorrectly 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`\n\ndiff and exhaled.\n\n``` js\n-const backend = defineBackend({\n+export const backend = defineBackend({\n   auth,\n   data,\n   storage,\n   // ... existing resolvers, untouched ...\n });\n\n backend.addOutput({ /* existing */ });\n+\n+// Blocks integration — adds Building Blocks to your Amplify backend\n+import { initBlocks } from './blocks.js';\n+await initBlocks(backend);\n```\n\n**This is an append, not a rewrite.** It changed `const`\n\nto `export const`\n\nand added one `initBlocks(backend)`\n\nline at the end. Not a single line of the existing definitions in that 548-line `backend.ts`\n\nchanged. Looking at the generated `amplify/blocks.ts`\n\n, 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.\n\n```\n// amplify/blocks.ts (generated, excerpt)\nexport async function initBlocks(backend: any) {\n  const blocksStack = backend.createStack('blocks');\n  const blocks = await createBlocksBackend(blocksStack, sandboxMode);\n\n  // Reuse Amplify's Cognito for Blocks' bearer-token verification\n  if (backend.auth?.resources?.cfnResources) {\n    const { cfnUserPool, cfnUserPoolClient } = backend.auth.resources.cfnResources;\n    blocks.handler.addEnvironment('COGNITO_USER_POOL_ID', cfnUserPool.ref);\n    blocks.handler.addEnvironment('COGNITO_CLIENT_ID', cfnUserPoolClient.ref);\n  }\n  // ...\n}\n```\n\nSo **\"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.\n\nIt wasn't all smooth, though. After generating files, the CLI runs `npm install`\n\ninternally on its own — and that fails.\n\n```\nnpm error code EUNSUPPORTEDPROTOCOL\nnpm error Unsupported URL Type \"workspace:\": workspace:*\n```\n\nThe cause: my test project is a **pnpm workspace** and its dependencies use pnpm's own `workspace:*`\n\nprotocol. The AWS Blocks CLI is hardcoded to `npm install`\n\n, and on top of that it adds an npm-style `workspaces`\n\narray to the generated `package.json`\n\n. That's a double conflict with a pnpm monorepo.\n\nThe 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`\n\ncommand 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.\n\nBecause 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).\n\nI scaffolded a standalone backend template. This one assumes npm, so it goes through cleanly.\n\n```\npnpm dlx @aws-blocks/create-blocks-app@latest standalone --template backend -y\n# → installs 284 packages, done in ~29 seconds\n```\n\nThe backend entry point is `aws-blocks/index.ts`\n\n. That's where I add the **DynamoDB Block.** The AWS Blocks data Blocks are split by use case:\n\n`KVStore`\n\n(`@aws-blocks/bb-kv-store`\n\n) = simple key-value (DynamoDB-backed)`DistributedTable`\n\n(`@aws-blocks/blocks`\n\n) = DynamoDB itself.I want to hit DynamoDB head-on, so `DistributedTable`\n\nit is. The schema is written in zod.\n\n``` js\n// aws-blocks/index.ts\nimport { ApiNamespace, Scope, DistributedTable } from '@aws-blocks/blocks';\nimport { z } from 'zod';\n\nconst scope = new Scope('my-app');\n\n// DistributedTable = DynamoDB.\n// Local dev: in-process mock / synth & deploy: real DynamoDB. Same code.\nconst noteSchema = z.object({\n  userId: z.string(),\n  noteId: z.string(),\n  body: z.string(),\n  createdAt: z.number(),\n});\n\nconst notes = new DistributedTable(scope, 'notes', {\n  schema: noteSchema,\n  key: { partitionKey: 'userId', sortKey: 'noteId' },\n});\n\nexport const api = new ApiNamespace(scope, 'api', (context) => ({\n  async putNote(userId: string, noteId: string, body: string) {\n    await notes.put({ userId, noteId, body, createdAt: Date.now() });\n    return { ok: true };\n  },\n  async getNote(userId: string, noteId: string) {\n    return await notes.get({ userId, noteId });\n  },\n  async listNotes(userId: string) {\n    return await Array.fromAsync(\n      notes.query({ where: { userId: { equals: userId } } }),\n    );\n  },\n}));\n```\n\nTypes hold all the way through (`tsc --noEmit`\n\nin ~2.7s). The key types are inferred from the schema, so TypeScript even enforces the key shape on `get({ userId, noteId })`\n\n. No code-gen step. Small thing, but a nice one.\n\nStart the local server:\n\n```\nnpm run dev\n# Loading backend...\n# Deploying local resources...\n# 📝 Generating client code...\n# AWS Blocks local server running on http://localhost:3001\n```\n\nIt says `Deploying local resources`\n\n, but **it isn't talking to AWS at all.** Look at `.blocks-sandbox/config.json`\n\nand it's `\"environment\": \"local\"`\n\n, with the API pointed at the local `http://localhost:3001/aws-blocks/api`\n\n(port 3000 was taken by another process, so it helpfully fell back to 3001 on its own).\n\nFrom here, I hit the DynamoDB Block through the typed client.\n\n```\ngetNote(alice,n1): {\"userId\":\"alice\",\"noteId\":\"n1\",\"body\":\"first note\",\"createdAt\":1782292043870}\nlistNotes(alice) count: 2\nlistNotes(bob): [{\"userId\":\"bob\",\"noteId\":\"n1\",\"body\":\"bob note\",...}]\nput×3 + get + query×2 round-trip: 31ms\n```\n\n**It works.** No AWS account, no deploy, and DynamoDB put / get / partition-key queries all run, with `alice`\n\nand `bob`\n\nproperly 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.\n\nThe 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`\n\n's `package.json`\n\nuses **conditional exports** to resolve the same import to a different thing depending on context.\n\n```\n\".\": { \"browser\": \"...client...\", \"cdk\": \"...construct...\", \"default\": \"...local/lambda...\" }\n```\n\n`default`\n\n) → in-process mock`cdk synth`\n\n(`cdk`\n\n) → CDK construct (i.e. CloudFormation)`browser`\n\n) → typed RPC clientSo I ran `cdk synth`\n\non **the exact same index.ts** (it's synth, not deploy, so no AWS credentials needed).\n\n```\nnpx cdk synth --quiet\n```\n\nPeeking into the generated CloudFormation template, there it was.\n\n```\nAWS::DynamoDB::Table count: 1\n logicalId: myappnotestable...\n   BillingMode: PAY_PER_REQUEST\n   KeySchema: userId:HASH, noteId:RANGE\n```\n\nThat one line, `new DistributedTable(..., { key: { partitionKey: 'userId', sortKey: 'noteId' } })`\n\n, 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.\n\nIf you're going to use DynamoDB, indexes are obviously next on your mind. `DistributedTable`\n\nhas an `indexes`\n\noption, so I tried adding two GSIs — and while I was at it, see if I could make an LSI too.\n\n``` js\nconst notes = new DistributedTable(scope, 'notes', {\n  schema: noteSchema, // userId, noteId, status, createdAt, body\n  key: { partitionKey: 'userId', sortKey: 'noteId' },\n  indexes: {\n    // same PK(userId) + different SK(createdAt) → in raw DynamoDB this is an LSI candidate\n    byCreatedAt: { partitionKey: 'userId', sortKey: 'createdAt' },\n    // different PK(status) → clearly a GSI\n    byStatus: { partitionKey: 'status', sortKey: 'createdAt' },\n  },\n});\n```\n\nLocally it all just worked. `byCreatedAt`\n\nreturned createdAt in ascending order (100, 200, 300), and `byStatus`\n\npulled across statuses fine.\n\nBut then I looked at the `cdk synth`\n\n'd CloudFormation and went \"wait, huh?\" for a second.\n\n```\nGlobalSecondaryIndexes: []\nLocalSecondaryIndexes: []\nAttributeDefinitions: [userId, noteId]   # no createdAt, no status\n```\n\nThe 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`\n\n(`my-app-notes-gsi-resource`\n\n) had `byCreatedAt`\n\nand `byStatus`\n\nsitting right there in its `Indexes`\n\nproperty. And there was a whole `BlocksGsiManager`\n\nLambda set generated alongside it (the Provider framework's onEvent / isComplete / Step Functions waiter).\n\nSo **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:\n\nDynamoDB only allows one GSI change per\n\n`UpdateTable`\n\n. Standard CDK's`Table`\n\ncan't express multiple GSI changes in a single deploy. So we manage them declaratively with a custom resource.\n\nFor 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.\n\n**LSIs, on the other hand, aren't supported.** `indexes`\n\nis exactly what the name says — Global secondary index only — so even my `byCreatedAt`\n\n(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`\n\n. 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.\n\n| Supported | At deploy time | Locally | |\n|---|---|---|---|\nGSI |\n✅ Yes | Custom Resource + GSI Manager Lambda (sequential `UpdateTable` ) |\nQueries work (in-memory filtering) |\nLSI |\n❌ 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 |\n\nOne 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.\n\nMeasured numbers (standalone, dependencies already installed).\n\n| Metric | Measured |\n|---|---|\n| scaffold + dependency install (cold) | ~29s (284 packages) |\n`tsc --noEmit` (typecheck) |\n~2.7s |\n`npm run dev` start → local ready |\n~1.23s |\n| edit one handler line → re-apply (HMR) | ~1.03s |\n| DynamoDB local round-trip (put×3+get+query×2) | 31ms |\n\nWhat 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`\n\ndeploy 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.**\n\nTo be fair: this isn't an apples-to-apples, head-to-head comparison. I didn't benchmark Amplify's\n\n`ampx sandbox`\n\nreal 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.\n\nThis 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`\n\n, and per-Block usage docs live at `node_modules/@aws-blocks/blocks/docs/<package>.md`\n\n.\n\n`AGENTS.md`\n\nhas rules like:\n\nUse 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`\n\nbefore using a block.The JSON-RPC transport is invisible— do not construct RPC payloads manually.\n\nBasically 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`\n\ndocs 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`\n\n.\" Because this etiquette is right there in your `node_modules`\n\nbefore 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.\n\nConclusions from trying it on a real project.\n\n`backend.ts`\n\ngets exactly one `initBlocks(backend)`\n\nline, 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`\n\ngives you an in-process mock for local dev (put/get/query in 31ms, no AWS account) and a real `AWS::DynamoDB::Table`\n\nat `cdk synth`\n\n. Same code.`UpdateTable`\n\n. `npm install`\n\ndies on `workspace:*`\n\n, 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`\n\nonce. And if you've got \"here's how I used it\" stories around GSI/LSI, I'd love to hear them.", "url": "https://wpnews.pro/news/i-tried-aws-blocks-on-a-real-amplify-gen2-project-local-dynamodb-no-aws-account", "canonical_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_at": "2026-06-24 10:00:06+00:00", "updated_at": "2026-06-24 10:13:35.184903+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["AWS", "Amplify Gen2", "DynamoDB", "AWS Blocks", "CDK"], "alternates": {"html": "https://wpnews.pro/news/i-tried-aws-blocks-on-a-real-amplify-gen2-project-local-dynamodb-no-aws-account", "markdown": "https://wpnews.pro/news/i-tried-aws-blocks-on-a-real-amplify-gen2-project-local-dynamodb-no-aws-account.md", "text": "https://wpnews.pro/news/i-tried-aws-blocks-on-a-real-amplify-gen2-project-local-dynamodb-no-aws-account.txt", "jsonld": "https://wpnews.pro/news/i-tried-aws-blocks-on-a-real-amplify-gen2-project-local-dynamodb-no-aws-account.jsonld"}}