Backend with TypeScript, PostgreSQL & Prisma: REST, Validation & Tests
Update (July 2026): This article was updated for Prisma ORM 7. The REST API is now built with Hono instead of Hapi, with Zod for input validation and Vitest for testing. It uses the rust-free
prisma-clientgenerator, theprisma.config.tsconfig file, and driver adapters. All commands and code work verbatim on Prisma ORM 7 with Node.js 20 or later.
This article is part of a series on building a backend with TypeScript, PostgreSQL, and Prisma. In this second part, you will build a REST API on top of the data model from part 1, validate input, and test the API.
Introduction
The goal of the series is to explore and demonstrate different patterns, problems, and architectures for a modern backend by solving a concrete problem: a grading system for online courses. This is a good example because it features diverse relation types and is complex enough to represent a real-world use case.
What the series covers
| Topic | Part |
|---|---|
| Data modeling | Part 1 |
| CRUD | Part 1 |
| Aggregations | Part 1 |
| REST API layer | Part 2 (current) |
| Validation | Part 2 (current) |
| Testing | Part 2 (current) |
| Authentication | Coming up |
| Authorization | Coming up |
| Integration with external APIs | Coming up |
| Deployment | Coming up |
What you will learn today
In the first article, you designed a data model for the problem domain and wrote a seed script that uses Prisma Client to save data to the database.
In this second article, you will build a REST API on top of that data model and Prisma schema. You will use Hono to build the REST API. With the REST API, you'll be able to perform database operations via HTTP requests.
As part of the REST API, you will develop the following aspects:
- REST API: Implement an HTTP server with resource endpoints to handle CRUD for the different models. You will make Prisma Client available to the endpoint handlers through Hono middleware.
- Validation: Add payload validation rules with Zod to ensure that user input matches the expected types of the Prisma schema.
- Testing: Write tests for the REST endpoints with Vitest and Hono's
app.requesthelper, which simulates HTTP requests to verify the validation and persistence logic of the endpoints.
By the end of this article you will have a REST API with endpoints for CRUD (Create, Read, Update, and Delete) operations and tests. The REST resources map HTTP requests to the models in the Prisma schema, for example a GET /users endpoint handles operations associated with the User model.
Note: Throughout the guide you'll find various checkpoints that enable you to validate whether you performed the steps correctly.
Prerequisites
Assumed knowledge
This series assumes basic knowledge of TypeScript, Node.js, and relational databases. Familiarity with REST concepts is useful. No prior knowledge of Prisma is required.
This article continues directly from part 1. You should already have the grading-app project from part 1, with the Prisma schema, a migrated database, and the generated Prisma Client. If you haven't completed part 1, do that first, as this article builds on its data model and project setup.
Development environment
You should have the following installed:
- Node.js 20 or later
If you're using Visual Studio Code, the Prisma extension is recommended for syntax highlighting, formatting, and autocomplete in your schema.
You will use the Prisma Postgres database you created in part 1, so you don't need a local database server. If you set up a local database with Docker in part 1 instead, that works identically here.
Building a REST API
Before diving into the implementation, here are some basic concepts relevant in the context of REST APIs:
- API: Application programming interface. A set of rules that allow programs to talk to each other. Typically the developer creates the API on the server and allows clients to talk to it.
- REST: A set of conventions that developers follow to expose state-related operations (in this case state stored in the database) over HTTP requests. As an example, check out the GitHub REST API.
- Endpoint: Entry point to the REST API which has the following properties (non-exhaustive):
- Path, for example
/users/, which is used to access the users endpoint. The path determines the URL used to access the endpoint, for examplewww.myapi.com/users/. - HTTP method, for example
GET,POST, andDELETE. The HTTP method determines the type of operation an endpoint exposes, for example theGET /usersendpoint allows fetching users and thePOST /usersendpoint allows creating users. - Handler: The code (in this case TypeScript) that handles requests for an endpoint.
- Path, for example
- HTTP status codes: The response HTTP status code informs the API consumer whether the operation was successful and if any errors occurred. Check out this list of the different HTTP status codes, for example
201when a resource was created successfully, and400when consumer input fails validation.
Note: One of the key objectives of the REST approach is using HTTP as an application protocol to avoid reinventing the wheel by sticking to conventions.
The API endpoints
The API will have the following endpoints (HTTP method followed by path):
| Resource | HTTP Method | Route | Description |
|---|---|---|---|
User | POST | /users | Create a user (and optionally associate with courses) |
User | GET | /users/{userId} | Get a user |
User | PUT | /users/{userId} | Update a user |
User | DELETE | /users/{userId} | Delete a user |
User | GET | /users | Get users |
CourseEnrollment | GET | /users/{userId}/courses | Get a user's course enrollments |
CourseEnrollment | POST | /users/{userId}/courses | Enroll a user in a course (as student or teacher) |
CourseEnrollment | DELETE | /users/{userId}/courses/{courseId} | Delete a user's enrollment in a course |
Course | POST | /courses | Create a course |
Course | GET | /courses | Get courses |
Course | GET | /courses/{courseId} | Get a course |
Course | PUT | /courses/{courseId} | Update a course |
Course | DELETE | /courses/{courseId} | Delete a course |
Test | POST | /courses/{courseId}/tests | Create a test for a course |
Test | GET | /courses/tests/{testId} | Get a test |
Test | PUT | /courses/tests/{testId} | Update a test |
Test | DELETE | /courses/tests/{testId} | Delete a test |
Test Result | GET | /users/{userId}/test-results | Get a user's test results |
Test Result | POST | /courses/tests/{testId}/test-results | Create a test result for a test associated with a user |
Test Result | GET | /courses/tests/{testId}/test-results | Get multiple test results for a test |
Test Result | PUT | /courses/tests/test-results/{testResultId} | Update a test result (associated with a user and a test) |
Test Result | DELETE | /courses/tests/test-results/{testResultId} | Delete a test result |
Note: The paths containing a parameter enclosed in
{}, for example{userId}, represent a variable that is interpolated in the URL. Inwww.myapi.com/users/13theuserIdis13.
The endpoints above have been grouped based on the main model/resource they're associated with. The categorization helps with organizing the code into separate modules for maintainability.
In this article, you will implement a subset of these endpoints (the CRUD operations for User) to illustrate the different patterns. You can implement the rest by following the same principles.
Note: Throughout the article, the words endpoint and route are used interchangeably. While they refer to the same thing, endpoint is the term used in the context of REST, while route is the term used in the context of HTTP servers.
Hono
The API will be built with Hono, a small, fast web framework that runs on any JavaScript runtime (Node.js, Bun, Deno, Cloudflare Workers, and more). It has a clean routing API, first-class TypeScript support, built-in middleware, and a testing helper that lets you send requests to your app without starting a server.
You will use the following packages alongside Hono:
@hono/node-serverto run the Hono app on Node.jszodfor declarative, type-safe input validation@hono/zod-validatorto plug Zod schemas into Hono routes as validation middlewarevitestas the test runner
Install them in your grading-app project:
npm install hono @hono/node-server zod @hono/zod-validator dotenv
npm install vitest --save-devMaking Prisma Client available with middleware
Before defining routes, you need a way to access Prisma Client inside your route handlers. Hono has the concept of middleware: functions that run before your handlers and can read from and write to the request context.
You will write a small middleware that attaches a single Prisma Client instance to the context so every handler can reach it through c.get("prisma"). Reusing one instance avoids opening a new database connection on every request.
Create a new file src/lib/prisma.ts:
import "dotenv/config";
import type { Context, Next } from "hono";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../../generated/prisma/client";
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is not set");
}
const adapter = new PrismaPg({ connectionString: databaseUrl });
export const prisma = new PrismaClient({ adapter });
export type AppEnv = {
Variables: {
prisma: PrismaClient;
};
};
export function withPrisma(c: Context<AppEnv>, next: Next) {
if (!c.get("prisma")) {
c.set("prisma", prisma);
}
return next();
}A few things to note:
- Prisma Client is instantiated once with the
@prisma/adapter-pgdriver adapter, exactly as in the seed script from part 1. AppEnvis a Hono environment type. By declaringVariables.prisma, TypeScript knows thatc.get("prisma")returns aPrismaClient, giving you autocomplete and type safety in every handler.withPrismasets the client on the context if it isn't already there, then callsnext()to continue to the route handler.
Creating the server
Next, create the Hono app and register the middleware. Splitting the app definition from the code that starts the server is deliberate: it lets your tests import the fully configured app and send requests to it without ever binding to a port.
Create src/app.ts:
import { Hono } from "hono";
import { withPrisma, type AppEnv } from "./lib/prisma";
import status from "./routes/status";
import users from "./routes/users";
const app = new Hono<AppEnv>();
app.use("*", withPrisma);
app.route("/", status);
app.route("/", users);
export default app;app.use("*", withPrisma) applies the Prisma middleware to every route. app.route() mounts route modules (which you'll create next) onto the app, keeping each resource's routes in its own file.
Then create the runnable entry point, src/index.ts, which starts the server with @hono/node-server:
import { serve } from "@hono/node-server";
import app from "./app";
serve(
{
fetch: app.fetch,
port: 3000,
},
(info) => {
console.log(`Server is running on http://localhost:${info.port}`);
},
);Finally, add dev, start, and test scripts to your package.json:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"test": "vitest run"
}
}Defining the status route
Before defining routes related to business logic, it's good practice to add a status endpoint that returns a 200 HTTP status code. This is useful to check that the server is running correctly.
Create the src/routes directory and add a status.ts file:
import { Hono } from "hono";
import type { AppEnv } from "../lib/prisma";
const status = new Hono<AppEnv>();
status.get("/", (c) => {
return c.json({ up: true }, 200);
});
export default status;Here you create a Hono instance for the status routes, define a GET / handler that returns the object { up: true } with a 200 status code, and export it so app.ts can mount it.
Start the server to try it out:
npm run devCheckpoint: If you open http://localhost in your browser, you should see: {"up":true}
Testing the status route
To test the status endpoint, you'll use Vitest as the test runner together with Hono's app.request helper, which sends a request straight to your app and returns a standard Response. Because app.request doesn't start a server, tests run fast and don't need a free port.
Create a tests directory and add status.test.ts:
import { describe, test, expect } from "vitest";
import app from "../src/app";
describe("Status route", () => {
test("GET / returns 200 and { up: true }", async () => {
const res = await app.request("/");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ up: true });
});
});The test imports the configured app, sends a GET / request with app.request, and asserts on the status code and the JSON body.
Checkpoint: Run the tests with npm test. You should see output similar to:
✓ tests/status.test.ts > Status route > GET / returns 200 and { up: true }
Test Files 1 passed (1)
Tests 1 passed (1)Defining the user routes
You'll now create a route module for the User resource. Because the Prisma middleware is registered on the app, every handler in this module can access Prisma Client through c.get("prisma").
Create src/routes/users.ts with the following starting point:
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import type { AppEnv } from "../lib/prisma";
const users = new Hono<AppEnv>();
export default users;The Hono<AppEnv> type parameter carries the prisma context variable through to every handler you add. In the next steps you'll fill in the CRUD routes.
Defining the create user route with validation
The create user route uses the HTTP method POST and the path /users.
User input arrives at runtime, so it can't be checked by the TypeScript compiler. This is what validation is for: it's a runtime check that the incoming payload has the right shape before you touch the database. You'll define the expected shape once as a Zod schema and reuse it both for validation and for deriving the TypeScript type.
Add the schemas to src/routes/users.ts:
const userSchema = z.object({
firstName: z.string(),
lastName: z.string(),
email: z.email(),
social: z
.object({
bluesky: z.string().optional(),
linkedin: z.string().optional(),
website: z.string().optional(),
})
.optional(),
});Now define the create route. zValidator("json", userSchema) is middleware from @hono/zod-validator that validates the JSON body against userSchema. If validation fails, it responds with a 400 automatically. If it succeeds, the parsed and typed payload is available through c.req.valid("json"):
users.post("/users", zValidator("json", userSchema), async (c) => {
const prisma = c.get("prisma");
const data = c.req.valid("json");
const createdUser = await prisma.user.create({
data,
select: { id: true },
});
return c.json(createdUser, 201);
});Here you access prisma from the context (set by the middleware), pass the validated payload to prisma.user.create, and return the created user's id with a 201 status code. Because social in the schema matches the Json? field in the Prisma schema, you pass the object straight through, no manual serialization required.
Testing the create user route
Create tests/users.test.ts and add a test that sends a valid payload and asserts that the response contains a numeric id:
import { describe, test, expect, afterAll } from "vitest";
import app from "../src/app";
import { prisma } from "../src/lib/prisma";
describe("User routes", () => {
let userId: number;
afterAll(async () => {
await prisma.$disconnect();
});
test("POST /users creates a user", async () => {
const res = await app.request("/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: "test-first-name",
lastName: "test-last-name",
email: `test-${Date.now()}@prisma.io`,
social: { bluesky: "thisisalice", website: "https://thisisalice.com" },
}),
});
expect(res.status).toBe(201);
const body = await res.json();
userId = body.id;
expect(typeof userId).toBe("number");
});
});The test stores the created userId in a variable scoped to the describe block so later tests can reuse it. Using `test-${Date.now()}@prisma.io` keeps the email unique on every run, avoiding unique constraint errors. The afterAll hook disconnects Prisma Client when the suite finishes.
Now add a test for the validation logic by sending an invalid payload that omits the required firstName field:
test("POST /users rejects invalid input with 400", async () => {
const res = await app.request("/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lastName: "test-last-name",
email: `test-${Date.now()}@prisma.io`,
}),
});
expect(res.status).toBe(400);
});Checkpoint: Run npm test and verify that both tests pass.
Defining and testing the get user route
The get user endpoint has the GET /users/{userId} signature.
The practice of first writing the test and then the implementation is often referred to as test-driven development. It can improve productivity by giving you a fast way to verify the correctness of changes while you work on the implementation.
Writing the tests
First, test that the route returns 404 when a user is not found. Add these tests inside the describe block in users.test.ts:
test("GET /users/:userId returns 404 for a non-existent user", async () => {
const res = await app.request("/users/999999");
expect(res.status).toBe(404);
});
test("GET /users/:userId returns the user", async () => {
const res = await app.request(`/users/${userId}`);
expect(res.status).toBe(200);
const user = await res.json();
expect(user.id).toBe(userId);
});The second test uses the userId from the create test, so it fetches an existing user.
Defining the route
To validate URL parameters, define a Zod schema for the userId and use zValidator("param", ...). Since parameters arrive as strings, z.coerce.number() converts the value before checking that it's an integer:
const userIdParam = z.object({
userId: z.coerce.number().int(),
});
users.get("/users/:userId", zValidator("param", userIdParam), async (c) => {
const prisma = c.get("prisma");
const { userId } = c.req.valid("param");
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return c.body(null, 404);
}
return c.json(user, 200);
});The handler parses the validated userId, queries the database, returns 404 if no user is found, and otherwise returns the user with a 200.
Note: When calling
findUnique, Prisma Client returnsnullif no record could be found.
Checkpoint: Run npm test and verify that all tests pass.
Defining and testing the delete user route
The delete user endpoint has the DELETE /users/{userId} signature.
Writing the tests
First, write a test for the parameter validation, then one for the delete logic that removes the user created earlier:
test("DELETE /users/:userId fails with 400 for an invalid id", async () => {
const res = await app.request("/users/aa22", { method: "DELETE" });
expect(res.status).toBe(400);
});
test("DELETE /users/:userId deletes the user", async () => {
const res = await app.request(`/users/${userId}`, { method: "DELETE" });
expect(res.status).toBe(204);
});Note: The
204status code indicates that the request succeeded but the response has no content.
Defining the route
Reuse the userIdParam schema for validation and add the route:
users.delete("/users/:userId", zValidator("param", userIdParam), async (c) => {
const prisma = c.get("prisma");
const { userId } = c.req.valid("param");
await prisma.user.delete({ where: { id: userId } });
return c.body(null, 204);
});Checkpoint: Run npm test and verify that all tests pass.
Defining and testing the update user route
The update user endpoint has the PUT /users/{userId} signature.
Because the delete test above removes the user, add the update tests before the delete tests in the file so the user still exists when the update runs.
Writing the tests
test("PUT /users/:userId fails with 400 for an invalid id", async () => {
const res = await app.request("/users/aa22", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ firstName: "x" }),
});
expect(res.status).toBe(400);
});
test("PUT /users/:userId updates the user", async () => {
const res = await app.request(`/users/${userId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: "test-first-name-UPDATED",
lastName: "test-last-name-UPDATED",
}),
});
expect(res.status).toBe(200);
const user = await res.json();
expect(user.firstName).toBe("test-first-name-UPDATED");
expect(user.lastName).toBe("test-last-name-UPDATED");
});Deriving the update validation from the create schema
For the update route, every field should be optional so you can update a single field, for example just firstName. Rather than write a second schema by hand, derive it from userSchema with Zod's .partial() method, which makes every field optional. This keeps the create and update rules in sync and your code DRY:
const updateUserSchema = userSchema.partial();Defining the route
The update route validates both the userId parameter and the JSON body, then passes the validated payload to prisma.user.update:
users.put(
"/users/:userId",
zValidator("param", userIdParam),
zValidator("json", updateUserSchema),
async (c) => {
const prisma = c.get("prisma");
const { userId } = c.req.valid("param");
const data = c.req.valid("json");
const updatedUser = await prisma.user.update({
where: { id: userId },
data,
});
return c.json(updatedUser, 200);
},
);Checkpoint: Run npm test. With all four user routes and the status route in place, you should see all tests pass:
✓ tests/status.test.ts > Status route > GET / returns 200 and { up: true }
✓ tests/users.test.ts > User routes > POST /users creates a user
✓ tests/users.test.ts > User routes > POST /users rejects invalid input with 400
✓ tests/users.test.ts > User routes > GET /users/:userId returns 404 for a non-existent user
✓ tests/users.test.ts > User routes > GET /users/:userId returns the user
✓ tests/users.test.ts > User routes > PUT /users/:userId fails with 400 for an invalid id
✓ tests/users.test.ts > User routes > PUT /users/:userId updates the user
✓ tests/users.test.ts > User routes > DELETE /users/:userId fails with 400 for an invalid id
✓ tests/users.test.ts > User routes > DELETE /users/:userId deletes the user
Test Files 2 passed (2)
Tests 9 passed (9)Summary and next steps
The article covered a lot of ground, starting with REST concepts and then going into Hono concepts such as routes, middleware, route modules, testing, and validation.
You made Prisma Client available throughout your application with a middleware, and implemented routes that use it. You validated input at the edge with Zod, deriving the update schema from the create schema so the rules stay in sync, and you tested every endpoint with Vitest and Hono's app.request helper. Throughout, TypeScript kept the handlers in sync with the database schema, so the types you query against are the same types Prisma generates from your models.
The article covered a subset of all the endpoints. As a next step, you could implement the other routes following the same principles.
While Prisma aims to make working with relational databases easy, it helps to have a deeper understanding of the underlying database. Check out Prisma's Data Guide to learn more about how databases work.
In the next parts of this series, you'll learn more about:
- Authentication: implementing passwordless authentication with email and JWT
- Continuous integration: building a GitHub Actions pipeline to automate testing of the backend
- Integration with external APIs: using a transactional email API to send emails
- Authorization: providing different levels of access to different resources
- Deployment
Your database runs on Prisma Postgres, which you can manage in the Prisma Console. If AI agents are part of your development workflow, the Prisma MCP server lets them create and manage databases as they need them. When you reach the deployment part of this series, Prisma Compute runs your backend on the same platform as your database.
This series teaches Prisma ORM 7, the current production release. If you want to see where Prisma ORM is heading for AI-assisted development, read The Next Evolution of Prisma ORM. Prisma Next is the agent-native evolution of the ORM, available in Early Access today, and it becomes Prisma 8 at GA. The type-safe, schema-as-contract approach you used here to keep validation and queries in sync is exactly what Prisma Next builds on: every query returns structured output that humans and coding agents can build on safely, and you can adopt it incrementally alongside an existing project. The performance benchmark covers what the rewrite means for throughput and client size.
Build your next app with Prisma
Start free. Scale when you’re ready.
