Introduction
GraphQL is the gold standard for modern APIs in 2026, outshining REST with superior flexibility and efficiency. This advanced tutorial walks you through building a full-featured API using Apollo Server 4, Prisma 6 as the ORM, JWT authentication, request batching, caching, and WebSocket subscriptions for real-time features. Picture a blog app with users, posts, and comments: clients get precisely the data they need, eliminating over-fetching.
Why does it matter? GraphQL APIs cut production latency by 40-60%, tackle N+1 problems with DataLoader, and scale via federation. We'll create a production-ready TypeScript server that's testable and secure. By the end, you'll deploy it to Vercel or a Kubernetes cluster. Ready to supercharge your backends? (128 words)
Prerequisites
- Node.js 20+ and npm 10+
- Advanced TypeScript and async/await knowledge
- Familiarity with Prisma and relational databases
- Tools: GraphQL Playground or Apollo Studio for testing
Project Initialization
mkdir graphql-advanced-api && cd graphql-advanced-api
npm init -y
npm install @apollo/server @apollo/server-plugin-drain-http-server graphql prisma @prisma/client jsonwebtoken bcryptjs @types/jsonwebtoken @types/bcryptjs @types/node
databases/sqlite
docker run --name prisma-db -e POSTGRES_PASSWORD=prisma -p 5432:5432 -d postgres
npm install -D typescript ts-node @types/express express cors
npx prisma init --datasource-provider sqliteThis script sets up a Node.js project with Apollo Server, Prisma (SQLite for simplicity and portability), JWT for authentication, and TypeScript dependencies. Optional Postgres container; using SQLite here for standalone runs. Skip cloud databases in development for better reproducibility.
Prisma Setup
Prisma generates a type-safe client based on our schema: Users, Posts, Comments with full relations. We define advanced models with indexes and unique constraints for optimal scaling.
Complete Prisma Schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model User {
id String @id @default(uuid())
email String @unique
password String
name String?
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(uuid())
title String
content String
authorId String
author User @relation(fields: [authorId], references: [id])
comments Comment[]
createdAt DateTime @default(now())
@@index([authorId])
}
model Comment {
id String @id @default(uuid())
content String
postId String
post Post @relation(fields: [postId], references: [id])
createdAt DateTime @default(now())
@@index([postId])
}Full relational schema with UUIDs, performance indexes, and timestamps. SQLite enables fast local development; switch to Postgres for production. Run npx prisma migrate dev --name init followed by npx prisma generate.
Advanced GraphQL TypeDefs
import { gql } from 'graphql-tag';
export const typeDefs = gql`
type User {
id: ID!
email: String!
name: String
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
}
type Comment {
id: ID!
content: String!
post: Post!
}
type Query {
me: User
posts: [Post!]!
post(id: ID!): Post
}
type Mutation {
createPost(title: String!, content: String!): Post!
deletePost(id: ID!): Boolean!
addComment(postId: ID!, content: String!): Comment!
}
type Subscription {
postAdded: Post!
}
scalar DateTime
`; Strict type definitions with relations and subscriptions. Resolvers prevent N+1 issues. Include a DateTime scalar to handle Prisma timestamps.
Resolvers with DataLoader and Auth
Resolvers manage core business logic: N+1-proof batching via DataLoader, JWT authentication in context, and bcrypt password hashing.
JWT and Bcrypt Utilities
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
const JWT_SECRET = process.env.JWT_SECRET || 'supersecretdevkey2026';
export const hashPassword = async (password: string): Promise<string> => {
return bcrypt.hash(password, 12);
};
export const verifyPassword = async (password: string, hash: string): Promise<boolean> => {
return bcrypt.compare(password, hash);
};
export const createToken = (payload: { userId: string }): string => {
return jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
};
export const verifyToken = (token: string): { userId: string } | null => {
try {
return jwt.verify(token, JWT_SECRET) as { userId: string };
} catch {
return null;
}
};Secure utilities for JWT (short expiration) and bcrypt (salt rounds 12). Use a JWT_SECRET environment variable in production with dotenv. Pitfall: Never commit secrets to version control.
Context with Auth and DataLoader
import { Request } from 'express';
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';
import { verifyToken } from './utils/auth';
const prisma = new PrismaClient();
export interface Context {
prisma: PrismaClient;
userId: string | null;
loaders: {
users: DataLoader<string, any>;
postsByUser: DataLoader<string, any[]>;
};
}
export const createContext = ({ req }: { req: Request }): Context => {
const token = req.headers.authorization?.replace('Bearer ', '') || '';
const userId = verifyToken(token)?.userId || null;
return {
prisma,
userId,
loaders: {
users: new DataLoader(async (ids) => {/* implement batch */}),
postsByUser: new DataLoader(async (userIds) => {/* batch posts by user */}),
},
};
};Advanced context provides Prisma client, authenticated user ID, and DataLoaders for efficient batching. Flesh out batch functions in resolvers. Tip: Call prisma.$disconnect() on server shutdown to avoid memory leaks.
Complete Resolvers with Batching
import { Resolvers } from './types'; // généré par codegen
import { Context } from './context';
export const resolvers: Resolvers<Context> = {
Query: {
me: async (_, __, { userId, prisma }) => {
if (!userId) throw new Error('Non authentifié');
return prisma.user.findUnique({ where: { id: userId }, include: { posts: true } });
},
posts: async (_, __, { prisma }) => {
return prisma.post.findMany({ include: { author: true, comments: true } });
},
post: async (_, { id }, { prisma }) => {
return prisma.post.findUnique({ where: { id }, include: { author: true, comments: true } });
},
},
Post: {
author: async (parent, _, { loaders }) => {
return loaders.users.load(parent.authorId);
},
},
Mutation: {
createPost: async (_, { title, content }, { userId, prisma }) => {
if (!userId) throw new Error('Auth required');
return prisma.post.create({ data: { title, content, authorId: userId }, include: { author: true } });
},
},
Subscription: {
postAdded: {
subscribe: () => {/* PubSub implementation */},
},
},
};Type-safe resolvers with authentication guards, Prisma relation includes, and DataLoader integration. Add PubSub for subscriptions. N+1 issues eliminated through batched loaders.
Apollo Server with Plugins
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { expressMiddleware } from '@apollo/server/express4';
import express from 'express';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';
const app = express();
const server = new ApolloServer({
typeDefs,
resolvers,
});
await server.start();
app.use('/graphql', cors<cors.CorsRequest>(), express.json(), expressMiddleware(server, { context: createContext }));
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`🚀 Server ready at ${url}`);Hybrid Express/standalone server with context middleware. Built-in CORS and JSON handling. Default caching enabled. Launch via ts-node src/server.ts and test at http://localhost:4000/graphql.
Testing and Subscriptions
Example Query: { posts { title author { name } } }. Mutation: mutation { createPost(title: "Test", content: "Content") { id } } with Authorization: Bearer header. Subscriptions over WS: subscription { postAdded { title } }.
Apollo Client for Testing
const { ApolloClient, InMemoryCache, gql, split, HttpLink, WebSocketLink } = require('@apollo/client');
const { getMainDefinition } = require('@apollo/client/utilities');
const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' });
const wsLink = new WebSocketLink({ uri: 'ws://localhost:4000/graphql', options: { reconnect: true } });
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return definition.kind === 'OperationDefinition' && definition.operation === 'subscription';
},
wsLink,
httpLink
);
const client = new ApolloClient({ link: splitLink, cache: new InMemoryCache() });
client.query({ query: gql`query { posts { title } }` }).then(console.log);Split Apollo Client for HTTP queries/mutations and WS subscriptions. In-memory caching. Install @apollo/client and run to verify setup. Pitfall: WebSocket URI must omit 'http' or 'https'.
Best Practices
- Batching essential: Use DataLoader for all 1:N relations.
- Field-level auth: Implement guards in resolvers, not globally.
- Advanced caching: Apollo's built-in cache plus Redis in production.
- Rate limiting: Apollo Server plugins or Express middleware.
- Schema stitching: Ideal for microservices federation.
- Monitoring: Apollo Studio or Prometheus metrics.
Common Pitfalls to Avoid
- N+1 queries: Without DataLoader, 100 posts trigger 100+ database calls.
- Unverified JWTs: Invalid tokens crash without try/catch.
- Subscriptions without PubSub: Import
PubSubfrom 'graphql-subscriptions'. - Prisma leaks: Always call
prisma.$disconnect()on shutdown. - Missing scalars: Define custom resolvers for
DateTime.
Next Steps
Dive deeper into Apollo Federation for microservices. Scale with Postgres and Kafka. Explore our advanced GraphQL trainings at Learni and the Prisma + Apollo workshop.