Blog Home

Setting up tsoa in an Express backend

January 9, 2026

Kazi Ehsan Aziz

It is possible for your Express.js Controller function to look like this.

post.controller.ts
import { Request as ExpRequest } from 'express';
import { Body, Controller, Post, Path, Request, Response, Route, Security, SuccessResponse, Tags, Middlewares } from '@tsoa/runtime';
import * as postService from '@/modules/post/post.service.js';
import auth from '@/modules/auth/auth.middleware.js';
import validate from '@/lib/validate/validate.middleware.js';
// ...
// ...

@Route('v1/posts')
@Tags('Posts')
export class PostController extends Controller {
  /**
   * Invite Authors
   */
  @Response<IApiResponse<any>>(400, 'Validation failed.')
  @Response<IApiResponse<any>>(401, 'Unauthorized')
  @Response<IApiResponse<any>>(404, 'Not found')
  @SuccessResponse('200', 'OK')
  @Middlewares(validate(postValidation.inviteAuthors))
  @Security('jwt') // just adds auth specs to openapi
  @Middlewares(auth()) // actually handles auth
  @Post('{postId}/invite-authors')
  public async inviteAuthors(
    @Path() postId: string,
    @Body() requestBody: InviteAuthorsDto,
    @Request() request: ExpRequest
  ): Promise<IApiResponse<PostDto>> {
    const userId = request.user._id.toString();
    const result = await postService.inviteAuthors(postId, requestBody, userId);

    if (isServiceFailureResponse(result)) {
      throw new ApiError(result.code, result.message);
    }

    return {
      success: true,
      code: httpStatus.OK,
      message: 'Post updated successfully',
      data: result,
    };
  }
}

tsoa allows us to use NestJS-like decorators in our Express.js application. It reads decorated TypeScript controllers and generates two things from them: an Express routes file and an OpenAPI spec. You stop writing the spec by hand, and the controllers get small. The route, its middlewares, its documented responses, and its request and response types all sit on one method.

tsoa's documentation covers setup and the use of each decorator well. It says less about how the pieces behave together in a real backend:

  1. which decorators change runtime behaviour and which only change the spec,
  2. what the tsoa CLI drags into a production bundle,
  3. how to setup hot-reloading efficiently,
  4. what to watch out for when using with InversifyJS.

So, this article is not a walkthrough on how to setup tsoa, but more about some explanations and better practices you may want to implement after setting up tsoa. The example application here is built with Express and TypeScript running as native ES modules, deployed to AWS Lambda.

Two middlewares have already appeared in the code snippet above. auth(), which authenticates a JWT using Passport, and validate(), which runs Joi schemas. Both are out of scope here. For this article, they are just your custom Express middlewares.


What tsoa generates

Executing tsoa spec-and-routes reads your decorated Controller classes and writes src/specs/swagger.json and src/routes/routes.ts. Let's look at the tsoa.json configuration quickly.

tsoa.json
{
  "entryFile": "src/index.ts",
  "noImplicitAdditionalProperties": "throw-on-extras",
  "controllerPathGlobs": ["src/**/*.controller.ts"],
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "spec": {
    "outputDirectory": "src/specs",
    "specVersion": 3,
    "securityDefinitions": {
      "jwt": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT"
      }
    }
  },
  "routes": {
    "routesDir": "src/routes",
    "esm": true,
    "authenticationModule": "src/modules/auth/auth.tsoa.ts",
    "iocModule": "src/lib/utils/ioc.ts"
  }
}


It is worth opening the generated routes file once, because most of the quirks in this article are visible in it. Trimmed and reformatted to show only one API endpoint:

src/routes/routes.ts
app.post(
  '/v1/posts/:postId/invite-authors',
  authenticateMiddleware([{ jwt: [] }]),
  ...fetchMiddlewares<RequestHandler>(PostController),
  ...fetchMiddlewares<RequestHandler>(PostController.prototype.inviteAuthors),

  async function PostController_inviteAuthors(request, response, next) {
    try {
      const validatedArgs = templateService.getValidatedArgs({
        args: argsPostController_inviteAuthors,
        request,
        response,
      });

      const controller = await iocContainer.get<PostController>(PostController);
      controller.setStatus(undefined);

      await templateService.apiHandler({
        methodName: 'inviteAuthors',
        controller,
        response,
        next,
        validatedArgs,
        successStatus: 200,
      });
    } catch (err) {
      return next(err);
    }
  }
);

Reading it top to bottom:


authenticationModule

The intuitive reading of @Security('jwt') is "this endpoint requires a JWT". In the spec, that is exactly what it means: Swagger UI shows a lock on the API endpoint.

