From project setup to production — routing, validation, error handling, and testing.
Welcome everyone! Today we're diving into building production-ready REST APIs using Express.js and TypeScript. We'll cover everything from writing your first route to deploying a secure, scalable API. By the end of this talk, you'll understand RESTful architecture, how to structure an Express application with proper typing, and the essential patterns for validation, error handling, and security. Whether you're new to backend development or looking to level up your API skills, this practical guide will give you the tools you need. Let's get started.
Let's start with the fundamentals. REST, or Representational State Transfer, is an architectural style defined by Roy Fielding in 2000. Looking at the four key principles shown here: {{step}}First, REST is resource-oriented. Everything is a resource with a URL, like slash users or slash orders slash forty-two. {{step}}Second, it's stateless. Each request contains all the context it needs, like an auth token or parameters. The server doesn't remember anything between requests. {{step}}Third, REST uses a uniform interface. Standard HTTP methods and status codes make every API predictable. {{step}}And fourth, responses are cacheable. GET requests can be safely cached, while POST, PUT, and DELETE cannot. These principles give us a consistent, scalable way to design APIs.
Now let's look at the five core HTTP methods. The table here maps them to CRUD operations. GET reads a resource and is idempotent with no request body. POST creates new resources and is not idempotent, meaning two identical POST requests create two separate resources. PUT replaces an entire resource, PATCH partially updates it, and DELETE removes it. All three are idempotent. Notice the idempotent column. This is crucial for API design. Idempotent means calling it multiple times produces the same result. If you DELETE a user twice, the user is still gone. But POST twice creates two users. Understanding this distinction helps you choose the right method for each operation.
Let's set up our project. Looking at the terminal commands here, we start by creating a directory and initializing npm. Then we install Express, CORS for handling cross-origin requests, and Helmet for security headers. Notice we're also installing TypeScript and the type definitions for Express and Node. The tsx package is particularly useful because it lets us run TypeScript directly during development without a separate build step. We then initialize TypeScript with strict mode enabled, pointing our output to a dist folder and our source to src. Finally, we create the source directory and our entry file. This gives us a solid foundation with type safety from day one.
Here's our main entry point. Looking at this code, we import Express and our middleware libraries, then create the Express app. Notice the middleware chain. We apply Helmet first to set secure HTTP headers, then CORS to enable cross-origin requests, and express dot json to parse incoming JSON bodies. These run on every single request. Next, we mount our route modules under the slash api path. This is where all our user routes will live. Critically, the error handler is registered last. Express processes middleware in order, so the error handler must come after all routes to catch any errors they throw. Finally, we start the server on port 3000, or whatever's in the environment variable.
Now let's define some routes with proper TypeScript types. At the top, we define a User interface. This becomes our API contract, ensuring type safety throughout our handlers. Looking at the first route, GET slash api slash users returns a list of all users. We query the database and respond with a JSON object containing the data and total count. {{step}}The second route, GET slash api slash users slash id, retrieves a single user by ID. Notice on line seventeen, we check if the user exists. If not, we return a 404 status with an error message. Otherwise, we respond with the user data. TypeScript's Request and Response types give us full autocomplete and type checking throughout these handlers.
Middleware is what makes Express so powerful. As you can see here, middleware functions run in order between receiving a request and sending a response. Looking at the first function, request logger, it logs every request method, path, and timestamp, then calls next to pass control to the next middleware. The second example shows auth middleware. This protects specific routes by checking for a bearer token in the authorization header. If there's no token, we respond with a 401 immediately. If the token is valid, we decode it with JWT verify, attach the user payload to the request object, and call next. If verification fails, we catch the error and return 401. Each middleware either responds or calls next. This chain of responsibility pattern is incredibly flexible.
Never trust client input. Here's where Zod comes in. Looking at this code, we define a schema that's both validation rules and a TypeScript type. The CreateUserSchema requires a name between two and one hundred characters, a valid email, and a role that's either admin or member with a default of member. Below that, we have a generic validate function that takes any Zod schema. It parses the request body using safe parse. If validation fails, we return a 422 status with detailed field errors. If it succeeds, we replace the request body with the parsed and typed data, then call next. At the bottom, you can see how clean the usage is. Just pass validate with your schema as middleware before your handler. This gives you runtime validation and compile-time types in one step.
Centralized error handling keeps your code clean and your errors consistent. Looking at this code, we define a custom AppError class that extends the built-in Error. It includes an HTTP status code and a machine-readable error code. Below that is our error handler middleware. Express knows it's an error handler because it takes four arguments. If the error is an instance of AppError, we return a structured JSON response with the status code and error details. For unexpected errors, we log the full error for debugging but return a generic five hundred response to the client. This prevents leaking implementation details. In your route handlers, you just throw new AppError and this middleware catches it. No more try-catch blocks everywhere.
Let's integrate a database with proper connection pooling. Looking at the top of this code, we create a postgres pool with our database URL. The pool maintains up to twenty connections, reusing them across requests instead of opening a new connection for every query. This is critical for performance. Below that, we have a typed query helper function. It executes the SQL and returns the rows cast to our generic type. Looking at the usage example at the bottom, we're inserting a new user with a parameterized query. Notice the dollar one, dollar two, dollar three placeholders. We pass the actual values in the array. This is essential. Never interpolate user input directly into SQL strings. Always use parameterized queries to prevent SQL injection attacks.
Let's look at testing. Here we're using Supertest and Vitest. Looking at the first test, we make a POST request to slash api slash users with valid data. Supertest lets us make real HTTP requests against our Express app without actually starting a server. We check that the status is 201, and that the response body matches our expected user object. Notice the role defaults to member even though we didn't specify it. That's our validation schema at work. The second test verifies error handling. We send an invalid email and expect a 422 status. We also check that the response body includes validation details for the email field. Testing both happy paths and error cases gives you confidence that your API behaves correctly.
Security isn't optional. Here are four essential practices. {{step}}First, rate limiting. Use express-rate-limit to cap requests per IP address. Start with one hundred requests per fifteen minutes for authentication endpoints. {{step}}Second, input sanitization. Validate everything with Zod or Joi. Reject unexpected fields and never pass raw input to SQL or shell commands. {{step}}Third, HTTPS only. Terminate TLS at your load balancer, set HSTS headers, and redirect all HTTP traffic to HTTPS in production. {{step}}And fourth, authentication best practices. Use short-lived JWTs, maybe fifteen minutes. Store refresh tokens in HTTP-only cookies so JavaScript can't access them. And always hash passwords with bcrypt, never store them in plain text. Following these four principles will protect your API from the most common attacks.
Finally, let's talk deployment. We have four critical practices here. {{step}}First, environment variables. Never hardcode secrets in your code. Use dot env files locally and inject variables in production. Validate them at startup with Zod so your app fails fast if configuration is wrong. {{step}}Second, health checks. Expose a GET slash health endpoint that checks your database connectivity and returns 200 if healthy or 503 if not. Load balancers and orchestrators depend on this. {{step}}Third, graceful shutdown. Listen for the SIGTERM signal. When you receive it, stop accepting new connections, finish any in-flight requests, close your database pool, and then exit. {{step}}And fourth, structured logging. Use pino or winston to log JSON with request IDs, methods, paths, status codes, and durations. This makes debugging production issues much easier. These practices ensure your API runs reliably in production. Thank you for joining me today!
Hands-on implementation guides with detailed code examples, step-by-step instructions, and expanded explanations for each topic.