View a markdown version of this page

Creating custom Blocks - AWS Blocks

Creating custom Blocks

When the built-in Blocks don’t cover your use case, you can create your own. A custom Block packages infrastructure, runtime logic, and a local implementation into a single reusable module, usable across projects or shared with your team.

Use cases for custom Blocks

Create a custom Block when:

  • You’re repeating the same AWS resource + SDK boilerplate across multiple API methods

  • You want local development support for a resource that doesn’t have a built-in Block

  • You want to share a reusable capability across projects or teams

Don’t create a custom Block when:

  • You only need the resource in one place. Use the The CDK layer directly instead

  • A built-in Block already does what you need

Block structure

A Block is an npm package with up to four exports, each targeting a different execution context:

my-building-block/ ├── src/ │ ├── index.ts │ ├── index.mock.ts │ ├── index.cdk.ts │ ├── client-hook.ts │ └── types.ts ├── package.json ├── tsconfig.json └── README.md

Configure package.json exports

The package.json uses conditional exports to route imports to the correct file:

{ "name": "@my-org/bb-notifications", "version": "1.0.0", "type": "module", "exports": { ".": { "types": "./dist/types.d.ts", "cdk": "./dist/index.cdk.js", "aws-runtime": "./dist/index.js", "default": "./dist/index.mock.js" } }, "files": ["dist"] }
Export condition File When it’s used

types

types.d.ts

IDE IntelliSense and TypeScript compilation

cdk

index.cdk.js

CDK synthesis (infrastructure provisioning)

aws-runtime

index.js

Lambda execution (production)

default

index.mock.js

Local development (npm run dev)

Define shared types

Start by defining the interface your Block exposes. Both the runtime and local implementations must conform to this interface.

// src/types.ts import { Scope } from '@aws-blocks/blocks'; export interface NotificationOptions { /** The sender email address (must be verified in SES) */ fromAddress: string; } export interface SendEmailInput { to: string; subject: string; body: string; } export declare class Notifications { constructor(scope: Scope, id: string, options: NotificationOptions); sendEmail(input: SendEmailInput): Promise<{ messageId: string }>; }

Implement the runtime

The runtime implementation uses the AWS SDK to interact with real services. It runs inside Lambda in production.

// src/index.ts import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'; import { Scope } from '@aws-blocks/blocks'; import type { NotificationOptions, SendEmailInput } from './types.js'; export class Notifications { private client: SESv2Client; private fromAddress: string; constructor(scope: Scope, id: string, options: NotificationOptions) { this.client = new SESv2Client({}); this.fromAddress = options.fromAddress; } async sendEmail(input: SendEmailInput): Promise<{ messageId: string }> { const result = await this.client.send(new SendEmailCommand({ FromEmailAddress: this.fromAddress, Destination: { ToAddresses: [input.to] }, Content: { Simple: { Subject: { Data: input.subject }, Body: { Text: { Data: input.body } }, }, }, })); return { messageId: result.MessageId ?? 'unknown' }; } }

Implement the local version

The local implementation provides the same API but runs without AWS. Use in-memory data structures, the filesystem, or embedded databases.

// src/index.mock.ts import { Scope, getMockDataDir } from '@aws-blocks/blocks'; import { writeFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import type { NotificationOptions, SendEmailInput } from './types.js'; export class Notifications { private logDir: string; constructor(scope: Scope, id: string, options: NotificationOptions) { this.logDir = getMockDataDir(scope); mkdirSync(this.logDir, { recursive: true }); } async sendEmail(input: SendEmailInput): Promise<{ messageId: string }> { const messageId = crypto.randomUUID(); const logFile = join(this.logDir, `${messageId}.json`); writeFileSync(logFile, JSON.stringify({ ...input, messageId, sentAt: new Date().toISOString() })); console.log(`[mock] Email sent to ${input.to}: "${input.subject}"`); return { messageId }; } }

During local development, emails are logged to the console and saved as JSON files in .bb-data/ instead of being sent through SES.