At runtime it means something more. tsoa inserts authenticateMiddleware([{ jwt: [] }]) into the route, and that middleware calls a function named expressAuthentication, imported by name from your authenticationModule. tsoa expects you to verify the token there, and whatever the function resolves becomes request.user.

You could do all of your auth in that function, and pass required permissions through the decorator's scopes array. Then the auth logic would live in a tsoa-specific function with a tsoa-specific signature. I wanted auth to be an ordinary Express middleware factory that takes the required permissions as plain arguments, like auth(PERMISSIONS.moderateForumPosts) or auth(). So my expressAuthentication does nothing:

src/modules/auth/auth.tsoa.ts
import * as express from 'express';

export function expressAuthentication(
  _request: express.Request,
  _securityName: string,
  _scopes?: string[]
): Promise<any> {
  /*
   * This just tells TSOA to add auth specs to openapi.
   * Actual authentication is handled by a custom middleware.
   */
  return Promise.resolve();
}

The module still has to exist and export the exact name expressAuthentication, since I use @Security for the purpose of notifying spec about secured endpoints.

My actual authentication enforcement happens through @Middlewares(auth()). And I mentioned in the beginning of the article that auth() is just a custom Express middleware that uses Passport to authenticate JWTs.

That is why the pair (@Middlewares(auth()) & @Security('jwt')) sits together on every protected method, each with a comment saying which half does what.


request.user assignment

authenticateMiddleware writes the resolved value to request['user']. Ours resolves undefined, so request.user is undefined until @Middlewares(auth()) runs and sets the real user. This is harmless because authenticateMiddleware always runs before every @Middlewares entry. It does mean any middleware that reads request.user has to run after auth(), which brings us to order.

Middleware order is bottom-to-top. TypeScript applies stacked decorators from the bottom up. The decorator nearest to the method applies before the one above it. Except for @Security('jwt'), which we already saw from the generated routes file that it runs first.


Which decorators change runtime behaviour

The same "does this actually do anything?" question applies to every decorator on the method:


Ship @tsoa/runtime, not tsoa

The tsoa package is a thin wrapper around two others:

// node_modules/tsoa/package.json
"dependencies": {
  "@tsoa/cli": "^6.6.0",
  "@tsoa/runtime": "^6.6.0"
}

@tsoa/cli is the code generator. It depends on the TypeScript compiler (its own nested copy), handlebars, yargs, glob and more, and takes about 25 MB on disk. @tsoa/runtime is under 500 KB.

A running app never needs the generator. @tsoa/runtime exports every decorator, and the generated routes.ts already imports from it:

src/routes/routes.ts
import type { TsoaRoute } from "@tsoa/runtime";
import { fetchMiddlewares, ExpressTemplateService } from "@tsoa/runtime";

So the CLI moves to devDependencies, the runtime becomes the production dependency, and every import in your src switches over:

package.json
   "devDependencies": {
+    "tsoa": "6.6.0",
   },
   "dependencies": {
+    "@tsoa/runtime": "6.6.0",
-    "tsoa": "^6.4.0",
   }
- import { Body, Controller, Post, Route } from 'tsoa';
+ import { Body, Controller, Post, Route } from '@tsoa/runtime';

A few details:


In my deployment case, the Lambda package zip size went from 26 MB down to 11 MB. That is less to upload on every deploy, and less for Lambda to fetch and unpack whenever it spins up a new instance.


Guard it with a lint rule

The move creates a new failure mode. A file that still imports tsoa keeps working on your machine, because devDependencies are installed there. It only fails in the deployed Lambda, with ERR_MODULE_NOT_FOUND.

The no-extraneous-dependencies rule from eslint-plugin-import turns that into a lint error. It flags any import of a devDependency outside an explicit allowlist:

.eslintrc.json
"import/no-extraneous-dependencies": [
  "error",
  {
    "devDependencies": [
      "**/*.test.*",
      "**/*.spec.*",
      "**/lambda.ts",
      "**/setupTestDB.ts",
      "**/swagger.route.ts"
    ]
  }
]

Keep Swagger UI out of production too

Serving the spec gets the same treatment. swagger-ui-express is a devDependency, and the docs route is mounted only in development, through a dynamic import:

src/routes/v1/index.ts
import express from 'express';
import config from '@/config/config.js';

const router = express.Router();

if (config.env === 'development') {
  // A static import is hoisted and evaluated unconditionally, so production
  // would still load swagger-ui-express for a route it never mounts.
  const { default: docsRoute } = await import('@/routes/v1/swagger.route.js');
  router.use('/docs', docsRoute);
}

export default router;

Hot reloading

