AuraWorks
GuidesMay 6, 202510 min read

Middy.js: Middleware for AWS Lambda Explained

Middy.js is the middleware engine for AWS Lambda. This guide covers input validation, error handling, secrets injection, and how to compose middleware cleanly.

Middy.js middleware wrapping an AWS Lambda handler

Introduction

As serverless technology becomes more popular, developers are looking for tools to make working with AWS Lambda easier. Middy.js is a key solution for this, offering a simple way to handle Lambda code. This guide explains Middy.js’s main features, showing how it makes working with Lambda functions easier and more efficient, and ends with a complete handler you can copy.

What is Middy.js?

Middy.js is a lightweight tool designed for Node.js developers working with AWS Lambda. It borrows ideas from popular web frameworks, like Express.js, and helps developers focus on the main purpose of their Lambda functions. Middy.js makes it easy to add important functions like user checking, permissions, data checking, and formatting.

The core package is @middy/core. Every other feature is a separate package (@middy/http-json-body-parser, @middy/validator, and so on), so a function only bundles what it uses. The examples below are for Middy 5 on Node 20; the API has been stable since version 2, when middleware moved from callbacks to async functions.

A Practical Example

Let’s see how Middy.js works in real life. Suppose you’re building a serverless function for user authentication. Normally, you’d handle this in your Lambda function. With Middy.js, you can make your code cleaner and more organized:

javascript
import middy from '@middy/core';
import authMiddleware from './middleware/auth.js';
import loggingMiddleware from './middleware/logging.js';
 
const lambdaHandler = async (event) => {
  // Main business logic here
  const { userId, action } = event.body;
 
  // Perform the action
  const result = await doAction(userId, action);
 
  // Return the outcome
  return { statusCode: 200, body: JSON.stringify(result) };
};
 
export const handler = middy(lambdaHandler)
  .use(authMiddleware())
  .use(loggingMiddleware());

In this setup, Middy.js handles user checking and logging, keeping your main code focused and simple.

Why Middy.js?

When developing serverless applications, you want to concentrate on the main business tasks. However, Lambda functions often get mixed up with additional technical needs like data validation and error management. Middy.js solves this by using a pattern called ‘middleware,’ allowing these extra tasks to be handled separately and reused. This makes your main business logic clearer and your applications easier to manage and scale.

How the middleware chain runs

A middleware is an object with up to three functions: before, after and onError. Middy calls every before in the order you attached them, then your handler, then every after in reverse order. If anything throws, it skips straight to the onError functions, again in reverse.

text
.use(A).use(B).use(C)
 
A.before -> B.before -> C.before -> handler -> C.after -> B.after -> A.after

This is why order matters. A JSON body parser has to come before a validator, because the validator reads the parsed body. An error handler is usually attached last, so its onError runs first and can format whatever the others threw. Reading the chain backwards for after and onError takes a minute to get used to, and then it explains every ordering bug you will hit.

Each function receives one request object with event, context, response and error on it. before reads and changes event; after reads and changes response; onError reads error and can set response to recover.

Advanced Uses

Handling Errors:

Middy.js can manage various errors in your Lambda functions. For instance, it can standardize how errors are sent back to users:

javascript
import middy from '@middy/core';
import jsonBodyParser from '@middy/http-json-body-parser';
import validator from '@middy/validator';
import { transpileSchema } from '@middy/validator/transpile';
import httpErrorHandler from '@middy/http-error-handler';
import createError from 'http-errors';
 
const orderSchema = {
  type: 'object',
  required: ['body'],
  properties: {
    body: {
      type: 'object',
      required: ['sku', 'quantity'],
      properties: {
        sku: { type: 'string', minLength: 1 },
        quantity: { type: 'integer', minimum: 1 },
      },
    },
  },
};
 
const lambdaHandler = async (event) => {
  const { sku, quantity } = event.body;
  const stock = await getStock(sku);
  if (stock < quantity) {
    throw new createError.Conflict(`Only ${stock} left for ${sku}`);
  }
  return { statusCode: 201, body: JSON.stringify(await placeOrder(sku, quantity)) };
};
 
export const handler = middy(lambdaHandler)
  .use(jsonBodyParser())
  .use(validator({ eventSchema: transpileSchema(orderSchema) }))
  .use(httpErrorHandler());

This setup ensures a consistent way of handling and reporting errors. The parser turns the raw string body into an object and answers 415 if the content type is wrong. The validator checks the event against a JSON Schema and answers 400 with the validation message if it fails, before your code runs. Anything your handler throws with a status code, like the 409 Conflict above, comes back as a proper HTTP response instead of a Lambda invocation error, and anything without a status code becomes a 500 with the stack trace kept out of the body.

