This article is published in English.
Building a Type-Safe GraphQL API with Prisma and Nexus in Node.js
Follow a seven-step walkthrough for building a Node.js GraphQL API that unifies Prisma's data model with Nexus-generated types and resolvers.
Explore how to bring Prisma Nexus into a Node.js project to produce type-safe GraphQL APIs, covering schema design, resolver logic, and a running server.
Picture a GraphQL project where the same "User" type ends up defined in four different spots: an SDL document, a hand-written TypeScript interface, a Prisma model, and a Zod validator a teammate bolted on months after launch. Every time one of those definitions changes, at least one of the others falls out of sync. A fix ships, and suddenly the TypeScript types still assume phone is mandatory even though the column vanished from the database weeks earlier.
That kind of drift is exactly what pairing Prisma with Nexus is meant to prevent. Nexus builds your GraphQL schema and your TypeScript types straight from the same data model you already defined in Prisma. There's a single source of truth, and everything downstream is derived from it. Update the definition once, and the types, the schema, and the resolver signatures all move together. It sounds like common sense once you say it out loud — the real lesson comes from working without it and feeling how expensive that gap becomes.
This guide walks through building a Node.js GraphQL API from the ground up with Prisma and Nexus, broken into seven steps with complete code and nothing skipped. By the end you'll have a working server wired to PostgreSQL — something you can run, expand, and reason about with confidence. It's meant to be a foundation sturdy enough for a real production ecommerce backend, not a demo that collapses the moment you add a second model.
What to Have Ready Before Step 1
You'll need:
- Node.js installed — grab the current LTS release from nodejs.org if you don't have it yet.
- The Prisma CLI available globally:
npm install -g prisma
- A running PostgreSQL database you can reach. A local Docker container, Supabase's free tier, Railway — the hosting doesn't matter, as long as you have a connection string on hand.
A note for anyone applying this to an existing codebase rather than a fresh project: during the first migration, Prisma attempts to reconcile schema.prisma with whatever already exists in the database. On a messy legacy schema, that reconciliation step can generate a large, intimidating diff. Read through it carefully before applying it, and always test against a dev environment first. If you're starting from a blank slate, none of this applies to you yet.
Step 1: Get the Project Running
This is the quickest step in the whole process. Create a folder and pull in every dependency in one shot:
mkdir prisma-nexus-graphql
cd prisma-nexus-graphql
# Initialize your project
npm init -y# Install required dependencies
npm install graphql nexus prisma express apollo-server-express path
That single command pulls in all seven packages at once: the GraphQL runtime, Nexus for code-first schema construction, Prisma itself, and the Apollo/Express pairing that will run the server. Installing everything together isn't just a matter of convenience — it lets npm resolve peer dependencies across the entire set in one pass, rather than risking mismatched minor versions if you install packages one at a time.
Step 2: Connect Prisma to Your Database
npx prisma init
Answer the prompts and choose PostgreSQL. Once the command finishes, two new files appear that weren't there before:
prisma/schema.prisma— this is where your data model lives.env— this is where yourDATABASE_URLconnection string goes, and it should go there immediately
That's not an exaggeration. Before you touch the schema, before you run a migration, before opening anything else, drop your connection string into .env. From this point forward, essentially every Prisma command tries to reach the database, and the errors you get when the string is missing or malformed are notoriously unhelpful. Instead of a clear "invalid connection string" message, you'll get a vague complaint about the client not being initialized — and you can easily burn fifteen minutes chasing the wrong cause.
Step 3: Write the Prisma Schema — This Is Not Your GraphQL Schema
If you've worked with GraphQL before but never alongside Prisma, resist treating schema.prisma as the place where you design your API surface. It isn't that. It's a representation of your database structure — tables, columns, relationships, constraints. The actual API shape gets derived from this later, via Nexus. Hold onto that distinction, because it keeps the whole mental model coherent.
// schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}model User {
id Int @id @default(autoincrement())
name String
email String @unique
}
Once your model is written, run the migration:
npx prisma migrate dev
This single command does two things nothing else in the setup does: it creates the actual table in your database, and it regenerates Prisma Client with TypeScript types that exactly match your current schema. Skip it, and Prisma Client simply won't recognize that a User model exists. What you get instead are type errors buried in generated files you don't control, with call stacks that lead nowhere useful — there's no clever shortcut around that. Run the migration every time your schema changes, without exception.
Step 4: Nexus — Why One More File Is Worth It
By this point in the setup, it's fair to ask whether Nexus is really pulling its weight. Nothing stops you from building a GraphQL server without it — hand-write the SDL, define your TypeScript interfaces yourself, and wire everything to resolvers manually. Plenty of codebases do exactly that. The catch is that this approach opens the door to a particular kind of bug: the SDL claims one shape, the TypeScript types describe a slightly different one, and the resolver returns something else entirely. Figuring out which of the three versions is the "real" one often eats more time than building the feature did in the first place.
Nexus sidesteps that problem by treating the SDL as a generated output instead of something you author by hand. You describe your types in TypeScript, and Nexus derives both the SDL and the matching type definitions from that single source. The three pieces that used to drift apart become one artifact that structurally cannot disagree with itself. Here's what schema.ts looks like:
// schema.ts
import { makeSchema } from 'nexus';
import path from 'path';
import * as resolvers from './resolvers';const schema = makeSchema({
types: [resolvers],
outputs: {
schema: path.join(__dirname, './generated/schema.graphql'),
typegen: path.join(__dirname, './generated/nexus.ts'),
},
});export default schema;
The outputs configuration tells Nexus where to place the files it generates: generated/schema.graphql receives the SDL, and generated/nexus.ts receives the corresponding TypeScript definitions. Both are rewritten on every run, so you should never touch them by hand. If you open generated/nexus.ts and notice something that needs fixing, resist the urge to edit it directly — track down the source definition instead and change it there. Modifying a generated file is a bit like patching a compiled binary: it works until the next build silently wipes out your change.
Step 5: Resolvers — Connecting the Schema to the Database
// resolvers.ts
import { extendType, stringArg, nonNull, objectType } from 'nexus';
import { PrismaClient } from '@prisma/client';const prisma = new PrismaClient();export const User = objectType({
name: 'User',
definition(t) {
t.nonNull.id('id')
t.string('name')
t.string('email')
},
})export const Query = extendType({
type: 'Query',
definition(t) {
t.list.field('users', {
type: 'User',
resolve: async () => {
return await prisma.user.findMany();
},
});
},
});export const Mutation = extendType({
type: 'Mutation',
definition(t) {
t.field('createUser', {
type: 'User',
args: {
name: nonNull(stringArg()),
email: nonNull(stringArg()),
},
resolve: async (_, args) => {
return await prisma.user.create({
data: {
name: args.name,
email: args.email,
},
});
},
});
},
});
Notice that PrismaClient is instantiated once, at the top level of the module, outside any function body. That placement matters more than it might seem at first glance. Every call to new PrismaClient() opens a fresh connection to the database. If you were to construct it inside a resolver instead, a new connection would fire on every single request. During normal local development, with maybe one or two requests per second, the database won't even register the difference. But under real concurrent traffic — imagine a few hundred shoppers hitting /checkout at once during a sale — that pattern will exhaust PostgreSQL's connection limit and start throwing errors under load.
Declaring the client at module level means the whole process shares a single connection. Requests aren't racing to open their own database connections; they queue against one shared client, which internally manages its own connection pool. This is the sort of detail that experienced Node.js developers apply reflexively, while less experienced teams tend to discover it the hard way, mid-incident. You now get to skip that lesson.
Step 6: The Server
// server.ts
import express from 'express';
import { ApolloServer } from 'apollo-server-express';
import schema from './schema';const app = express();
const server = new ApolloServer({ schema });const startServer = async () => {
await server.start(); // Start Apollo Server server.applyMiddleware({ app }); // Apply Apollo Server middleware to Express const PORT = process.env.PORT || 4000; app.listen(PORT, () => {
console.log(`Server is running at http://localhost:${PORT}/graphql`);
});
}startServer().catch((err) => {
console.error('Error starting the server:', err);
});
One detail worth flagging before you stumble into it: await server.start() must run before server.applyMiddleware(). That ordering requirement didn't exist in Apollo Server 2 — Apollo 3 introduced an explicit asynchronous startup phase, and any sample code written before late 2021 is likely missing this call entirely. If you skip it, you'll get the error Server must be started before calling server.applyMiddleware, which is at least clear about what went wrong, even if it doesn't explain why the rule exists. Once you understand the reasoning, it's a two-second fix rather than a confusing detour.
Step 7: Start It. Break It. Trust It.
node server.ts
Navigate to http://localhost:4000/graphql. This drops you into GraphQL Playground. Run the mutation first:
// Fetch Users
query {
users {
id
name
email
}
}
// Create Users
mutation {
createUser(name: "John Doe", email: "john@example.com") {
id
name
email
}
}
Run the mutation before the query, so there's actually data to fetch. Watch the record you just inserted come back in the query response. Then do something most walkthroughs skip: open a database client — psql, TablePlus, DBeaver, whatever you have — and inspect the User table directly. Not the JSON the API returned. The raw table itself.
Your row is there, written by a GraphQL mutation you defined in TypeScript using Nexus types, executed through Prisma, and persisted in PostgreSQL. Every link in that chain held. You can point to the exact spot where your application code touches the database. For anyone coming from years of REST endpoints and hand-written SQL, that's usually the moment this stack stops feeling like a diagram and starts feeling like something real.
What You Built and What You Still Need to Add
What you have now is a working backend foundation, not a toy example. The pattern you just followed — define a Prisma model, run a migration, add a Nexus objectType, write the resolver, wire it into the Apollo/Express server — is exactly what you'll repeat for every additional model you introduce. Whether it's Product, Order, or Cart, the steps don't change, and neither do the guarantees. Add a relation inside schema.prisma, run migrate dev, then implement the resolver, and your types update on their own. That automatic synchronization is really the core benefit of this setup — you're no longer relying on memory to keep your schema, types, and resolvers aligned, because the tooling enforces it for you.
What's conspicuously missing so far: authentication, authorization, rate limiting, and input validation. Nexus guarantees that your types are correct. It has nothing to say about who is allowed to call which operation. As things stand, the createUser mutation will happily respond to anyone who can reach port 4000. That's acceptable while you're developing locally. It stops being acceptable the moment the API is reachable from a real URL. Adding auth middleware needs to happen before this goes anywhere near an environment other people can access.
For deeper coverage, check the Prisma documentation on relations, filtering, and pagination, and the Nexus documentation on field-level authorization and custom scalars. Both sets of docs are organized well enough to actually read start to finish, rather than just skimming for a fix whenever something breaks — a trait that's rarer than it should be in technical documentation.