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
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
and the rest as devDependencies using:
yarn add --dev nodemon ts-node typescript
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
}
}
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
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}.`)
});
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"
}
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',
},
]
}
]
});
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"
},
}
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
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:
- It is locally installed in your project
-
NODE_ENV
is equal totest
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
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',
],
};
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/'],
};
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;
In this file, we're performing the following actions before our tests are executed:
- Importing the server, which will boot up Express and Payload using
mongodb-memory-server
- Loading our
.env
file - 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',
}
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();
});
});
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"
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"
},
}
]
}
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:
Here is a screenshot of a breakpoint being hit within our login.spec.ts
test:
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!
Top comments (0)