DEV Community

Cover image for Tutorial - Configuring TypeScript, Express, Jest, Payload, and VSCode Debugging from Scratch
James Mikrut for Payload CMS

Posted on • Originally published at payloadcms.com

Tutorial - Configuring TypeScript, Express, Jest, Payload, and VSCode Debugging from Scratch

Setting up a TypeScript project from scratch can be intimidating. Bring in Jest, VSCode debugging, and a headless CMS - and there are many moving parts. But it doesn't have to be difficult!

We've been working hard at making Payload + TypeScript a match made in heaven, and over the last few months we've released a suite of features, including automatic type generation, which makes Payload by far the best TypeScript headless CMS available. To celebrate, we're going to show you how to scaffold a TypeScript and Express project from scratch, including testing it with Jest and debugging with VSCode.

By understanding just a few new concepts, you can master your dev environment's setup to maximize productivity and gain a deep understanding how it all works together. Let's get started.

Software Requirements

Before going further, make sure you have the following software:

  • Yarn or NPM
  • NodeJS
  • A Mongo Database

Step 1 - initialize a new project

Create a new folder, cd into it, and initialize:

mkdir ts-payload && cd ts-payload && yarn init
Enter fullscreen mode Exit fullscreen mode

Step 2 - install dependencies

We'll need a few baseline dependencies:

  • dotenv - to set up our environment easily
  • express - Payload is built on top of Express
  • ts-node - to execute our TypeScript project in development mode
  • typescript - base TS dependency
  • payload - no description necessary
  • nodemon - to make sure our project restarts automatically when files change

Install these dependencies by running:

yarn add dotenv express payload
Enter fullscreen mode Exit fullscreen mode

and the rest as devDependencies using:

yarn add --dev nodemon ts-node typescript
Enter fullscreen mode Exit fullscreen mode

Step 3 - create a tsconfig:

In the root of your project folder, create a new file called tsconfig.json and add the following content to it. This file tells the TS compiler how to behave, including where to write its output files.

Example tsconfig.json:

{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "outDir": "./dist",
    "skipLibCheck": true,
    "strict": false,
    "esModuleInterop": true,
    "module": "commonjs",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "sourceMap": true
  },
  "include": [
    "src"
  ],
  "ts-node": {
    "transpileOnly": true
  }
}
Enter fullscreen mode Exit fullscreen mode

In the above example config, we're planning to keep all of our TypeScript files in /src, and then build to /dist.

Step 4 - set up your .env file

We'll be using dotenv to manage our environment and get ourselves set up for deployment to various different environments like staging and production later down the road. The dotenv package will read all values in a .env file within our project root and bind their values to process.env so that you can access them in your code.

Important:

If you're using GitHub, make sure you ignore your environment file from your repository. Often, environment files store sensitive information and should not be included in source code.

Let's create a .env file in the root folder of your project and add the following:

PORT=3000
MONGO_URL=mongodb://localhost/your-project-name
PAYLOAD_SECRET_KEY=alwifhjoq284jgo5w34jgo43f3
PAYLOAD_CONFIG_PATH=src/payload.config.ts
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Make sure that the MONGO_URL line in your .env matches an available MongoDB instance. If you have Mongo running locally on your computer, the line above should work right out of the box, but if you want to use a hosted MongoDB like Mongo Atlas, make sure you copy and paste the connection string from your database and update your .env accordingly.

For more information on what these values do, take a look at Payload's Getting Started docs.

Step 5 - create your server

Setting up an Express server might be pretty familiar. Create a src/server.ts file in your project and add the following to the file:

import express from 'express';
import payload from 'payload';
import path from 'path';

// Use `dotenv` to import your `.env` file automatically
require('dotenv').config({
  path: path.resolve(__dirname, '../.env'),
});

const app = express();

payload.init({
  secret: process.env.PAYLOAD_SECRET_KEY,
  mongoURL: process.env.MONGO_URL,
  express: app,
})

app.listen(process.env.PORT, async () => {
    console.log(`Express is now listening for incoming connections on port ${process.env.PORT}.`)
});
Enter fullscreen mode Exit fullscreen mode

The file above first imports our server dependencies. Then, we use dotenv to load our .env file at our project root. Next, we initialize Payload by providing it with our secret key, Mongo connection string, and Express app. Finally, we tell our Express app to listen on the port defined in our .env file.

Step 6 - create a nodemon file

We'll use Nodemon to automatically restart our server when any .ts files change within our ./src directory. Nodemon will execute ts-node for us, which will use our server as its entry point. Create a nodemon.json file within the root of your project and add the following content.

Example nodemon.json:

{
  "watch": [
    "./src/**/*.ts"
  ],
  "exec": "ts-node ./src/server.ts"
}
Enter fullscreen mode Exit fullscreen mode

Step 7 - add your Payload config

The Payload config is central to everything Payload does. Add it to your src folder and enter the following baseline code:

./src/payload.config.ts:

