Skip to content

Smithy TypeScript API

Filter this guide Pick generator option values to hide sections that don't apply.

Smithy is a protocol-agnostic interface definition language for authoring APIs in a model driven fashion.

The Smithy TypeScript API generator creates a new API using Smithy for service definition, and the Smithy TypeScript Server SDK for implementation. The generator vends CDK or Terraform infrastructure as code to deploy your service to AWS Lambda, exposed via an AWS API Gateway REST API. It provides type-safe API development with automatic code generation from Smithy models. The generated handler uses AWS Lambda Powertools for TypeScript for observability, including logging, AWS X-Ray tracing and CloudWatch Metrics

You can generate a new Smithy TypeScript API in two ways:

  1. Install the Nx Console VSCode Plugin if you haven't already
  2. Open the Nx Console in VSCode
  3. Click Generate (UI) in the "Common Nx Commands" section
  4. Search for @aws/nx-plugin - ts#api
  5. Fill in the required parameters
    • framework: smithy
  6. Click Generate
Parameter Type Default Description
name Required string - The name of the API (required). Used to generate class names and file paths.
framework trpc | smithy trpc The API framework to use.
namespace string - The namespace for the Smithy API (only applicable for the smithy framework). Defaults to your monorepo scope
integrationPattern isolated | shared isolated How API Gateway integrations are generated for the API. Choose between isolated (default) and shared.
auth iam | cognito | custom iam The method used to authenticate with your API. Choose between iam (default), cognito or custom.
directory string packages The directory to store the application in.
subDirectory string - The sub directory the project is placed in. By default this is the project name.
iac inherit | cdk | terraform inherit The preferred IaC provider. By default this is inherited from your initial selection.
infra rest-lambda | http-lambda | none rest-lambda The type of infrastructure to use to deploy this API.
preferInstallDependencies boolean true Whether to prefer installing dependencies after the generator runs. Set to false to defer installing when batching multiple generators (an install still runs if needed so subsequent generators can compute the Nx project graph); install once at the end.

The generator creates two related projects in the <directory>/<api-name> directory:

  • Directorymodel/ Smithy model project
    • project.json Project configuration and build targets
    • smithy-build.json Smithy build configuration
    • build.Dockerfile Docker configuration for building Smithy artifacts
    • Directorysrc/
      • main.smithy Main service definition
      • Directoryoperations/
        • echo.smithy Example operation definition
  • Directorybackend/ TypeScript backend implementation
    • project.json Project configuration and build targets
    • rolldown.config.ts Bundle configuration
    • Directorysrc/
      • handler.ts AWS Lambda handler
      • local-server.ts Local development server
      • service.ts Service implementation
      • context.ts Service context definition
      • Directoryoperations/
        • echo.ts Example operation implementation
      • Directorygenerated/ Generated TypeScript SDK (created during build)

Since this generator creates infrastructure as code based on your chosen iacProvider, it will create a project in packages/common which includes the relevant CDK constructs or Terraform modules.

The common infrastructure as code project is structured as follows:

  • Directorypackages/common/constructs
    • Directorysrc
      • Directoryapp/ Constructs for infrastructure specific to a project/generator
        • Directoryapis/
          • <project-name>.ts CDK construct for deploying your API
      • Directorycore/ Generic constructs which are reused by constructs in app
        • Directoryapi/
          • rest-api.ts CDK construct for deploying a REST API
          • utils.ts Utilities for the API constructs
      • index.ts Entry point exporting constructs from app
    • project.json Project build targets and configuration

The deployed Smithy API has the following architecture, with an AWS WAFv2 Web ACL in front of the API Gateway stage:

ClientWAFAPI Gateway(REST API)Lambda(Smithy Server SDK)CloudWatch(Logs, Metrics)X-Ray(Traces)

Operations are defined in Smithy files within the model project. The main service definition is in main.smithy:

$version: "2.0"
namespace your.namespace
use aws.protocols#restJson1
use smithy.framework#ValidationException
@title("YourService")
@restJson1
service YourService {
version: "1.0.0"
operations: [
Echo,
// Add your operations here
]
errors: [
ValidationException
]
}

Individual operations are defined in separate files in the operations/ directory:

$version: "2.0"
namespace your.namespace
@http(method: "POST", uri: "/echo")
operation Echo {
input: EchoInput
output: EchoOutput
}
structure EchoInput {
@required
message: String
foo: Integer
bar: String
}
structure EchoOutput {
@required
message: String
}

Operation implementations are located in the backend project’s src/operations/ directory. Each operation is implemented using the generated types from the TypeScript Server SDK (generated at build time from your Smithy model).

