TechLead
Lesson 8 of 20
7 min read
GraphQL

Apollo Server Setup

Build a production-ready GraphQL server with Apollo Server 4, Express, and TypeScript from scratch

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

Continue Learning