Skip to content

Building a GraphQL Server From Scratch

A step-by-step tutorial for a GraphQL server in TypeScript: schema design, resolvers, dataloaders against N+1, authentication, errors and deployment.

4 min read
GraphQL playground interface showing a query, response, and schema explorer panel side by side

GraphQL solves the over-fetching and under-fetching problems of REST by letting clients request exactly the data they need. Instead of hitting 3 endpoints to render a user profile page (user, posts, followers), a single GraphQL query returns all three in the shape the client specifies.

This tutorial builds a production-ready GraphQL server from scratch using TypeScript and Apollo Server. By the end, you will have a working API with schema design, resolvers, N+1 query prevention, authentication, and error handling.

Schema Design

The schema defines your API contract — the types, queries, and mutations available to clients. Think of it as a typed interface for your entire API surface.

graphqlgraphql
# schema.graphql
 
type User {
  id: ID!
  email: String!
  name: String!
  role: Role!
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
  createdAt: DateTime!
}
 
type Post {
  id: ID!
  title: String!
  content: String!
  published: Boolean!
  author: User!
  tags: [Tag!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}
 
type Tag {
  id: ID!
  name: String!
  posts: [Post!]!
}
 
enum Role {
  USER
  ADMIN
  EDITOR
}
 
scalar DateTime
 
type Query {
  user(id: ID!): User
  me: User
  posts(
    limit: Int = 20
    offset: Int = 0
    published: Boolean
  ): [Post!]!
  post(id: ID!): Post
}
 
type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
}
 
input CreatePostInput {
  title: String!
  content: String!
  published: Boolean = false
  tagIds: [ID!] = []
}
 
input UpdatePostInput {
  title: String
  content: String
  published: Boolean
}

Implementing Resolvers

Resolvers are functions that fetch the data for each field in your schema. The resolver chain starts at the query root and walks the type graph, calling resolvers for each field the client requests.

tstypescript
import { Resolvers } from "./generated/types";
 
const resolvers: Resolvers = {
  Query: {
    user: async (_parent, { id }, context) => {
      return context.db.user.findUnique({ where: { id } });
    },
 
    me: async (_parent, _args, context) => {
      if (!context.currentUser) return null;
      return context.db.user.findUnique({
        where: { id: context.currentUser.id },
      });
    },
 
    posts: async (_parent, { limit, offset, published }, context) => {
      return context.db.post.findMany({
        where: published !== undefined ? { published } : {},
        take: Math.min(limit ?? 20, 100),
        skip: offset ?? 0,
        orderBy: { createdAt: "desc" },
      });
    },
 
    post: async (_parent, { id }, context) => {
      return context.db.post.findUnique({ where: { id } });
    },
  },
 
  Mutation: {
    createPost: async (_parent, { input }, context) => {
      if (!context.currentUser) {
        throw new AuthenticationError("Must be logged in");
      }
 
      return context.db.post.create({
        data: {
          ...input,
          authorId: context.currentUser.id,
          tags: input.tagIds?.length
            ? { connect: input.tagIds.map((id) => ({ id })) }
            : undefined,
        },
      });
    },
 
    updatePost: async (_parent, { id, input }, context) => {
      const post = await context.db.post.findUnique({ where: { id } });
 
      if (!post) throw new NotFoundError("Post not found");
      if (post.authorId !== context.currentUser?.id) {
        throw new ForbiddenError("Not authorized to edit this post");
      }
 
      return context.db.post.update({ where: { id }, data: input });
    },
 
    deletePost: async (_parent, { id }, context) => {
      await context.db.post.delete({ where: { id } });
      return true;
    },
  },
 
  // Field resolvers for nested types
  User: {
    posts: async (user, { limit, offset }, context) => {
      return context.db.post.findMany({
        where: { authorId: user.id },
        take: limit ?? 10,
        skip: offset ?? 0,
      });
    },
  },
 
  Post: {
    author: async (post, _args, context) => {
      return context.db.user.findUnique({
        where: { id: post.authorId },
      });
    },
    tags: async (post, _args, context) => {
      return context.db.tag.findMany({
        where: { posts: { some: { id: post.id } } },
      });
    },
  },
};

Solving the N+1 Problem with DataLoader