In development, a change to a .ts file in src goes through three steps before the server sees it: regenerate routes.ts and the spec, compile TypeScript, restart Node. So the pipeline is split into three watchers, started together by yarn dev:

package.json
"compile": "tsoa spec-and-routes && tsc --build && tsc-alias",
"postcompile": "cp package*json dist/ && cp -r src/specs dist/",
"tsoa:watch": "onchange 'src/**/*.ts' -e 'src/routes/routes.ts' -e 'src/specs/*.json' -- tsoa spec-and-routes",
"compile:watch": "onchange 'src/routes/routes.ts' -- sh -c 'tsc --build && tsc-alias'",
"pre:dev": "cross-env NODE_ENV=development nodemon --enable-source-maps --watch dist dist/index.js",
"dev": "yarn compile && concurrently --kill-others \"yarn tsoa:watch\" \"yarn compile:watch\" \"yarn pre:dev\"",

What makes it work:


iocModule: with InversifyJS

Without an iocModule, the generated route constructs the controller with new PostController(). There are no constructor arguments, so there is no way to hand the controller its dependencies. With iocModule set in your tsoa.json, the route asks your container instead:

src/routes/routes.ts
import { iocContainer } from "./../lib/utils/ioc.js";

const container: IocContainer =
  typeof iocContainer === "function"
    ? (iocContainer as IocContainerFactory)(request)
    : iocContainer;
const controller: any = await container.get<PostController>(PostController);

So your module has to export a binding named exactly iocContainer. Let's look at it:

ioc.ts
import { Container } from 'inversify';

// Create a new container tsoa can use
const iocContainer = new Container();

// export according to convention
export { iocContainer };

If PostService were a class, the service, the controller and the bindings would look like this:

  • post.service.ts
  • post.controller.ts
  • app.ts
import { injectable } from "inversify";

@injectable()
export class PostService {
  async inviteAuthors(
    postId: string,
    body: InviteAuthorsDto,
    userId: string,
  ): Promise<PostDto | IServiceFailureResponse> {
    // ...
  }
}
import { inject, injectable } from "inversify";
import { PostService } from "@/modules/post/post.service.js";

@Route("v1/posts")
@Tags("Posts")
@injectable()
export class PostController extends Controller {
  constructor(@inject(PostService) private readonly postService: PostService) {
    super();
  }

  @Post("{postId}/invite-authors")
  public async inviteAuthors(
    @Path() postId: string,
    @Body() requestBody: InviteAuthorsDto,
    @Request() request: ExpRequest,
  ): Promise<IApiResponse<PostDto>> {
    const userId = request.user._id.toString();
    const result = await this.postService.inviteAuthors(
      postId,
      requestBody,
      userId,
    );
    // ...
  }
}
import { RegisterRoutes } from "@/routes/routes.js";
// ...

const app: Express = express();

// ...
app.use(passport.initialize() as any);
passport.use("jwt", jwtStrategy);
// ...

// Register services
iocContainer.bind(PostService).toSelf().inSingletonScope();
// ...

// Register controllers
iocContainer.bind(PostController).toSelf();
// ...

// v1 api routes
app.use("/v1", routes);
RegisterRoutes(app);

// send back a 404 error for any unknown api request
app.use((_req, _res, next) => {
  next(new ApiError(httpStatus.NOT_FOUND, "Not found"));
});

// convert error to ApiError, if needed
app.use(errorConverter);

// handle error
app.use(errorHandler);

Three pieces make that work, and each fails in its own way when it is missing:


Mind the order in the app.ts too. RegisterRoutes(app) goes after your body parsers and Passport initialisation, and before the 404 handler and the error middleware. The generated routes pass every error to next(err), so the error middleware has to be registered after them to receive it.


Keep controllers transient

toSelf() without a scope uses Inversify's default, transient: a new controller instance for every request. It's tempting to add .inSingletonScope() to the controller bindings too, like the service. Before you do, look at tsoa's Controller:

// @tsoa/runtime/dist/interfaces/controller.js
class Controller {
  constructor() {
    this.statusCode = undefined;
    this.headers = {};
  }
  setStatus(statusCode) {
    this.statusCode = statusCode;
  }
  setHeader(name, value) {
    this.headers[name] = value;
  }
}

A response's status code and headers live on the controller instance. The generated route resets the status with controller.setStatus(undefined) at the start of each request in routes.ts, and reads it back once the method resolves. With one shared instance on a long-running server, concurrent requests share that state across every await. One request's this.setStatus(201) can be reset by a request that starts in between, or end up in someone else's response. Nothing resets headers at all, so on a singleton a header set once is sent with every later response.

Without an iocModule, tsoa creates a controller per request. Transient bindings keep that behaviour.


Recap