Implement the infrastructure

The infrastructure export defines CDK constructs that are synthesized during deployment. Grant the Lambda handler the permissions it needs.

// src/index.cdk.ts import { Scope } from '@aws-blocks/blocks'; import type { NotificationOptions } from './types.js'; import * as iam from 'aws-cdk-lib/aws-iam'; export class Notifications { constructor(scope: Scope, id: string, options: NotificationOptions) { // Grant the Lambda handler permission to send emails via SES scope.handler.addToRolePolicy(new iam.PolicyStatement({ actions: ['ses:SendEmail'], resources: ['*'], conditions: { StringEquals: { 'ses:FromAddress': options.fromAddress }, }, })); } // No-op methods. Infrastructure doesn't execute runtime logic async sendEmail() { return { messageId: '' }; } }

Client hook (optional)

Most Blocks don’t need a client hook. Add one only if your Block requires a custom client-server protocol (such as WebSockets or streaming).

// src/client-hook.ts // No client-side protocol needed for email notifications export {};

If your Block does need a client hook (for example, a real-time messaging block), export a class with lifecycle methods:

// src/client-hook.ts (for a real-time Block) export class RealtimeClientHook { private ws?: WebSocket; async onInit(config: { websocketUrl: string }) { this.ws = new WebSocket(config.websocketUrl); } subscribe(channel: string, handler: (msg: any) => void) { this.ws?.addEventListener('message', (event) => { const data = JSON.parse(event.data); if (data.channel === channel) handler(data.message); }); } }

Use your Block

After building your package, install it in your project and use it like any built-in Block:

// aws-blocks/index.ts import { ApiNamespace, Scope } from '@aws-blocks/blocks'; import { Notifications } from '@my-org/bb-notifications'; const scope = new Scope('my-app'); const email = new Notifications(scope, 'email', { fromAddress: 'noreply@example.com', }); export const api = new ApiNamespace(scope, 'api', (context) => ({ async sendWelcomeEmail(userEmail: string) { return email.sendEmail({ to: userEmail, subject: 'Welcome!', body: 'Thanks for signing up.', }); }, }));

Testing your Block

Test both the mock and runtime implementations:

  • Mock tests: Run directly with a test runner (Vitest, Jest). Verify that the mock behaves correctly and returns expected shapes.

  • Runtime tests: Deploy to a sandbox and verify against real AWS services.

  • Interface parity: Ensure both implementations accept the same inputs and return the same output shapes.

// tests/notifications.test.ts import { describe, it, expect } from 'vitest'; import { Notifications } from '../src/index.mock.js'; describe('Notifications (mock)', () => { it('returns a messageId', async () => { const scope = new MockScope('test'); const notif = new Notifications(scope, 'test-email', { fromAddress: 'test@example.com', }); const result = await notif.sendEmail({ to: 'user@example.com', subject: 'Test', body: 'Hello', }); expect(result.messageId).toBeDefined(); }); });

Publishing

To share your Block:

  • Within a monorepo: Reference it as a workspace dependency. No publishing needed.

  • Within your organization: Publish to a private npm registry (such as AWS CodeArtifact).

  • Publicly: Publish to the npm public registry.

Include a README.md with usage examples, API documentation, and information about the AWS resources provisioned. AI coding agents use this documentation to help developers integrate your Block.

Best practices for custom Blocks

  • Keep the interface identical between runtime and mock. Consumers shouldn’t need to know which implementation is active.

  • Document mock limitations: If the mock doesn’t replicate all production behavior (such as DynamoDB query limits), document the differences.

  • Use getMockDataDir(scope) for persistent mock data. This follows the .bb-data/{id}/ convention.

  • Minimize client hooks: Only use them for non-HTTP protocols. Most Blocks don’t need one.

  • Export {} for unused layers: Don’t omit files. An explicit empty export signals intent.

  • Share types in a separate file: This ensures type consistency across all implementations.