import { ServiceContext } from '../context.js';
import { Echo as EchoOperation } from '../generated/ssdk/index.js';
export const Echo: EchoOperation<ServiceContext> = async (input) => {
// Your business logic here
return {
message: `Echo: ${input.message}` // type-safe based on your Smithy model
};
};

Operations must be registered to the service definition in src/service.ts:

import { ServiceContext } from './context.js';
import { YourServiceService } from './generated/ssdk/index.js';
import { Echo } from './operations/echo.js';
// Import other operations here
// Register operations to the service here
export const Service: YourServiceService<ServiceContext> = {
Echo,
// Add other operations here
};

You can define shared context for your operations in context.ts:

export interface ServiceContext {
// Powertools tracer, logger and metrics are provided by default
tracer: Tracer;
logger: Logger;
metrics: Metrics;
// Add shared dependencies, database connections, etc.
dbClient: any;
userIdentity: string;
}

This context is passed to all operation implementations and can be used to share resources like database connections, configuration, or logging utilities.

The generator configures structured logging using AWS Lambda Powertools with automatic context injection via Middy middleware.

handler.ts
export const handler = middy<APIGatewayProxyEvent, APIGatewayProxyResult>()
.use(captureLambdaHandler(tracer))
.use(injectLambdaContext(logger))
.use(logMetrics(metrics))
.handler(lambdaHandler);

You can reference the logger from your operation implementations via the context:

operations/echo.ts
import { ServiceContext } from '../context.js';
import { Echo as EchoOperation } from '../generated/ssdk/index.js';
export const Echo: EchoOperation<ServiceContext> = async (input, ctx) => {
ctx.logger.info('Your log message');
// ...
};

AWS X-Ray tracing is configured automatically via the captureLambdaHandler middleware.

handler.ts
export const handler = middy<APIGatewayProxyEvent, APIGatewayProxyResult>()
.use(captureLambdaHandler(tracer))
.use(injectLambdaContext(logger))
.use(logMetrics(metrics))
.handler(lambdaHandler);

You can add custom subsegments to your traces in your operations:

operations/echo.ts
import { ServiceContext } from '../context.js';
import { Echo as EchoOperation } from '../generated/ssdk/index.js';
export const Echo: EchoOperation<ServiceContext> = async (input, ctx) => {
// Creates a new subsegment
const subsegment = ctx.tracer.getSegment()?.addNewSubsegment('custom-operation');
try {
// Your logic here
} catch (error) {
subsegment?.addError(error as Error);
throw error;
} finally {
subsegment?.close();
}
};

CloudWatch metrics are collected automatically for each request via the logMetrics middleware.

handler.ts
export const handler = middy<APIGatewayProxyEvent, APIGatewayProxyResult>()
.use(captureLambdaHandler(tracer))
.use(injectLambdaContext(logger))
.use(logMetrics(metrics))
.handler(lambdaHandler);

You can add custom metrics in your operations:

operations/echo.ts
import { MetricUnit } from '@aws-lambda-powertools/metrics';
import { ServiceContext } from '../context.js';
import { Echo as EchoOperation } from '../generated/ssdk/index.js';
export const Echo: EchoOperation<ServiceContext> = async (input, ctx) => {
ctx.metrics.addMetric("CustomMetric", MetricUnit.Count, 1);
// ...
};

Smithy provides built-in error handling. You can define custom errors in your Smithy model:

@error("client")
@httpError(400)
structure InvalidRequestError {
@required
message: String
}

And register them to your operation/service:

operation MyOperation {
...
errors: [InvalidRequestError]
}

Then throw them in your TypeScript implementation:

import { InvalidRequestError } from '../generated/ssdk/index.js';
export const MyOperation: MyOperationHandler<ServiceContext> = async (input) => {
if (!input.requiredField) {
throw new InvalidRequestError({
message: "Required field is missing"
});
}
return { /* success response */ };
};

When your API is protected by authentication, your operations often need to know who is calling. The recommended approach is to resolve the caller’s identity once in the handler and pass it through the service context for consumption by specific operations.

We’ll model the unauthorized case as a Smithy error so it serializes to a proper 403 response. Add it to your model, for example in model/src/operations/errors.smithy, and reference it on any operation that requires identity:

$version: "2.0"
namespace your.namespace
/// Thrown when the calling user cannot be determined
@error("client")
@httpError(403)
structure UnauthorizedError {
@required
message: String
}

First, expose the resolved identity on the service context in src/context.ts. We provide it as a function so that the UnauthorizedError is thrown from within an operation (where the Server SDK serializes it to a 403), rather than from the handler:

import { Logger } from '@aws-lambda-powertools/logger';
import { Metrics } from '@aws-lambda-powertools/metrics';
import { Tracer } from '@aws-lambda-powertools/tracer';
export interface Identity {
sub: string;
username: string;
}
/**
* Context provided to all operations.
*/
export interface ServiceContext {
tracer: Tracer;
logger: Logger;
metrics: Metrics;
getIdentity: () => Promise<Identity>;
}

Next, write the resolver in src/identity.ts. It throws UnauthorizedError when the caller cannot be determined. The implementation depends on your selected auth method:

auth = iam

For IAM authentication, we look up the caller in Cognito using the sub extracted from the API Gateway event:

import { CognitoIdentityProvider } from '@aws-sdk/client-cognito-identity-provider';
import type { APIGatewayProxyEvent } from 'aws-lambda';
import { Identity } from './context.js';
import { UnauthorizedError } from './generated/ssdk/index.js';
const cognito = new CognitoIdentityProvider();
export const getIdentity = async (
event: APIGatewayProxyEvent,
): Promise<Identity> => {
const cognitoAuthenticationProvider =
event.requestContext?.identity?.cognitoAuthenticationProvider;
let sub: string | undefined = undefined;
if (cognitoAuthenticationProvider) {
const providerParts = cognitoAuthenticationProvider.split(':');
sub = providerParts[providerParts.length - 1];
}
if (!sub) {
throw new UnauthorizedError({ message: 'Unable to determine calling user' });
}
const { Users } = await cognito.listUsers({
// Assumes user pool id is configured in lambda environment
UserPoolId: process.env.USER_POOL_ID!,
Limit: 1,
Filter: `sub="${sub}"`,
});
if (!Users || Users.length !== 1) {
throw new UnauthorizedError({ message: `No user found with subjectId ${sub}` });
}
return { sub, username: Users[0].Username! };
};
auth = cognito

With auth: 'cognito', the API Gateway Cognito User Pools authorizer verifies the JWT that the caller supplies in the Authorization header and places the verified claims on the event at event.requestContext.authorizer.claims:

import type { APIGatewayProxyEvent } from 'aws-lambda';
import { Identity } from './context.js';
import { UnauthorizedError } from './generated/ssdk/index.js';
export const getIdentity = async (
event: APIGatewayProxyEvent,
): Promise<Identity> => {
const claims = event.requestContext?.authorizer?.claims as
| Record<string, string>
| undefined;
const sub = claims?.sub;
const username = claims?.username;
if (!sub || !username) {
throw new UnauthorizedError({ message: 'Unable to determine calling user' });
}
return { sub, username };
};

Then wire the resolver into the context in src/handler.ts:

import { Service } from './service.js';
import { getIdentity } from './identity.js';
// ...
const httpResponse = await serviceHandler.handle(httpRequest, {
tracer,
logger,
metrics,
getIdentity: () => getIdentity(event),
});

We can now use the resolved identity in an operation, for example in src/operations/echo.ts:

import { ServiceContext } from '../context.js';
import { Echo as EchoOperation } from '../generated/ssdk/index.js';
export const Echo: EchoOperation<ServiceContext> = async (input, ctx) => {
const identity = await ctx.getIdentity();
return { message: `${identity.username} says ${input.message}` };
};

The Smithy model project uses Docker to build the Smithy artifacts and generate the TypeScript Server SDK:

Terminal window
pnpm nx build <model-project>

This process:

  1. Compiles the Smithy model and validates it
  2. Generates OpenAPI specification from the Smithy model
  3. Creates TypeScript Server SDK with type-safe operation interfaces
  4. Outputs build artifacts to dist/<model-project>/build/

The backend project automatically copies the generated SDK during compilation:

Terminal window
pnpm nx copy-ssdk <backend-project>

The generator automatically configures a bundle target which uses Rolldown to create a deployment package:

Terminal window
pnpm nx bundle <project-name>

Rolldown configuration can be found in rolldown.config.ts, with an entry per bundle to generate. Rolldown manages creating multiple bundles in parallel if defined.

The generator configures a local development server with hot reloading:

Terminal window
pnpm nx serve <backend-project>

The generator creates CDK or Terraform infrastructure based on your selected iacProvider.

The CDK construct for deploying your API is in the common/constructs folder:

import { MyApi } from ':my-scope/common-constructs';
export class ExampleStack extends Stack {
constructor(scope: Construct, id: string) {
// Add the API to your stack
const api = new MyApi(this, 'MyApi', {
integrations: MyApi.defaultIntegrations(this).build(),
});
}
}

This sets up:

  1. An AWS Lambda function for the Smithy service
  2. API Gateway REST API as the function trigger
  3. IAM roles and permissions
  4. CloudWatch log group
  5. X-Ray tracing configuration
auth = cognito
auth = custom

For REST APIs, the generated construct associates an AWS WAFv2 Web ACL with the API Gateway stage by default. The Web ACL uses the AWS managed default ruleset (AWSManagedRulesCommonRuleSet and AWSManagedRulesKnownBadInputsRuleSet), providing protection against common web exploits including the OWASP Top 10. WAF request logs are written to a CloudWatch Logs group.

You can edit the generated rest-api construct to add, remove, or adjust rules (for example, to add rate-based rules or additional managed rule groups).

To opt out (for example, to attach your own Web ACL), set enableWaf to false:

const api = new MyApi(this, 'MyApi', {
integrations: MyApi.defaultIntegrations(this).build(),
enableWaf: false,
});

For REST APIs, the generated infrastructure enables access logging by default, writing one structured JSON line per request to a dedicated CloudWatch Logs group. The log group is encrypted with a customer-managed KMS key and retained for one year.

API Gateway writes access logs using an account-level CloudWatch Logs role. This role is configured on the AWS::ApiGateway::Account setting, which is a singleton per region per account — there is only one role for every REST API in the region. To manage this safely across multiple independently-deployed stacks, the generated infrastructure:

  • Creates a shared CloudWatch Logs role and configures it on the account only when no working role is already set, so deployments never overwrite a role another stack owns.
  • Leaves the account setting untouched on teardown, so destroying one stack never disables logging for other REST APIs in the region.

The account role is managed by the ApiGatewayAccount construct, a stack-scoped singleton resolved via ApiGatewayAccount.ensure(scope). Each REST API’s stage depends on it, and the role is configured by a Lambda-backed custom resource.

You can customise the access log format by passing deployOptions when constructing your API:

const api = new MyApi(this, 'MyApi', {
integrations: MyApi.defaultIntegrations(this).build(),
deployOptions: {
accessLogFormat: AccessLogFormat.clf(),
},
});

The REST/HTTP API CDK constructs are configured to provide a type-safe interface for defining integrations for each of your operations.

The CDK constructs provide full type-safe integration support as described below.

You can use the static defaultIntegrations to make use of the default pattern, which defines an individual AWS Lambda function for each operation:

new MyApi(this, 'MyApi', {
integrations: MyApi.defaultIntegrations(this).build(),
});

You can access the underlying AWS Lambda functions via the API construct’s integrations property, in a type-safe manner. For example, if your API defines an operation named sayHello and you need to add some permissions to this function, you can do so as follows:

const api = new MyApi(this, 'MyApi', {
integrations: MyApi.defaultIntegrations(this).build(),
});
// sayHello is typed to the operations defined in your API
api.integrations.sayHello.handler.addToRolePolicy(new PolicyStatement({
effect: Effect.ALLOW,
actions: [...],
resources: [...],
}));

If your API uses the shared pattern, the shared router Lambda is exposed as api.integrations.$router:

const api = new MyApi(this, 'MyApi', {
integrations: MyApi.defaultIntegrations(this).build(),
});
api.integrations.$router.handler.addEnvironment('LOG_LEVEL', 'DEBUG');

If you would like to customise the options used when creating the Lambda function for each default integration, you can use the withDefaultOptions method. For example, if you would like all of your Lambda functions to reside in a Vpc:

const vpc = new Vpc(this, 'Vpc', ...);
new MyApi(this, 'MyApi', {
integrations: MyApi.defaultIntegrations(this)
.withDefaultOptions({
vpc,
})
.build(),
});

To customise the options used to create the default integration for specific operations (without affecting the others), you can use the withOperationOptions method. For example, if you would like to increase the Lambda function timeout for just one operation:

const api = new MyApi(this, 'MyApi', {
integrations: MyApi.defaultIntegrations(this)
.withOperationOptions({
sayHello: {
timeout: Duration.seconds(60),
},
})
.build(),
});
// The selected operations remain default integrations, so they're still typed accordingly:
api.integrations.sayHello.handler.addToRolePolicy(new PolicyStatement({ ... }));

The options you specify are merged with the default integration options (and any options set via withDefaultOptions). Note that you cannot specify options for operations which you have replaced via withOverrides, since these no longer use the default integration.

You will encounter a type error if the same operation is targeted by both withOperationOptions and withOverrides, regardless of the order in which you call them.

You can also override integrations for specific operations using the withOverrides method. Each override must specify an integration property which is typed to the appropriate CDK integration construct for the HTTP or REST API. The withOverrides method is also type-safe. For example, if you would like to override a getDocumentation API to point to documentation hosted by some external website you could achieve this as follows:

new MyApi(this, 'MyApi', {
integrations: MyApi.defaultIntegrations(this)
.withOverrides({
getDocumentation: {
integration: new HttpIntegration('https://example.com/documentation'),
},
})
.build(),
});

You will also notice that the overridden integration no longer has a handler property when accessing it via api.integrations.getDocumentation.

You can add additional properties to an integration which will also be typed accordingly, allowing for other types of integration to be abstracted but remain type-safe, for example if you have created an S3 integration for a REST API and later wish to reference the bucket for a particular operation, you can do so as follows:

const storageBucket = new Bucket(this, 'Bucket', { ... });
const apiGatewayRole = new Role(this, 'ApiGatewayS3Role', {
assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
});
storageBucket.grantRead(apiGatewayRole);
const api = new MyApi(this, 'MyApi', {
integrations: MyApi.defaultIntegrations(this)
.withOverrides({
getFile: {
bucket: storageBucket,
integration: new AwsIntegration({
service: 's3',
integrationHttpMethod: 'GET',
path: `${storageBucket.bucketName}/{fileName}`,
options: {
credentialsRole: apiGatewayRole,
requestParameters: {
'integration.request.path.fileName': 'method.request.querystring.fileName',
},
integrationResponses: [{ statusCode: '200' }],
},
}),
options: {
requestParameters: {
'method.request.querystring.fileName': true,
},
methodResponses: [{
statusCode: '200',
}],
}
},
})
.build(),
});
// Later, perhaps in another file, you can access the bucket property we defined
// in a type-safe manner
api.integrations.getFile.bucket.grantRead(...);

You can also supply options in your integration to override particular method options such as authorizers, for example if you wished to use Cognito authentication for your getDocumentation operation:

new MyApi(this, 'MyApi', {
integrations: MyApi.defaultIntegrations(this)
.withOverrides({
getDocumentation: {
integration: new HttpIntegration('https://example.com/documentation'),
options: {
authorizer: new CognitoUserPoolsAuthorizer(...) // for REST, or HttpUserPoolAuthorizer for an HTTP API
}
},
})
.build(),
});

If you prefer, you can choose not to use the default integrations and instead directly supply one for each operation. This is useful if, for example, each operation needs to use a different type of integration or you would like to receive a type error when adding new operations:

new MyApi(this, 'MyApi', {
integrations: {
sayHello: {
integration: new LambdaIntegration(...),
},
getDocumentation: {
integration: new HttpIntegration(...),
},
},
});

Generated CDK API constructs support two integration patterns:

  • isolated creates one Lambda function per operation. This is the default for generated APIs.
  • shared creates a single default router Lambda and reuses it for every operation unless you override specific integrations.

isolated gives you finer-grained permissions and configuration per operation. shared reduces Lambda and API Gateway integration sprawl while still allowing selective overrides.

For example, setting pattern to 'shared' creates a single function instead of one per integration:

packages/common/constructs/src/app/apis/my-api.ts
export class MyApi<...> extends ... {
public static defaultIntegrations = (scope: Construct) => {
...
return IntegrationBuilder.rest({
pattern: 'shared',
...
});
};
}

Since operations are defined in Smithy, we use code generation to supply metadata to the CDK construct for type-safe integrations.

A generate:<ApiName>-metadata target is added to the common constructs project.json to facilitate this code generation, which emits a file such as packages/common/constructs/src/generated/my-api/metadata.gen.ts. Since this is generated at build time, it is ignored in version control.

auth = iam

If you selected IAM authentication, you can use the grantInvokeAccess method to grant access to your API:

api.grantInvokeAccess(myIdentityPool.authenticatedRole);

To invoke your API from a React website, you can use the connection generator, which provides type-safe client generation from your Smithy model.

Use the connection generator to integrate this project with others in your workspace. The following connections involve this project:

Smithy
React to Smithy API Call a Smithy API from a React website
Smithy Amazon Aurora
Smithy API to Relational Database Connect a Smithy API to an Aurora relational database
Smithy Amazon DynamoDB
Smithy API to TypeScript DynamoDB Connect a Smithy API to a DynamoDB table