Skip to content
Learni
View all tutorials
GraphQL

How to Implement Advanced GraphQL with Apollo Server and Prisma in 2026

Lire en français

Introduction

In 2026, GraphQL remains the gold standard for flexible, high-performance APIs, outshining REST in complex apps like dashboards or mobile applications. This advanced tutorial walks you through building a complete GraphQL server with Apollo Server 4, TypeScript, Prisma for ORM, and DataLoader for batching to avoid N+1 issues. We'll create a blog CRUD (Users/Posts) with JWT authentication, shared context, optimized resolvers, and professional error handling.

Why does it matter? Naive resolvers trigger inefficient cascading queries; DataLoader smartly batches them, like a delivery service consolidating packages. Result: 10x scalability. This guide is 100% actionable—copy-paste the code and run in 10 minutes. Ideal for seniors mastering production-ready patterns.

Prerequisites

  • Node.js 20+ and npm/yarn
  • Advanced knowledge of TypeScript and GraphQL (schema, resolvers)
  • Basics of Prisma and relational databases
  • TS-aware editor (VS Code recommended)
  • Tools: GraphQL Playground or Altair for testing

Project Initialization

terminal
mkdir graphql-advanced && cd graphql-advanced
npm init -y
npm install @apollo/server graphql @prisma/client prisma express cors dotenv jsonwebtoken
dnpm install -D typescript @types/node @types/express @types/jsonwebtoken tsx
npx tsc --init --target es2022 --module commonjs --lib es2022,dom --outDir dist --rootDir src --strict --esModuleInterop --skipLibCheck
mkdir -p src/{graphql,lib,prisma/migrations}

This script sets up a Node.js project with TypeScript, installs Apollo Server for GraphQL, Prisma for ORM/SQLite, and dependencies for auth (JWT) and HTTP server. tsconfig.json is configured for strict ES2022, avoiding compatibility pitfalls. Run it for a zero-config setup.

TypeScript and .env Configuration

Create tsconfig.json using the script above and tweak if needed. Add a .env file at the root: DATABASE_URL="file:./dev.db". This sets up Prisma for an embedded SQLite DB, perfect for dev/testing without Docker.

Prisma Schema

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id    Int     @id @default(autoincrement())
  name  String
  email String  @unique
  posts Post[]
}

model Post {
  id      Int    @id @default(autoincrement())
  title   String
  content String
  author  User   @relation(fields: [authorId], references: [id])
  authorId Int
}

This schema defines two related models: User (1:N) Post. The @relation annotation handles foreign keys automatically. SQLite simplifies testing; switch to PostgreSQL in production without changing client code.

Generate and Migrate DB

terminal
npx prisma generate
npx prisma db push

Generates the TypeScript Prisma client and syncs the DB with the schema (like migrate dev). db push is great for prototyping; use migrate dev in production for versioning. Check with npx prisma studio.

Preparing GraphQL Types

We'll use a schema-first approach with typeDefs as template literals for readability, paired with TS resolvers. Create the src/graphql/ folder to organize.

GraphQL Schema Definition

src/graphql/schema.ts
import { gql } from 'graphql-tag';

export const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
    posts: [Post!]!
    post(id: ID!): Post
    postsByUser(id: ID!): [Post!]!
  }

  type Mutation {
    createUser(name: String!, email: String!): User!
    createPost(title: String!, content: String!, authorId: ID!): Post!
  }

  type Subscription {
    postAdded: Post!
  }

  schema {
    query: Query
    mutation: Mutation
    subscription: Subscription
  }
