Introduction to Apollo Server
Apollo Server is the most popular GraphQL server implementation for Node.js. Version 4 is framework-agnostic, meaning it integrates with Express, Fastify, Koa, and other HTTP frameworks. It provides schema validation, error formatting, plugin support, and built-in performance monitoring.
Project Setup
# Initialize a new project
mkdir graphql-server && cd graphql-server
npm init -y
# Install dependencies
npm install @apollo/server graphql express cors
npm install @graphql-tools/schema
npm install -D typescript @types/node @types/express @types/cors ts-node nodemon
# Initialize TypeScript
npx tsc --init
TypeScript Configuration
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Basic Apollo Server with Express
// src/index.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers.js';
import { createContext, Context } from './context.js';
async function startServer() {
const app = express();
const httpServer = http.createServer(app);
const server = new ApolloServer<Context>({
typeDefs,
resolvers,
plugins: [
// Graceful shutdown
ApolloServerPluginDrainHttpServer({ httpServer }),
],
formatError: (formattedError, error) => {
// Log errors server-side
console.error('GraphQL Error:', error);
// Don't expose internal errors to clients in production
if (process.env.NODE_ENV === 'production') {
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return {
message: 'An unexpected error occurred',
extensions: { code: 'INTERNAL_SERVER_ERROR' },
};
}
}
return formattedError;
},
});
await server.start();
// Health check endpoint (REST)
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// GraphQL endpoint
app.use(
'/graphql',
cors<cors.CorsRequest>({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true,
}),
express.json({ limit: '50mb' }),
expressMiddleware(server, {
context: async ({ req }) => createContext({ req }),
}),
);
const PORT = process.env.PORT || 4000;
httpServer.listen(PORT, () => {
console.log(`GraphQL server ready at http://localhost:${PORT}/graphql`);
});
}
startServer().catch(console.error);
Schema Definition
// src/schema.ts
export const typeDefs = `#graphql
type Query {
users(limit: Int = 20, offset: Int = 0): [User!]!
user(id: ID!): User
posts(filter: PostFilterInput): [Post!]!
post(id: ID!): Post
me: User
}
type Mutation {
signUp(input: SignUpInput!): AuthPayload!
signIn(email: String!, password: String!): AuthPayload!
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
type User {
id: ID!
name: String!
email: String!
bio: String
posts: [Post!]!
postsCount: Int!
createdAt: String!
}
type Post {
id: ID!
title: String!
body: String!
slug: String!
isPublished: Boolean!
author: User!
tags: [String!]!
createdAt: String!
updatedAt: String!
}
type AuthPayload {
token: String!
user: User!
}
input SignUpInput {
name: String!
email: String!
password: String!
}
input CreatePostInput {
title: String!
body: String!
tags: [String!]
isPublished: Boolean = false
}
input UpdatePostInput {
title: String
body: String
tags: [String!]
isPublished: Boolean
}
input PostFilterInput {
isPublished: Boolean
authorId: ID
search: String
}
`;
Context Factory
// src/context.ts
import { Request } from 'express';
import { PrismaClient, User } from '@prisma/client';
import jwt from 'jsonwebtoken';
const prisma = new PrismaClient();
export interface Context {
prisma: PrismaClient;
currentUser: User | null;
}
export async function createContext({ req }: { req: Request }): Promise<Context> {
let currentUser: User | null = null;
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
try {
const token = authHeader.slice(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
currentUser = await prisma.user.findUnique({
where: { id: decoded.userId },
});
} catch {
// Invalid token — continue as unauthenticated
}
}
return { prisma, currentUser };
}
Complete Resolver Implementation
// src/resolvers.ts
import { GraphQLError } from 'graphql';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { Context } from './context.js';
function requireAuth(ctx: Context) {
if (!ctx.currentUser) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return ctx.currentUser;
}
export const resolvers = {
Query: {
users: async (_: unknown, args: { limit: number; offset: number }, ctx: Context) => {
return ctx.prisma.user.findMany({
take: args.limit,
skip: args.offset,
orderBy: { createdAt: 'desc' },
});
},
user: async (_: unknown, args: { id: string }, ctx: Context) => {
return ctx.prisma.user.findUnique({ where: { id: args.id } });
},
me: async (_: unknown, __: unknown, ctx: Context) => {
return ctx.currentUser;
},
posts: async (_: unknown, args: { filter?: any }, ctx: Context) => {
const where: any = {};
if (args.filter?.isPublished !== undefined) where.isPublished = args.filter.isPublished;
if (args.filter?.authorId) where.authorId = args.filter.authorId;
if (args.filter?.search) {
where.OR = [
{ title: { contains: args.filter.search, mode: 'insensitive' } },
{ body: { contains: args.filter.search, mode: 'insensitive' } },
];
}
return ctx.prisma.post.findMany({ where, orderBy: { createdAt: 'desc' } });
},
post: async (_: unknown, args: { id: string }, ctx: Context) => {
return ctx.prisma.post.findUnique({ where: { id: args.id } });
},
},
Mutation: {
signUp: async (_: unknown, args: { input: any }, ctx: Context) => {
const existing = await ctx.prisma.user.findUnique({
where: { email: args.input.email },
});
if (existing) {
throw new GraphQLError('Email already registered', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
const hashedPassword = await bcrypt.hash(args.input.password, 12);
const user = await ctx.prisma.user.create({
data: {
name: args.input.name,
email: args.input.email,
password: hashedPassword,
},
});
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, {
expiresIn: '7d',
});
return { token, user };
},
signIn: async (_: unknown, args: { email: string; password: string }, ctx: Context) => {
const user = await ctx.prisma.user.findUnique({
where: { email: args.email },
});
if (!user || !(await bcrypt.compare(args.password, user.password))) {
throw new GraphQLError('Invalid email or password', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, {
expiresIn: '7d',
});
return { token, user };
},
createPost: async (_: unknown, args: { input: any }, ctx: Context) => {
const user = requireAuth(ctx);
const slug = args.input.title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
return ctx.prisma.post.create({
data: {
title: args.input.title,
body: args.input.body,
slug,
tags: args.input.tags || [],
isPublished: args.input.isPublished,
authorId: user.id,
},
});
},
updatePost: async (_: unknown, args: { id: string; input: any }, ctx: Context) => {
const user = requireAuth(ctx);
const post = await ctx.prisma.post.findUnique({ where: { id: args.id } });
if (!post || post.authorId !== user.id) {
throw new GraphQLError('Not found or unauthorized', {
extensions: { code: 'FORBIDDEN' },
});
}
return ctx.prisma.post.update({
where: { id: args.id },
data: args.input,
});
},
deletePost: async (_: unknown, args: { id: string }, ctx: Context) => {
const user = requireAuth(ctx);
const post = await ctx.prisma.post.findUnique({ where: { id: args.id } });
if (!post || post.authorId !== user.id) {
throw new GraphQLError('Not found or unauthorized', {
extensions: { code: 'FORBIDDEN' },
});
}
await ctx.prisma.post.delete({ where: { id: args.id } });
return true;
},
},
User: {
posts: (parent: any, _: unknown, ctx: Context) =>
ctx.prisma.post.findMany({ where: { authorId: parent.id } }),
postsCount: (parent: any, _: unknown, ctx: Context) =>
ctx.prisma.post.count({ where: { authorId: parent.id } }),
},
Post: {
author: (parent: any, _: unknown, ctx: Context) =>
ctx.prisma.user.findUnique({ where: { id: parent.authorId } }),
},
};
Production Checklist
- Error formatting: Never expose stack traces or internal details in production
- CORS: Restrict allowed origins to your actual frontend domains
- Rate limiting: Add rate limiting middleware to prevent abuse
- Query depth limiting: Prevent deeply nested queries that could overload the server
- Persisted queries: In production, consider only allowing pre-registered queries
- Monitoring: Use Apollo Studio or custom plugins for observability