import dotenv from 'dotenv';
import path from 'path';
import { buildConfig } from 'payload/config';

dotenv.config({
  path: path.resolve(__dirname, '../.env'),
});

export default buildConfig({
  serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL,
    typescript: {
        outputFile: path.resolve(__dirname, './generated-types.ts'),
    },
  collections: [
    {
      slug: 'posts',
      admin: {
        useAsTitle: 'title',
      },
      fields: [
        {
          name: 'title',
          type: 'text',
        },
        {
          name: 'author',
          type: 'relationship',
          relationTo: 'users',
        },
      ]
    }
  ]
});
Enter fullscreen mode Exit fullscreen mode

This config is very basic - but check out the Config docs for more on what the Payload config can do. Out of the box, this config will give you a default Users collection, a simple Posts collection with a few fields, and will open up the admin panel to you at http://localhost:3000/admin.

The config also specifies where Payload should output its auto-generated TypeScript types which is super cool (we'll come back to this).

Step 8 - add some NPM scripts

The last step before we can fire up our project is to add some development, build, and production NPM scripts.

Open your package.json and update the scripts property to the following:

{
  "scripts": {
    "generate:types": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
    "dev": "nodemon",
    "build:payload": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
    "build:server": "tsc",
    "build": "yarn build:server && yarn build:payload",
    "serve": "PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js"
  },
}
Enter fullscreen mode Exit fullscreen mode

To support Windows environments consider adding the cross-env package as a devDependency and use it in scripts before setting variables.

The first script uses Payload's generate:types command in order to automatically generate TypeScript types for each of your collections and globals automatically. You can run this command whenever you need to regenerate your types, and then you can use these types in your Payload code directly.

Tip:

Payload's ability to automatically generate types from your configs is super powerful and will benefit you immensely if you are writing custom hooks or custom components.

The next script is to execute nodemon, which will read the nodemon.json config that we've written and execute our /src/server.ts script, which fires up Payload in development mode.

The following three scripts are how we will prepare Payload's admin panel for production as well as how to compile the server's TypeScript code into regular JS to use in production.

Finally, we have a serve script which is used to serve our app in production mode once it's built.

Firing it up

We're ready to go! Run yarn dev in the root of your folder to start up Payload. Then, visit http://localhost:3000/admin to create your first user and sign into the admin panel.

Generating Payload types

Now that we've got a server generated, let's try and generate some types. Run the following command to automatically generate a file that contains an interface for the default Users collection and our simple Posts collection:

yarn generate:types
Enter fullscreen mode Exit fullscreen mode

Then check out the file that was created at /src/generated-types.ts. You can import these types in your own code to do some pretty awesome stuff.

Testing with Jest

Now it's time to get some tests written. There are a ton of different approaches to testing, but we're going to go straight for end-to-end tests. We'll use Jest to write our tests, and set up a fully functional Express server before we start our tests so that we can test against something that's as close to production as possible.

We'll also use mongodb-memory-server to connect to for all tests, so that we don't have to clutter up our development database with testing documents. This is great, because our tests will be totally controlled and isolated, but coverage will be incredibly thorough due to how we'll be testing the full API from top to bottom.

Payload will automatically attempt to use mongodb-memory-server if two conditions are met:

  1. It is locally installed in your project
  2. NODE_ENV is equal to test

Adding and configuring test dependencies

OK. Let's install all the testing dependencies we'll need:

yarn add --dev jest mongodb-memory-server babel-jest @babel/core @babel/preset-env @babel/preset-typescript isomorphic-fetch @types/jest
Enter fullscreen mode Exit fullscreen mode

Now let's add two new config files. First, babel.config.js:

module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' } }],
    '@babel/preset-typescript',
    '@babel/preset-react',
  ],
};
Enter fullscreen mode Exit fullscreen mode

We use Babel so we can write tests in TypeScript, and test full React components.

Next, jest.config.js:

module.exports = {
  verbose: true,
  testEnvironment: 'node',
  globalSetup: '<rootDir>/src/tests/globalSetup.ts',
  roots: ['<rootDir>/src/'],
};
Enter fullscreen mode Exit fullscreen mode

Global Test Setup

A sharp eye might find that we're using a globalSetup file in jest.config.js to scaffold our project before any of the real magic starts. Let's add that file:

src/tests/globalSetup.ts:

import '../server';
import { resolve } from 'path';
import testCredentials from './credentials';

require('dotenv').config({
  path: resolve(__dirname, '../.env'),
});

const { PAYLOAD_PUBLIC_SERVER_URL } = process.env;

const globalSetup = async (): Promise<void> => {
  const response = await fetch(`${PAYLOAD_PUBLIC_SERVER_URL}/api/users/first-register`, {
    body: JSON.stringify({
      email: testCredentials.email,
      password: testCredentials.password,
    }),
    headers: {
      'Content-Type': 'application/json',
    },
    method: 'post',
  });

  const data = await response.json();

  if (!data.user || !data.user.token) {
    throw new Error('Failed to register first user');
  }
};