`;

This schema exposes CRUD queries, mutations, and a basic subscription. Non-null types (!) enforce validation. postsByUser will test DataLoader for N+1. Subscriptions ready for WebSocket.

DataLoaders for Batching

src/lib/dataloaders.ts
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export interface Context {
  prisma: PrismaClient;
  loaders: {
    postsByUserId: DataLoader<number, any[]>;
  };
}

export const createLoaders = () => ({
  postsByUserId: new DataLoader(async (userIds: number[]) => {
    const posts = await prisma.post.findMany({
      where: { authorId: { in: userIds as number[] } },
      include: { author: true },
    });
    const postMap = new Map();
    posts.forEach((post) => {
      if (!postMap.has(post.authorId)) postMap.set(post.authorId, []);
      (postMap.get(post.authorId) as any[]).push(post);
    });
    return userIds.map((id) => postMap.get(id) || []);
  }),
});

export type LoaderKeys = keyof ReturnType<typeof createLoaders>;

DataLoader batches postsByUser queries into one DB call, preventing N+1 (e.g., 10 users → 10 queries becomes 1). Map() groups by authorId. Injected into Context for resolvers.

Context with JWT Authentication

src/lib/context.ts
import { Request } from 'express';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import { createLoaders, Context, LoaderKeys } from './dataloaders';

const prisma = new PrismaClient();

const SECRET = process.env.JWT_SECRET || 'secret';

export const createContext = ({ req }: { req: Request }): Context => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  let userId = -1;
  if (token) {
    try {
      const decoded = jwt.verify(token, SECRET) as { userId: number };
      userId = decoded.userId;
    } catch {}
  }
  return {
    prisma,
    userId,
    loaders: createLoaders() as any,
  };
};

The context shares Prisma, decoded userId from JWT (via Bearer header), and DataLoaders. Token verification protects mutations. userId available in resolvers for fine-grained auth. Set SECRET in .env.

Implementing Resolvers

Analogy: Resolvers are like SQL view functions—they resolve leaf fields. Here, we optimize posts with DataLoader and protect mutations.

Optimized Resolvers

src/graphql/resolvers.ts
import { IResolvers } from '@graphql-tools/utils';
import { Context } from '../lib/dataloaders';
import { GraphQLError } from 'graphql';

export const resolvers: IResolvers<any, Context> = {
  Query: {
    users: async (_, __, { prisma }) => prisma.user.findMany({ include: { posts: true } }),
    user: async (_, { id }, { prisma }) => {
      const user = await prisma.user.findUnique({ where: { id: Number(id) }, include: { posts: true } });
      if (!user) throw new GraphQLError('User not found');
      return user;
    },
    posts: async (_, __, { prisma }) => prisma.post.findMany({ include: { author: true } }),
    post: async (_, { id }, { prisma }) => {
      const post = await prisma.post.findUnique({ where: { id: Number(id) }, include: { author: true } });
      if (!post) throw new GraphQLError('Post not found');
      return post;
    },
    postsByUser: async (_, { id }, { loaders }) =>
      loaders.postsByUserId.load(Number(id)),
  },
  Mutation: {
    createUser: async (_, { name, email }, { prisma }) =>
      prisma.user.create({ data: { name, email } }),
    createPost: async (_, { title, content, authorId }, { prisma, userId }) => {
      if (userId === -1) throw new GraphQLError('Unauthorized');
      return prisma.post.create({
        data: { title, content, authorId: Number(authorId) },
        include: { author: true },
      });
    },
  },
  User: {
    posts: ({ id }, _, { loaders }) => loaders.postsByUserId.load(id),
  },
};

Query/Mutation resolvers handle CRUD with Prisma includes for joins. postsByUser and User.posts field use DataLoader for batching. Mutations check context userId; custom GraphQLError for pro UX.

Launching the Apollo Server

src/server.ts
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 './graphql/schema.js';
import { resolvers } from './graphql/resolvers.js';
import { createContext } from './lib/context.js';

const app = express();
const server = new ApolloServer({
  typeDefs,
  resolvers,
});

await server.start();

app.use(
  '/',
  cors(),
  express.json(),
  expressMiddleware(server, {
    context: async ({ req }) => createContext({ req }),
  }),
);

const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`🚀 Server ready at ${url}`);

// For Express: app.listen(4000); but standalone for subscriptions

export default app;

ApolloServer wires up typeDefs/resolvers with custom context. Express middleware handles CORS/JSON; startStandaloneServer enables WebSocket subscriptions. Access Playground at http://localhost:4000.

Testing and Running

Add to package.json: "dev": "tsx watch src/server.ts". Run npm run dev. Test queries in Playground: query { users { name posts { title } } }—watch DataLoader in Prisma logs.

Best Practices

  • Always batch with DataLoader for 1:N relations, even in production.
  • Inject Prisma into context (singleton) for connection pooling.
  • Validate inputs with class-validator in mutations.
  • Use persisted queries and Apollo Gateway for federation.
  • Monitor with Apollo Studio for performance tracing.

Common Errors to Avoid

  • N+1 queries: Fix with DataLoader, not Prisma includes everywhere (overfetching).
  • Context without async/await: JWT decode can fail—wrap in try/catch.
  • No rate limiting: Add express-rate-limit for DDoS protection.
  • Subscriptions without WebSocket: StandaloneServer handles them; test with ws://.

Next Steps