Building RESTful APIs with Node.js, Express, and TypeScript
Build an administrative user-profile API with Express, TypeScript, and PostgreSQL, then create, read, update, and delete a profile through HTTP. You will generate local test tokens and restart the API to verify that the profile survives. The API stores profile data; it does not implement login or password storage.
Introduction
The API serves trusted administrators with signed access tokens. A token needs the
profiles:read scope to read profiles and profiles:write to create, edit, or delete them.
These scopes authorize access to all profiles; this is not a self-service user API.
The trusted issuer assigns scopes. Request bodies cannot choose an authorization identity or role.
System requirements
Use Bash on Linux, Node.js 24.21.0, Corepack, cURL, and a running Docker Engine. Corepack runs the project’s pinned Yarn 4.12.0 without a global Yarn installation. The example uses PostgreSQL 17.11, Express 5.2.1, TypeORM 1.1.1, and TypeScript 5.9.3. Node.js 24 is a maintained LTS release. Express 5 forwards rejected route-handler promises to error middleware.
The local signer below supplies RS256 JWTs with issuer, audience, subject, expiration, and space-separated scopes. It lets you exercise real signature verification without an identity provider account. Anyone holding its private key can grant administrator access, so use it only for this loopback demonstration.
Setting up the environment
Run the setup steps in order and stop on any error. If stack-api already exists, choose another
directory rather than overwriting it. The subshell keeps your original directory on a setup failure:
(
mkdir stack-api && cd stack-api &&
mkdir -p src/config src/controllers src/middleware src/models src/routes src/local
) && cd stack-api
Save this complete package.json:
{
"name": "stack-api",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"build": "tsc --project tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"express": "5.2.1",
"helmet": "8.3.0",
"jose": "6.2.12",
"pg": "8.23.0",
"reflect-metadata": "0.2.2",
"typeorm": "1.1.1",
"zod": "3.25.76"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "24.10.1",
"typescript": "5.9.3"
}
}
Save .yarnrc.yml to use a project-local node_modules installation:
nodeLinker: node-modules
enableGlobalCache: false
enableTelemetry: false
ignorePath: true
injectEnvironmentFiles: []
Create tsconfig.json. Explicit types and typeRoots prevent the compiler from including
unrelated ambient types from an enclosing project:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"],
"typeRoots": ["./node_modules/@types"],
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"]
}
Now install. An empty yarn.lock establishes this directory as a
separate Yarn project, including inside a workspace.
Clearing NODE_OPTIONS prevents an enclosing project’s
Plug’n’Play loader from being injected into these commands:
touch yarn.lock && NODE_OPTIONS='' corepack yarn install
Keep the generated lockfile for subsequent installs. Do not put local signing keys or database
credentials in source control; ignore .local/ in your own project.
Project structure
src/
├── config/database.ts
├── controllers/userController.ts
├── middleware/errorHandler.ts
├── middleware/auth.ts
├── models/User.ts
├── routes/userRoutes.ts
├── local/createKeys.ts
├── local/issueToken.ts
├── app.ts
└── index.ts
Setting up the database connection
Start a disposable database with the official PostgreSQL image. Docker assigns an available port on loopback. The password below is only for this local example; the container’s database user is a superuser, unsuitable for a deployed application.
: "${DB_CONTAINER:=stack-api-db-${RANDOM}-${RANDOM}}"
docker run --detach --name "$DB_CONTAINER" \
--publish 127.0.0.1::5432 \
--tmpfs /var/lib/postgresql/data:rw,size=256m \
--env POSTGRES_USER=profiles --env POSTGRES_DB=profiles \
--env POSTGRES_PASSWORD=local-only-password postgres:17.11-bookworm
Check TCP readiness, retrying 30 times with a one-second pause between attempts. Continue only if this block succeeds:
(
for attempt in {1..30}; do
if docker exec "$DB_CONTAINER" pg_isready -h 127.0.0.1 -U profiles -d profiles; then
exit 0
fi
sleep 1
done
printf 'PostgreSQL did not become ready; inspect docker logs.\n' >&2
exit 1
)
Keep this terminal open: later configuration and cleanup use DB_CONTAINER.
The database remains available while the API restarts, but its temporary data disappears when
the database container stops. This is deliberately a local exercise.
Create src/config/database.ts:
import 'reflect-metadata'
import { DataSource } from 'typeorm'
import { User } from '../models/User.js'
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required')
export const AppDataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
synchronize: process.env.SCHEMA_SYNC === 'development-only',
logging: false,
entities: [User],
})
The .js import specifiers refer to the emitted files in this standalone NodeNext project.
Compile with TypeScript: Node’s type stripping
does not transform TypeORM’s decorators.
Use reviewed TypeORM migrations
for deployed databases.
Creating the user model
Create src/models/User.ts:
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column({ type: 'varchar', length: 100 })
name!: string
@Column({ type: 'varchar', length: 254, unique: true })
email!: string
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date
}
There is no password column. Creating a profile does not create a login account or grant access.
timestamptz
stores the creation time as an instant, independent of the database session’s time zone.
Error handling middleware
Create src/middleware/errorHandler.ts. Generate a fresh request ID for every request;
never trust a client’s request-ID header as an audit identifier.
import { randomUUID } from 'node:crypto'
import type { ErrorRequestHandler, RequestHandler } from 'express'
import { QueryFailedError } from 'typeorm'
import { z, ZodError } from 'zod'
export class AppError extends Error {
constructor(public statusCode: number, message: string) {
super(message)
}
}
export const requestId: RequestHandler = (_req, res, next) => {
res.locals.requestId = randomUUID()
res.setHeader('X-Request-ID', res.locals.requestId)
res.setHeader('Cache-Control', 'no-store')
next()
}
export const errorHandler: ErrorRequestHandler = (error, _req, res, next) => {
if (res.headersSent) {
next(error)
return
}
let status = 500
let message = 'Request failed'
if (error instanceof AppError) {
status = error.statusCode
message = error.message
} else if (error instanceof ZodError) {
status = 400
message = 'Invalid request'
} else if (error instanceof QueryFailedError &&
z.object({ code: z.literal('23505') }).safeParse(error.driverError).success) {
status = 409
message = 'Email is already in use'
} else {
const parsed = z.object({
type: z.enum(['entity.parse.failed', 'entity.too.large']),
}).safeParse(error)
if (parsed.success) {
status = parsed.data.type === 'entity.too.large' ? 413 : 400
message = 'Invalid request body'
}
}
console.error(JSON.stringify({ event: 'request_failed', requestId: res.locals.requestId, status }))
res.status(status).json({ error: message, requestId: res.locals.requestId })
}
Create src/middleware/auth.ts. The configured public key, issuer, audience, and algorithm
are server policy; no token header or request parameter can replace them.
jose verifies the signature and claims using
these verification options.
The server requires sub, exp, and iat, and rejects tokens issued more than 15 minutes ago
even if their expiration is later. Your production issuer must supply these claims too.
import type { RequestHandler } from 'express'
import { importSPKI, jwtVerify } from 'jose'
import { AppError } from './errorHandler.js'
const { JWT_PUBLIC_KEY, JWT_ISSUER, JWT_AUDIENCE } = process.env
if (!JWT_PUBLIC_KEY || !JWT_ISSUER || !JWT_AUDIENCE) {
throw new Error('JWT verification configuration is required')
}
const verificationKey = await importSPKI(JWT_PUBLIC_KEY, 'RS256')
export function authorize(scope: string): RequestHandler {
return async (req, _res, next) => {
const match = /^Bearer ([A-Za-z0-9_.-]+)$/.exec(req.headers.authorization ?? '')
if (!match) throw new AppError(401, 'Authentication required')
let payload
try {
const verified = await jwtVerify(match[1], verificationKey, {
algorithms: ['RS256'],
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
requiredClaims: ['sub', 'exp', 'iat'],
maxTokenAge: '15m',
})
payload = verified.payload
} catch {
throw new AppError(401, 'Invalid access token')
}
if (typeof payload.scope !== 'string' || !payload.scope.split(' ').includes(scope)) {
throw new AppError(403, 'Permission denied')
}
next()
}
}
Setting up the Express Server
Create src/app.ts:
import express from 'express'
import helmet from 'helmet'
import { errorHandler, requestId } from './middleware/errorHandler.js'
import { userRoutes } from './routes/userRoutes.js'
export const app = express()
app.disable('x-powered-by')
app.use(requestId)
app.use(helmet())
app.use('/api/v1/users', userRoutes)
app.use((_req, res) => res.status(404).json({
error: 'Not found', requestId: res.locals.requestId,
}))
app.use(errorHandler)
Create src/index.ts:
import { once } from 'node:events'
import { z } from 'zod'
import { app } from './app.js'
import { AppDataSource } from './config/database.js'
async function main(): Promise<void> {
const port = z.coerce.number().int().min(1).max(65535).parse(process.env.PORT ?? 3000)
await AppDataSource.initialize()
const server = app.listen(port, '127.0.0.1')
server.requestTimeout = 30_000
server.headersTimeout = 10_000
await once(server, 'listening')
console.log(`Listening on http://127.0.0.1:${port}`)
}
main().catch(async () => {
console.error('API startup failed; check database, JWT settings, and PORT')
if (AppDataSource.isInitialized) await AppDataSource.destroy()
process.exitCode = 1
})
The ready message appears only after PostgreSQL connects and the HTTP port binds.
If startup fails, check the database container, key configuration, and whether another process
already uses PORT. Choose a free port rather than stopping an unfamiliar process.
User controller
Create src/controllers/userController.ts. Strict schemas reject extra fields instead of
silently accepting id, password, role, or timestamps.
import type { Request, Response } from 'express'
import { z } from 'zod'
import { AppDataSource } from '../config/database.js'
import { AppError } from '../middleware/errorHandler.js'
import { User } from '../models/User.js'
const profiles = AppDataSource.getRepository(User)
const profileSchema = z.object({
name: z.string().trim().min(1).max(100),
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
}).strict()
const patchSchema = profileSchema.partial().refine((value) => Object.keys(value).length > 0)
const idSchema = z.string().uuid()
export async function getUsers(req: Request, res: Response): Promise<void> {
const query = z.object({
offset: z.coerce.number().int().min(0).max(10000).default(0),
limit: z.coerce.number().int().min(1).max(100).default(20),
}).strict().parse(req.query)
const data = await profiles.find({ skip: query.offset, take: query.limit, order: { id: 'ASC' } })
res.json({ data })
}
export async function getUser(req: Request, res: Response): Promise<void> {
const user = await profiles.findOneBy({ id: idSchema.parse(req.params.id) })
if (!user) throw new AppError(404, 'User not found')
res.json({ data: user })
}
export async function createUser(req: Request, res: Response): Promise<void> {
const { name, email } = profileSchema.parse(req.body)
const user = await profiles.save(profiles.create({ name, email }))
res.location('/api/v1/users/' + user.id).status(201).json({ data: user })
}
export async function updateUser(req: Request, res: Response): Promise<void> {
const id = idSchema.parse(req.params.id)
const fields = patchSchema.parse(req.body)
const result = await profiles.update(id, fields)
if (result.affected === 0) throw new AppError(404, 'User not found')
res.status(204).end()
}
export async function deleteUser(req: Request, res: Response): Promise<void> {
const result = await profiles.delete(idSchema.parse(req.params.id))
if (result.affected === 0) throw new AppError(404, 'User not found')
res.status(204).end()
}
Updates and deletions return an empty 204 response. Duplicate normalized emails return 409. Reads return only the profile model’s public fields.
User routes
Create src/routes/userRoutes.ts. Authentication happens before JSON parsing.
import { json, Router } from 'express'
import { createUser, deleteUser, getUser, getUsers, updateUser } from '../controllers/userController.js'
import { authorize } from '../middleware/auth.js'
export const userRoutes = Router()
const body = json({ limit: '10kb' })
userRoutes.get('/', authorize('profiles:read'), getUsers)
userRoutes.get('/:id', authorize('profiles:read'), getUser)
userRoutes.post('/', authorize('profiles:write'), body, createUser)
userRoutes.patch('/:id', authorize('profiles:write'), body, updateUser)
userRoutes.delete('/:id', authorize('profiles:write'), deleteUser)
Running the application
Create src/local/createKeys.ts. Run this once per fresh project; it refuses to overwrite keys:
import { generateKeyPairSync } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
const pair = generateKeyPairSync('rsa', { modulusLength: 2048 })
await mkdir('.local', { mode: 0o700 })
await writeFile('.local/private.pem', pair.privateKey.export({ type: 'pkcs8', format: 'pem' }), {
flag: 'wx', mode: 0o600,
})
await writeFile('.local/public.pem', pair.publicKey.export({ type: 'spki', format: 'pem' }), {
flag: 'wx', mode: 0o600,
})
Create src/local/issueToken.ts. This local test issuer grants both administrator scopes for
five minutes. It is a command-line helper, not an API login route:
import { readFile } from 'node:fs/promises'
import { importPKCS8, SignJWT } from 'jose'
const key = await importPKCS8(await readFile('.local/private.pem', 'utf8'), 'RS256')
const token = await new SignJWT({ scope: 'profiles:read profiles:write' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuer('https://local-issuer.example.test')
.setAudience('profile-api')
.setSubject('local-admin')
.setIssuedAt()
.setExpirationTime('5m')
.sign(key)
console.log(token)
Compile the TypeScript and create the keys. The && prevents running an older build if
compilation fails:
NODE_OPTIONS='' corepack yarn build && NODE_OPTIONS='' node dist/local/createKeys.js
Configure the server in the same terminal that started PostgreSQL. JWT_PUBLIC_KEY contains
the PEM text with actual newlines. SCHEMA_SYNC enables table creation only for this disposable
database; leave it unset for a deployed database managed with migrations.
DB_ADDRESS="$(docker port "$DB_CONTAINER" 5432/tcp)" &&
LOCAL_PUBLIC_KEY="$(cat .local/public.pem)" &&
export DATABASE_URL="postgresql://profiles:local-only-password@${DB_ADDRESS}/profiles" \
JWT_PUBLIC_KEY="$LOCAL_PUBLIC_KEY" \
JWT_ISSUER='https://local-issuer.example.test' JWT_AUDIENCE='profile-api' \
SCHEMA_SYNC=development-only PORT="${PORT:-3000}"
Start the API in the foreground. Wait for Listening on http://127.0.0.1:3000, or the port
you selected:
NODE_OPTIONS='' corepack yarn start
Testing the API
Open a second terminal in stack-api. Set PORT there too if you selected a different port.
Run these steps in this same second terminal so the token and returned UUID stay available.
Keep tokens out of logs and frontend source. To renew an expired token, repeat the token command.
BASE_URL="http://127.0.0.1:${PORT:-3000}"
NEXT_ACCESS_TOKEN="$(NODE_OPTIONS='' node dist/local/issueToken.js)" && ACCESS_TOKEN="$NEXT_ACCESS_TOKEN"
Create the profile and capture the returned UUID only after a successful response. If the request fails, do not proceed to the next step. A 409 means that email is already present; use a different email or read the existing profile rather than blindly retrying a possibly committed POST.
CREATED="$(curl -fsS -X POST "$BASE_URL/api/v1/users" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"John Doe","email":"john@example.com"}')" &&
NEXT_PROFILE_ID="$(printf '%s' "$CREATED" | NODE_OPTIONS='' node --input-type=module -e '
import { z } from "zod"
let text = ""
for await (const chunk of process.stdin) text += chunk
const { data } = z.object({ data: z.object({ id: z.string().uuid() }) }).parse(JSON.parse(text))
console.log(data.id)
')" && PROFILE_ID="$NEXT_PROFILE_ID" && printf '%s\n' "$CREATED"
The response is {"data":{"id":"…","name":"John Doe","email":"john@example.com","createdAt":"…"}},
with HTTP 201 and a Location header for the new profile. Read the profile and the paginated list:
curl -fsS "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" &&
curl -fsS "$BASE_URL/api/v1/users?limit=20&offset=0" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Update the name. -i shows the status and headers because PATCH returns no body:
curl -fsS -i -X PATCH "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"John Smith"}'
Expect HTTP 204. In the first terminal, press Ctrl+C, then repeat the startup block above. Leave PostgreSQL running. In the second terminal, repeat the following GET; the same UUID, updated name, email, and creation time should still be present:
curl -fsS "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Delete that profile, then observe its 404 response. The last command deliberately omits -f
so you can read the error JSON:
curl -fsS -i -X DELETE "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" &&
curl -sS -i "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
DELETE returns an empty 204. The following GET returns 404 with error and requestId.
Every response has a fresh X-Request-ID; an error body’s ID matches its header. Authentication
runs before body parsing, and the JSON parser limits the decoded request body to 10 KiB.
API documentation
Use these outcomes when describing this API in OpenAPI:
| Request | Status |
|---|---|
| Create a valid profile | 201, with Location |
| Read a profile or paginated list | 200 |
| Update or delete an existing profile | 204, empty body |
| Missing, expired, wrongly signed, or wrong issuer/audience token | 401 |
| Valid token without the route’s required scope | 403 |
| Invalid UUID, fields, pagination, or malformed JSON | 400 |
| Duplicate email after trimming and lowercasing | 409 |
| JSON body above 10 KiB | 413 |
| Unknown or deleted profile UUID | 404 |
Profile writes accept only name and email. PATCH requires at least one of them, and pagination
defaults to 20 items with a maximum of 100 and an offset between zero and 10,000. Document the
two scopes as administrator permissions over all profiles, including the bearer-token scheme
and request-ID header.
Deployment considerations
For an application with real users, replace the local signer with your identity provider's administrative access-token flow. Obtain its public key through trusted configuration and set the matching issuer and audience on the server. This example uses a fixed verification key; it does not implement key rotation or token revocation.
Keep the API behind HTTPS for remote access, replace the disposable superuser database with restricted application credentials, and use migrations instead of schema synchronization. Stopping the Node process does not stop PostgreSQL. When finished, stop the API and remove only the database container you created, discarding its temporary data:
docker rm --force --volumes "$DB_CONTAINER"
Conclusion
If these profiles later need file attachments, Transloadit’s upload API provides a separate ingestion and processing service.