export default globalSetup;
Enter fullscreen mode Exit fullscreen mode

In this file, we're performing the following actions before our tests are executed:

  1. Importing the server, which will boot up Express and Payload using mongodb-memory-server
  2. Loading our .env file
  3. Registering a first user so that we can authenticate in our tests

You'll notice we are importing testCredentials from next to our globalSetup file. Because our Payload API will require authentication for many of our tests, and we're creating that user in our globalSetup file, we will want to reuse our user credentials in other tests to ensure we can authenticate as the newly created user. Let's create a reusable file to store our user's credentials:

src/tests/credentials.ts:

export default {
  email: 'test@test.com',
  password: 'test',
}
Enter fullscreen mode Exit fullscreen mode

Our first test

Now that we've got our global setup in place, we can write our first test.

Add a file called src/tests/login.spec.ts:

import { User } from '../generated-types';
import testCredentials from './credentials';

require('isomorphic-fetch');

describe('Users', () => {
  it('should allow a user to log in', async () => {
    const result: {
      token: string
      user: User
    }  = await fetch(`${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/users/login`, {
      method: 'post',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        email: testCredentials.email,
        password: testCredentials.password,
      }),
    }).then((res) => res.json());

    expect(result.token).toBeDefined();
  });
});

Enter fullscreen mode Exit fullscreen mode

The test above is written in TypeScript and imports our auto-generated User TypeScript interface to properly type the fetch response that is returned from Payload's login REST API.

It will expect that a token is returned from the response.

Running tests

The last step is to add a script to execute our tests. Let's add a new line to our package.json scripts property:

    "test": "jest --forceExit --detectOpenHandles"
Enter fullscreen mode Exit fullscreen mode

Now, we can run yarn test to see a successful test!

Debugging with VSCode

Debugging can be an absolutely invaluable tool to developers working on anything more complex than a simple app. It can be difficult to understand how to set up, but once you have it configured properly, a proper debugging workflow can be significantly more powerful than just relying on console.log all the time.

You can debug your application itself, and you can even debug your tests to troubleshoot any tests that might fail in the future. Let's see how to set up VSCode to debug our new Typescript app and its tests.

First, create a new folder within your project root called .vscode. Then, add a launch.json within that folder, containing the following configuration:

./.vscode/launch.json:

{
  "version": "0.2.0",
  "configurations": [
        {
      "type": "node",
      "request": "launch",
      "name": "Launch Program",
      "env": {
        "PAYLOAD_CONFIG_PATH": "src/payload.config.ts",
      },
      "program": "${workspaceFolder}/src/server.ts",
    },
    {
      "name": "Debug Jest Tests",
      "type": "node",
      "request": "launch",
      "runtimeArgs": [
        "--inspect-brk",
        "${workspaceRoot}/node_modules/.bin/jest",
        "--runInBand"
      ],
      "env": {
        "PAYLOAD_CONFIG_PATH": "src/payload.config.ts"
      },
    }
  ]
}

Enter fullscreen mode Exit fullscreen mode

The file above includes two configurations. The first is to be able to debug your Express + Payload app itself, including any files you've imported within your Payload config. The second debug config is to be able to set breakpoints and debug directly within your Jest testing suite.

To debug, you can set breakpoints right in your code by clicking to the left of the line numbers. A breakpoint will be set and show as a red circle. When VSCode executes your scripts, it will pause at the breakpoint(s) you set and allow you to inspect the value of all variables, where your function(s) have been called from, and much more.

To start the debugger, click the "Run and Debug" sidebar icon in VSCode, choose the debugger you want to start, and click the "Play" button. If you've placed breakpoints, VSCode will automatically pause when it reaches your breakpoint.

Here is an example of a breakpoint being hit within our src/server.ts file:

Debugging Express and Payload using TypeScript in VSCode

Here is a screenshot of a breakpoint being hit within our login.spec.ts test:

Debugging a TypeScript Jest app in VSCode

Debugging can be an invaluable tool to you as a developer. Setting it up early in your project will pay dividends over time as your project gets more complex, and it will help you understand how your project works to an extremely fine degree.

Conclusion

With all of the above pieces in place, you have a modern and well-equipped dev environment that you can use to build out Payload into anything you can think of - be it an API to power a web app, native app, or just a headless CMS to power a website.

You can find the code for this guide here. Let us know what you think!

Star us on GitHub

If you haven't already, stop by GitHub and give us a star by clicking on the "Star" button in the top-right corner of our repo. With our inclusion into YC and our move to open-source, we're looking to dramatically expand our community and we can't do it without you.

Join our community on Discord

We've recently started a Discord community for the Payload community to interact in realtime. Often, Discord will be the first to hear about announcements like this move to open-source, and it can prove to be a great resource if you need help building with Payload. Click here to join!

Discussion (0)