transpileSchema compiles the schema once, at module load, so a warm Lambda does not pay for it on every request.

Injecting secrets and configuration

The other thing nearly every function needs is a secret or a config value that must not live in the code. Middy has middleware for SSM Parameter Store and Secrets Manager that fetch the value once, cache it for the lifetime of the container, and put it where your handler can reach it:

javascript
import ssm from '@middy/ssm';
 
export const handler = middy(lambdaHandler)
  .use(
    ssm({
      fetchData: {
        apiKey: '/orders/stripe/api-key',
        webhookSecret: '/orders/stripe/webhook-secret',
      },
      setToContext: true,
      cacheExpiry: 5 * 60 * 1000,
    })
  );
 
// inside lambdaHandler
const lambdaHandler = async (event, context) => {
  const stripe = new Stripe(context.apiKey);
  // ...
};

setToContext: true puts the values on context; the default puts them on process.env. The cache expiry keeps a long-lived container from serving a rotated secret forever. @middy/secrets-manager has the same shape with ARNs instead of parameter names. Compared with reading the parameter in the handler yourself, you get caching, batching of the parameter calls, and a fetch that happens before validation and business logic rather than in the middle of them.

Custom Middleware:

You can create your own middleware for specific needs, like logging request and response details. A middleware is a function that returns an object of async hooks:

javascript
// Define the custom logger middleware
const customLogger = ({ logger = console } = {}) => ({
  before: async (request) => {
    logger.info('Request', { path: request.event.rawPath, requestId: request.context.awsRequestId });
  },
  after: async (request) => {
    logger.info('Response', { statusCode: request.response?.statusCode });
  },
  onError: async (request) => {
    logger.error('Error', { message: request.error?.message });
    // Not setting request.response here lets the next onError (the http error handler) build it.
  },
});
 
// Usage in a Lambda handler
export const handler = middy(lambdaHandler)
  .use(customLogger())
  // ... other middleware

Two rules keep custom middleware predictable. Each hook is async and does its work directly; there is no next() to call, Middy moves on when the promise resolves. And an onError that sets request.response short-circuits the chain, which is what an error handler wants and a logger does not.

The same shape works for an authentication check: read the Authorization header in before, verify the token, throw createError.Unauthorized() on failure, and attach the decoded claims to request.context on success so the handler never sees a request that was not authenticated.

A complete handler

Putting the pieces together for an API Gateway HTTP API endpoint:

javascript
import middy from '@middy/core';
import httpHeaderNormalizer from '@middy/http-header-normalizer';
import jsonBodyParser from '@middy/http-json-body-parser';
import validator from '@middy/validator';
import { transpileSchema } from '@middy/validator/transpile';
import httpErrorHandler from '@middy/http-error-handler';
import cors from '@middy/http-cors';
import ssm from '@middy/ssm';
 
export const handler = middy(lambdaHandler)
  .use(httpHeaderNormalizer())          // lower-cases header names
  .use(jsonBodyParser())                // string body -> object
  .use(validator({ eventSchema: transpileSchema(orderSchema) }))
  .use(ssm({ fetchData: { apiKey: '/orders/stripe/api-key' }, setToContext: true }))
  .use(cors({ origins: ['https://app.example.com'] }))
  .use(httpErrorHandler());

Read it top to bottom for the request and bottom to top for the response: headers are normalised, the body parsed and validated, the secret loaded, then the handler runs; on the way out CORS headers are added, and if anything failed the error handler turned it into a response first.

For TypeScript, middy<APIGatewayProxyEventV2, APIGatewayProxyResultV2>(lambdaHandler) types the event and response, and the parser and SSM packages ship typings that narrow event.body and context accordingly.

Testing a Middy handler

Because handler is a plain async function, you invoke it directly in a test with a fake event and context. No Lambda runtime is needed:

javascript
const response = await handler(
  { rawPath: '/orders', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sku: 'A1', quantity: 2 }) },
  { awsRequestId: 'test' }
);
expect(response.statusCode).toBe(201);

Pass a body that fails the schema and assert on the 400; that one test covers the validator wiring for the whole function.

Middleware is a good place to hang instrumentation. Observability in AWS Lambda with Powertools Tracing shows what to put in that slot.

From the same desk

AuraLyrics

If you listen to music while you write code, AuraLyrics puts the lyrics over your editor instead of behind another window.

brew install --cask ozcberkay/tap/auralyrics

Notes like this one, once a month

Java, AWS and what shipping a side product actually costs.

No tracking pixels, unsubscribe in one click.