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 |
|---|---|---|
|
|
|
IDE IntelliSense and TypeScript compilation |
|
|
|
CDK synthesis (infrastructure provisioning) |
|
|
|
Lambda execution (production) |
|
|
|
Local development ( |
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.