Without DataLoader, querying 20 posts triggers 20 separate queries for their authors — the classic N+1 problem. DataLoader batches these into a single query.

tstypescript
import DataLoader from "dataloader";
 
// ❌ Without DataLoader: N+1 queries
// Query: posts(limit: 20) { author { name } }
// SQL: SELECT * FROM posts LIMIT 20
// SQL: SELECT * FROM users WHERE id = 1  (for post 1)
// SQL: SELECT * FROM users WHERE id = 2  (for post 2)
// ... 20 individual queries for authors
 
// ✅ With DataLoader: 2 queries total
// SQL: SELECT * FROM posts LIMIT 20
// SQL: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
 
interface DataLoaders {
  userLoader: DataLoader<string, User>;
  postTagsLoader: DataLoader<string, Tag[]>;
}
 
function createLoaders(db: PrismaClient): DataLoaders {
  return {
    userLoader: new DataLoader(async (userIds) => {
      const users = await db.user.findMany({
        where: { id: { in: [...userIds] } },
      });
 
      // DataLoader requires results in the same order as keys
      const userMap = new Map(users.map((u) => [u.id, u]));
      return userIds.map((id) => userMap.get(id) ?? null);
    }),
 
    postTagsLoader: new DataLoader(async (postIds) => {
      const posts = await db.post.findMany({
        where: { id: { in: [...postIds] } },
        include: { tags: true },
      });
 
      const tagMap = new Map(posts.map((p) => [p.id, p.tags]));
      return postIds.map((id) => tagMap.get(id) ?? []);
    }),
  };
}
 
// Updated resolver using DataLoader
const resolversWithLoader: Resolvers = {
  Post: {
    author: (post, _args, context) => {
      return context.loaders.userLoader.load(post.authorId);
    },
    tags: (post, _args, context) => {
      return context.loaders.postTagsLoader.load(post.id);
    },
  },
};

Authentication and Context

The context object is created per request and carries authentication state, database connections, and DataLoaders.

tstypescript
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import jwt from "jsonwebtoken";
 
interface Context {
  db: PrismaClient;
  loaders: DataLoaders;
  currentUser: User | null;
}
 
async function createContext(
  req: express.Request
): Promise<Context> {
  const db = prisma;
  const loaders = createLoaders(db);
 
  // Extract user from JWT token
  const token = req.headers.authorization?.replace("Bearer ", "");
  let currentUser: User | null = null;
 
  if (token) {
    try {
      const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
        userId: string;
      };
      currentUser = await db.user.findUnique({
        where: { id: payload.userId },
      });
    } catch {
      // Invalid token — proceed as unauthenticated
    }
  }
 
  return { db, loaders, currentUser };
}
 
const server = new ApolloServer<Context>({
  typeDefs,
  resolvers,
});
 
await server.start();
 
app.use(
  "/graphql",
  expressMiddleware(server, {
    context: async ({ req }) => createContext(req),
  })
);

Error Handling

GraphQL returns errors alongside partial data. Structure your errors so clients can handle them programmatically.

tstypescript
import { GraphQLError } from "graphql";
 
class AuthenticationError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: "UNAUTHENTICATED", http: { status: 401 } },
    });
  }
}
 
class ForbiddenError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: "FORBIDDEN", http: { status: 403 } },
    });
  }
}
 
class NotFoundError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: "NOT_FOUND", http: { status: 404 } },
    });
  }
}
 
// ❌ Generic error — client can't distinguish error types
// throw new Error("Something went wrong");
 
// ✅ Typed error — client checks extensions.code
// throw new AuthenticationError("Must be logged in to create posts");
// Response: { errors: [{ message: "...", extensions: { code: "UNAUTHENTICATED" } }] }

Key Takeaways

  1. Schema-first design defines your API contract — write the schema before the resolvers; the schema is documentation, validation, and type generation in one artifact
  2. DataLoader is mandatory for nested queries — without it, querying a list of posts with their authors causes N+1 database queries; DataLoader batches them into one
  3. Create new DataLoaders per request — DataLoader caches results within a request; reusing across requests serves stale data
  4. Use typed error classes with extension codes — clients need to distinguish authentication errors from not-found errors; extensions.code provides a stable machine-readable field
  5. Limit query depth and complexity — without limits, a client can request deeply nested queries that overload your database; add depth limiting and query cost analysis
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX