Skip to content

tRPC

tRPC is a framework for building APIs in TypeScript with end-to-end type safety. Using tRPC, updates to API operation inputs and outputs are immediately reflected in client code and are visible in your IDE without the need to rebuild your project.

The tRPC API generator creates a new tRPC API with AWS CDK infrastructure setup. The generated backend uses AWS Lambda for serverless deployment and includes schema validation using Zod. It sets up AWS Lambda Powertools for observability, including logging, AWS X-Ray tracing and Cloudwatch Metrics.

Usage

Generate a tRPC API

You can generate a new tRPC 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#trpc-api
  5. Fill in the required parameters
    • Click Generate

    Options

    Parameter Type Default Description
    name Required string - The name of the API (required). Used to generate class names and file paths.
    computeType string ServerlessApiGatewayRestApi The type of compute to use to deploy this API. Choose between ServerlessApiGatewayRestApi (default) or ServerlessApiGatewayHttpApi.
    auth string IAM The method used to authenticate with your API. Choose between IAM (default), Cognito or None.
    directory string packages The directory to store the application in.

    Generator Output

    The generator will create the following project structure in the <directory>/<api-name> directory:

    • Directoryschema
      • Directorysrc
        • index.ts Schema entrypoint
        • Directoryprocedures
          • echo.ts Shared schema definitions for the “echo” procedure, using Zod
      • tsconfig.json TypeScript configuration
      • project.json Project configuration and build targets
    • Directorybackend
      • Directorysrc
        • init.ts Backend tRPC initialisation
        • router.ts tRPC router definition (Lambda handler API entrypoint)
        • Directoryprocedures Procedures (or operations) exposed by your API
          • echo.ts Example procedure
        • Directorymiddleware
          • error.ts Middleware for error handling
          • logger.ts middleware for configuring AWS Powertools for Lambda logging
          • tracer.ts middleware for configuring AWS Powertools for Lambda tracing
          • metrics.ts middleware for configuring AWS Powertools for Lambda metrics
        • local-server.ts tRPC standalone adapter entrypoint for local development server
        • Directoryclient
          • index.ts Type-safe client for machine-to-machine API calls
      • tsconfig.json TypeScript configuration
      • project.json Project configuration and build targets

    The generator will also create CDK constructs which can be used to deploy your API, which reside in the packages/common/constructs directory.

    Implementing your tRPC API

    As you can see above, there are two main components of a tRPC API, schema and backend, defined as individual packages in your workspace.

    Schema

    The schema package defines the types that are shared between your client and server code. In this package, these types are defined using Zod, a TypeScript-first schema declaration and validation library.

    An example schema might look as follows:

    import { z } from 'zod';
    // Schema definition
    export const UserSchema = z.object({
    name: z.string(),
    height: z.number(),
    dateOfBirth: z.string().datetime(),
    });
    // Corresponding TypeScript type
    export type User = z.TypeOf<typeof UserSchema>;

    Given the above schema, the User type is equivalent to the following TypeScript:

    interface User {
    name: string;
    height: number;
    dateOfBirth: string;
    }

    Schemas are shared by both server and client code, providing a single place to update when making changes to the structures used in your API.

    Schemas are automatically validated by your tRPC API at runtime, which saves hand-crafting custom validation logic in your backend.

    Zod provides powerful utilities to combine or derive schemas such as .merge, .pick, .omit and more. You can find more information on the Zod documentation website.

    Backend

    The nested backend folder contains your API implementation, where you define your API operations and their input, output and implementation.

    You can find the entry point to your api in src/router.ts. This file contains the lambda handler which routes requests to “procedures” based on the operation being invoked. Each procedure defines the expected input, output, and implementation.

    The sample router generated for you has a single operation, called echo:

    import { echo } from './procedures/echo.js';
    export const appRouter = router({
    echo,
    });

    The example echo procedure is generated for you in src/procedures/echo.ts:

    export const echo = publicProcedure
    .input(EchoInputSchema)
    .output(EchoOutputSchema)
    .query((opts) => ({ result: opts.input.message }));

    To break down the above:

    • publicProcedure defines a public method on the API, including the middleware set up in src/middleware. This middleware includes AWS Lambda Powertools integration for logging, tracing and metrics.
    • input accepts a Zod schema which defines the expected input for the operation. Requests sent for this operation are automatically validated against this schema.
    • output accepts a Zod schema which defines the expected output for the operation. You will see type errors in your implementation if you don’t return an output which conforms to the schema.
    • query accepts a function which defines the implementation for your API. This implementation receives opts, which contains the input passed to your operation, as well as other context set up by middleware, available in opts.ctx. The function passed to query must return an output which conforms to the output schema.

    The use of query to define the implementation indicates that the operation is not mutative. Use this to define methods to retrieve data. To implement a mutative operation, use the mutation method instead.

    If you add a new operation, make sure you register it by adding it to the router in src/router.ts.

    Customising your tRPC API

    Errors

    In your implementation, you can return error responses to clients by throwing a TRPCError. These accept a code which indicates the type of error, for example:

    throw new TRPCError({
    code: 'NOT_FOUND',
    message: 'The requested resource could not be found',
    });

    Organising Your Operations

    As your API grows, you may wish to group related operations together.

    You can group operations together using nested routers, for example:

    import { getUser } from './procedures/users/get.js';
    import { listUsers } from './procedures/users/list.js';
    const appRouter = router({
    users: router({
    get: getUser,
    list: listUsers,
    }),
    ...
    })

    Clients then receive this grouping of operations, for example invoking the listUsers operation in this case might look as follows:

    client.users.list.query();

    Logging

    The AWS Lambda Powertools logger is configured in src/middleware/logger.ts, and can be accessed in an API implementation via opts.ctx.logger. You can use this to log to CloudWatch Logs, and/or control additional values to include in every structured log message. For example:

    export const echo = publicProcedure
    .input(...)
    .output(...)
    .query(async (opts) => {
    opts.ctx.logger.info('Operation called with input', opts.input);
    return ...;
    });

    For more information about the logger, please refer to the AWS Lambda Powertools Logger documentation.

    Recording Metrics

    AWS Lambda Powertools metrics are configured in src/middleware/metrics.ts, and can be accessed in an API implementation via opts.ctx.metrics. You can use this to record metrics in CloudWatch without the need to import and use the AWS SDK, for example:

    export const echo = publicProcedure
    .input(...)
    .output(...)
    .query(async (opts) => {
    opts.ctx.metrics.addMetric('Invocations', 'Count', 1);
    return ...;
    });

    For more information, please refer to the AWS Lambda Powertools Metrics documentation.

    Fine-tuning X-Ray Tracing

    The AWS Lambda Powertools tracer is configured in src/middleware/tracer.ts, and can be accessed in an API implementation via opts.ctx.tracer. You can use this to add traces with AWS X-Ray to provide detailed insights into the performance and flow of API requests. For example:

    export const echo = publicProcedure
    .input(...)
    .output(...)
    .query(async (opts) => {
    const subSegment = opts.ctx.tracer.getSegment()!.addNewSubsegment('MyAlgorithm');
    // ... my algorithm logic to capture
    subSegment.close();
    return ...;
    });

    For more information, please refer to the AWS Lambda Powertools Tracer documentation.

    Implementing Custom Middleware

    You can add additional values to the context provided to procedures by implementing middleware.

    As an example, let’s implement some middlware to extract some details about the calling user from our API in src/middleware/identity.ts.

    This example assumes auth was set to IAM. For Cognito authentication, identity middleware is more straightforward, extracting the relevant claims from the event.

    First, we define what we’ll add to the context:

    export interface IIdentityContext {
    identity?: {
    sub: string;
    username: string;
    };
    }

    Note that we define an additional optional property to the context. tRPC manages ensuring that this is defined in procedures which have correctly configured this middleware.

    Next, we’ll implement the middlware itself. This has the following structure:

    export const createIdentityPlugin = () => {
    const t = initTRPC.context<...>().create();
    return t.procedure.use(async (opts) => {
    // Add logic here to run before the procedure
    const response = await opts.next(...);
    // Add logic here to run after the procedure
    return response;
    });
    };

    In our case, we want to extract details about the calling Cognito user. We’ll do that by extracting the user’s subject ID (or “sub”) from the API Gateway event, and retrieving user details from Cognito. The implementation varies slightly depending on whether the event was provided to our function by a REST API or an HTTP API:

    import { CognitoIdentityProvider } from '@aws-sdk/client-cognito-identity-provider';
    import { initTRPC, TRPCError } from '@trpc/server';
    import { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';
    import { APIGatewayProxyEvent } from 'aws-lambda';
    export interface IIdentityContext {
    identity?: {
    sub: string;
    username: string;
    };
    }
    export const createIdentityPlugin = () => {
    const t = initTRPC.context<IIdentityContext & CreateAWSLambdaContextOptions<APIGatewayProxyEvent>>().create();
    const cognito = new CognitoIdentityProvider();
    return t.procedure.use(async (opts) => {
    const cognitoAuthenticationProvider = opts.ctx.event.requestContext?.identity?.cognitoAuthenticationProvider;
    let sub: string | undefined = undefined;
    if (cognitoAuthenticationProvider) {
    const providerParts = cognitoAuthenticationProvider.split(':');
    sub = providerParts[providerParts.length - 1];
    }
    if (!sub) {
    throw new TRPCError({
    code: 'FORBIDDEN',
    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 TRPCError({
    code: 'FORBIDDEN',
    message: `No user found with subjectId ${sub}`,
    });
    }
    // Provide the identity to other procedures in the context
    return await opts.next({
    ctx: {
    ...opts.ctx,
    identity: {
    sub,
    username: Users[0].Username!,
    },
    },
    });
    });
    };

    Deploying your tRPC API

    The tRPC backend generator generates a CDK construct for deploying your API in the common/constructs folder. You can consume this in a CDK application, for example:

    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 your API infrastructure, including an AWS API Gateway REST or HTTP API, AWS Lambda functions for business logic, and authentication based on your chosen auth method.

    Type-Safe Integrations

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

    Default Integrations

    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(),
    });

    Accessing Integrations

    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: [...],
    }));

    Customising Default Options

    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(),
    });

    Overriding Integrations

    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(...);

    Overriding Authorizers

    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(),
    });

    Explicit Integrations

    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(...),
    },
    },
    });

    Router Pattern

    If you prefer to deploy a single Lambda function to service all API requests, you can freely edit the defaultIntegrations method for your API to create 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) => {
    const router = new Function(scope, 'RouterHandler', { ... });
    return IntegrationBuilder.rest({
    ...
    defaultIntegrationOptions: {},
    buildDefaultIntegration: (op) => {
    return {
    // Reference the same router lambda handler in every integration
    integration: new LambdaIntegration(router),
    };
    },
    });
    };
    }

    You can modify the code in other ways if you prefer, for example you may prefer to define the router function as a parameter to defaultIntegrations instead of constructing it within the method.

    Granting Access (IAM Only)

    If you selected to use IAM authentication, can use the grantInvokeAccess method to grant access to your API, for example you might wish to grant authenticated Cognito users access to your API:

    api.grantInvokeAccess(myIdentityPool.authenticatedRole);

    Local tRPC Server

    You can use the serve target to run a local server for your api, for example:

    Terminal window
    pnpm nx run @my-scope/my-api:serve

    The entry point for the local server is src/local-server.ts.

    Invoking your tRPC API

    You can create a tRPC client to invoke your API in a type-safe manner. If you are calling your tRPC API from another backend, you can use the client in src/client/index.ts, for example:

    import { createMyApiClient } from ':my-scope/my-api';
    const client = createMyApiClient({ url: 'https://my-api-url.example.com/' });
    await client.echo.query({ message: 'Hello world!' });

    If you are calling your API from a React website, consider using the API Connection generator to configure the client.

    More Information

    For more information about tRPC, please refer to the tRPC documentation.