How to create REST API with Express and ts-rest?

When you are working with Typescript to create REST API there are so many options you need to consider. But the most important thing is that you need to make sure that your API is type safety. Don't forget to make sure you validate the request body from client. Not only that you will also need to make sure that your api is safely accessed by the client.

So the perfect solution to handle all of this case is using this new library ts-rest. Today we will try to create simple restapi using ts-rest. ts-rest can be used in your next.js, nest.js, fastly and expressjs.

But this time will learn to use ts-rest in expressjs.

Setup project

Let's start with creating new project, so the api we will create today is youtube video transcription api with youtube-transcript npm package.

Create new folder EchoTube:

mkdir EchoTube

Then create package.json file:

npm init -y

Now let's install some dependency for development.

npm install --save-dev @types/node ts-node typescript nodemon concurrently

Now let's install ts-rest and express dependency.

npm install express cors @ts-rest/core @ts-rest/express @ts-rest/open-api zod
npm install --save-dev @types/cors @types/express

We will also have some openapi documentation using swagger, so let's instal that dependency as well.

npm install swagger-ui-express
npm install --save-dev @types/swagger-ui-express

And lastly also install youtube-transcript package.

npm install youtube-transcript

With all of that here's the complete package.json file:

{
  "name": "echotube",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "npx tsc",
    "start": "node build/index.js",
    "dev": "concurrently \"npx tsc --watch\" \"nodemon -q build/index.js\""
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@ts-rest/core": "^3.26.4",
    "@ts-rest/express": "^3.26.4",
    "@ts-rest/open-api": "^3.26.4",
    "cors": "^2.8.5",
    "express": "^4.18.2",
    "swagger-ui-express": "^5.0.0",
    "youtube-transcript": "^1.0.6",
    "zod": "^3.21.4"
  },
  "devDependencies": {
    "@types/cors": "^2.8.13",
    "@types/express": "^4.17.17",
    "@types/node": "^20.4.7",
    "@types/swagger-ui-express": "^4.1.3",
    "concurrently": "^8.2.0",
    "nodemon": "^3.0.1",
    "ts-node": "^10.9.1",
    "typescript": "^5.1.6"
  }
}

API Contract

Contract is a good way to define the schema for our API, with contract we can have request and response consistent, so we don't need to make duplicate code on the server and client for just defining the object for request and response.

Here's how to define contract in ts-rest:

import { initContract } from "@ts-rest/core";
import { z } from "zod";

export const contract = initContract();

const PostSchema = z.object({
  language: z.string(),
  url: z.string(),
  content: z.string(),
});

export const apiContract = contract.router({
  transcribeVideo: {
    method: "POST",
    path: "/transcribe",
    responses: {
      200: PostSchema,
    },
    body: z.object({
      videoUrl: z.string(),
    }),
    summary: "Transcribe YouTube video by videoUrl",
  },
});

With ts-rest we can define not only the schema for request and response but also http header, query params, request method and path endpoint. This way we can share the code for backend and client via only api contract. This will save a lot of time for backend and frontend developer, so that we can focus more on the product not on boilerplace let ts-rest handle everything for us.

Setup the express server

Let's import dependency for our express server.

import express from "express";
import type { Request, Response } from "express";
import cors from "cors";
import { initServer, createExpressEndpoints } from "@ts-rest/express";
import { apiContract } from "./contract";
import { generateOpenApi } from "@ts-rest/open-api";
import { serve, setup } from "swagger-ui-express";
import { YoutubeTranscript } from "youtube-transcript";

So the package that will help us to make the schema works in express app is @ts-rest/express.

Now let's setup the config for our express app.

const app = express();

app.use(cors());
app.use(express.urlencoded({ extended: false }));
app.use(express.json());

const s = initServer();

And then we can implement the function for each endpoint from our contracts like this.

const router = s.router(apiContract, {
  transcribeVideo: async ({ body }) => {
    const transcript = await YoutubeTranscript.fetchTranscript(body.videoUrl);
    return {
      status: 200,
      body: {
        url: body.videoUrl,
        content: transcript.map((item) => item.text).join("\n"),
        language: "English",
      },
    };
  },
});

If you need to generate the OpenAPI spec you can use @ts-rest/open-api like this.

const openapiDocument = generateOpenApi(
  apiContract,
  {
    openapi: "3.0.0",
    info: { title: "EchoTube API", version: "1.0.0" },
    servers: [
      {
        url: process.env.BASE_URL || "http://localhost:3333/",
      },
    ],
  },
  {
    setOperationId: true,
  }
);

app.get("/swagger.json", (req: Request, res: Response) => {
  res.contentType("application/json");
  res.send(JSON.stringify(openapiDocument, null, 2));
});

Then to display the swagger ui for the OpenAPI spec you can do it like this:

const apiDocs = express.Router();
apiDocs.use(serve);
apiDocs.get("/", setup(openapiDocument, { customCssUrl: CSS_URL }));
app.use("/docs", apiDocs);

With all of the now let's glue all the config togheter and server the http server.

createExpressEndpoints(apiContract, router, app);
const port = process.env.port || 3333;
const server = app.listen(port, () => {
  console.log(`Listening at http://localhost:${port}`);
});

And finally here's the complete source code:

import express from "express";
import type { Request, Response } from "express";
import cors from "cors";
import { initServer, createExpressEndpoints } from "@ts-rest/express";
import { apiContract } from "./contract";
import { generateOpenApi } from "@ts-rest/open-api";
import { serve, setup } from "swagger-ui-express";
import { YoutubeTranscript } from "youtube-transcript";
const CSS_URL =
  "https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.1.0/swagger-ui.min.css";

const app = express();

app.use(cors());
app.use(express.urlencoded({ extended: false }));
app.use(express.json());

const s = initServer();

const router = s.router(apiContract, {
  transcribeVideo: async ({ body }) => {
    const transcript = await YoutubeTranscript.fetchTranscript(body.videoUrl);
    return {
      status: 200,
      body: {
        url: body.videoUrl,
        content: transcript.map((item) => item.text).join("\n"),
        language: "English",
      },
    };
  },
});

const openapiDocument = generateOpenApi(
  apiContract,
  {
    openapi: "3.0.0",
    info: { title: "EchoTube API", version: "1.0.0" },
    servers: [
      {
        url: process.env.BASE_URL || "http://localhost:3333/",
      },
    ],
  },
  {
    setOperationId: true,
  }
);

const apiDocs = express.Router();
apiDocs.use(serve);
apiDocs.get("/", setup(openapiDocument, { customCssUrl: CSS_URL }));
app.use("/docs", apiDocs);
app.get("/swagger.json", (req: Request, res: Response) => {
  res.contentType("application/json");
  res.send(JSON.stringify(openapiDocument, null, 2));
});

createExpressEndpoints(apiContract, router, app);

const port = process.env.port || 3333;
const server = app.listen(port, () => {
  console.log(`Listening at http://localhost:${port}`);
});

And now you can access it like this.

curl -X POST  http://localhost:3333/transcribe \
    -H "Content-Type: application/json" \
    -d '{"videoUrl":"https://www.youtube.com/watch?v=OUArPmGsjPk"}'

Conclusion

That's how to use ts-rest in very simple project, I hope you learn more about ts-rest today. Let me know if you have any question, you can get the full source code for this tutorial here.