Back to blog

GraphQL in 2025: Complete Guide with Apollo Server For Optimized APIs

Hello HaWkers, GraphQL continues to gain adoption in 2025 as a powerful alternative to traditional REST. If you have not yet explored this technology or want to deepen your knowledge, this is the ideal time.

Have you ever been frustrated with REST APIs that return too much or too little data? GraphQL solves exactly this problem, allowing the client to request only the data it needs.

Why GraphQL in 2025

GraphQL did not come to completely replace REST, but it offers significant advantages in specific scenarios.

GraphQL

GraphQL Advantages

For the Client:

  • Request exactly the necessary data
  • Single request for related data
  • Strong typing and automatic documentation
  • Schema introspection

For the Server:

  • Schema as API contract
  • Automatic query validation
  • API evolution without versioning
  • Better usage observability

When to Use GraphQL

Use GraphQL when:

  • Different clients need different data
  • Multiple related resources are needed
  • Network performance is critical (mobile)
  • Public API with many consumers

Prefer REST when:

  • Simple CRUD operations
  • HTTP caching is a priority
  • Team has no GraphQL experience
  • Integration with legacy systems

Setting Up Apollo Server

Let us create a complete GraphQL API from scratch with Apollo Server 4 and TypeScript.

Initial Setup

// 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';
import { resolvers } from './resolvers';
import { createContext, Context } from './context';

async function startServer() {
  const app = express();
  const httpServer = http.createServer(app);

  const server = new ApolloServer<Context>({
    typeDefs,
    resolvers,
    plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
    introspection: process.env.NODE_ENV !== 'production'
  });

  await server.start();

  app.use(
    '/graphql',
    cors<cors.CorsRequest>(),
    express.json(),
    expressMiddleware(server, {
      context: createContext
    })
  );

  const PORT = process.env.PORT || 4000;

  await new Promise<void>((resolve) =>
    httpServer.listen({ port: PORT }, resolve)
  );

  console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`);
}

startServer().catch(console.error);

Defining the Schema

// src/schema.ts
export const typeDefs = `#graphql
  # Basic types
  type User {
    id: ID!
    name: String!
    email: String!
    avatar: String
    posts: [Post!]!
    comments: [Comment!]!
    createdAt: String!
    updatedAt: String!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    published: Boolean!
    author: User!
    categories: [Category!]!
    comments: [Comment!]!
    views: Int!
    createdAt: String!
    updatedAt: String!
  }

  type Category {
    id: ID!
    name: String!
    slug: String!
    posts: [Post!]!
  }

  type Comment {
    id: ID!
    content: String!
    author: User!
    post: Post!
    createdAt: String!
  }

  # Inputs for mutations
  input CreateUserInput {
    name: String!
    email: String!
    password: String!
    avatar: String
  }

  input UpdateUserInput {
    name: String
    avatar: String
  }

  input CreatePostInput {
    title: String!
    content: String!
    published: Boolean = false
    categoryIds: [ID!]
  }

  input UpdatePostInput {
    title: String
    content: String
    published: Boolean
    categoryIds: [ID!]
  }

  input PostFilter {
    published: Boolean
    authorId: ID
    categoryId: ID
    search: String
  }

  # Pagination
  type PaginationInfo {
    total: Int!
    page: Int!
    perPage: Int!
    totalPages: Int!
    hasNext: Boolean!
    hasPrevious: Boolean!
  }

  type PaginatedPosts {
    data: [Post!]!
    pagination: PaginationInfo!
  }

  # Queries
  type Query {
    # Users
    users: [User!]!
    user(id: ID!): User
    userByEmail(email: String!): User

    # Posts
    posts(
      filter: PostFilter
      page: Int = 1
      perPage: Int = 10
    ): PaginatedPosts!
    post(id: ID!): Post

    # Categories
    categories: [Category!]!
    category(slug: String!): Category
  }

  # Mutations
  type Mutation {
    # Users
    createUser(input: CreateUserInput!): User!
    updateUser(id: ID!, input: UpdateUserInput!): User!
    deleteUser(id: ID!): Boolean!

    # Posts
    createPost(input: CreatePostInput!): Post!
    updatePost(id: ID!, input: UpdatePostInput!): Post!
    deletePost(id: ID!): Boolean!
    publishPost(id: ID!): Post!

    # Comments
    createComment(postId: ID!, content: String!): Comment!
    deleteComment(id: ID!): Boolean!
  }

  # Subscriptions (optional)
  type Subscription {
    newComment(postId: ID!): Comment!
    postUpdated(id: ID!): Post!
  }
`;

Implementing Resolvers

// src/resolvers/index.ts
import { userResolvers } from './user';
import { postResolvers } from './post';
import { categoryResolvers } from './category';
import { commentResolvers } from './comment';

export const resolvers = {
  Query: {
    ...userResolvers.Query,
    ...postResolvers.Query,
    ...categoryResolvers.Query
  },
  Mutation: {
    ...userResolvers.Mutation,
    ...postResolvers.Mutation,
    ...commentResolvers.Mutation
  },
  // Field resolvers
  User: userResolvers.User,
  Post: postResolvers.Post,
  Category: categoryResolvers.Category,
  Comment: commentResolvers.Comment
};

// src/resolvers/post.ts
import { Context } from '../context';
import { GraphQLError } from 'graphql';

interface PostFilter {
  published?: boolean;
  authorId?: string;
  categoryId?: string;
  search?: string;
}

export const postResolvers = {
  Query: {
    posts: async (
      _: unknown,
      args: { filter?: PostFilter; page?: number; perPage?: number },
      { db, user }: Context
    ) => {
      const { filter = {}, page = 1, perPage = 10 } = args;
      const offset = (page - 1) * perPage;

      // Build dynamic query
      let where: any = {};

      if (filter.published !== undefined) {
        where.published = filter.published;
      }

      if (filter.authorId) {
        where.authorId = filter.authorId;
      }

      if (filter.search) {
        where.OR = [
          { title: { contains: filter.search, mode: 'insensitive' } },
          { content: { contains: filter.search, mode: 'insensitive' } }
        ];
      }

      const [posts, total] = await Promise.all([
        db.post.findMany({
          where,
          skip: offset,
          take: perPage,
          orderBy: { createdAt: 'desc' }
        }),
        db.post.count({ where })
      ]);

      const totalPages = Math.ceil(total / perPage);

      return {
        data: posts,
        pagination: {
          total,
          page,
          perPage,
          totalPages,
          hasNext: page < totalPages,
          hasPrevious: page > 1
        }
      };
    },

    post: async (_: unknown, { id }: { id: string }, { db }: Context) => {
      const post = await db.post.findUnique({ where: { id } });

      if (!post) {
        throw new GraphQLError('Post not found', {
          extensions: { code: 'NOT_FOUND' }
        });
      }

      // Increment views
      await db.post.update({
        where: { id },
        data: { views: { increment: 1 } }
      });

      return post;
    }
  },

  Mutation: {
    createPost: async (
      _: unknown,
      { input }: { input: any },
      { db, user }: Context
    ) => {
      if (!user) {
        throw new GraphQLError('Unauthorized', {
          extensions: { code: 'UNAUTHORIZED' }
        });
      }

      const { categoryIds, ...data } = input;

      return db.post.create({
        data: {
          ...data,
          authorId: user.id,
          categories: categoryIds
            ? { connect: categoryIds.map((id: string) => ({ id })) }
            : undefined
        }
      });
    },

    updatePost: async (
      _: unknown,
      { id, input }: { id: string; input: any },
      { db, user }: Context
    ) => {
      if (!user) {
        throw new GraphQLError('Unauthorized', {
          extensions: { code: 'UNAUTHORIZED' }
        });
      }

      const post = await db.post.findUnique({ where: { id } });

      if (!post) {
        throw new GraphQLError('Post not found', {
          extensions: { code: 'NOT_FOUND' }
        });
      }

      if (post.authorId !== user.id) {
        throw new GraphQLError('No permission to edit this post', {
          extensions: { code: 'FORBIDDEN' }
        });
      }

      const { categoryIds, ...data } = input;

      return db.post.update({
        where: { id },
        data: {
          ...data,
          categories: categoryIds
            ? { set: categoryIds.map((catId: string) => ({ id: catId })) }
            : undefined,
          updatedAt: new Date()
        }
      });
    },

    publishPost: async (
      _: unknown,
      { id }: { id: string },
      { db, user }: Context
    ) => {
      if (!user) {
        throw new GraphQLError('Unauthorized', {
          extensions: { code: 'UNAUTHORIZED' }
        });
      }

      return db.post.update({
        where: { id },
        data: { published: true, updatedAt: new Date() }
      });
    }
  },

  // Field resolvers for relationships
  Post: {
    author: (post: any, _: unknown, { loaders }: Context) => {
      return loaders.user.load(post.authorId);
    },

    categories: (post: any, _: unknown, { db }: Context) => {
      return db.category.findMany({
        where: { posts: { some: { id: post.id } } }
      });
    },

    comments: (post: any, _: unknown, { db }: Context) => {
      return db.comment.findMany({
        where: { postId: post.id },
        orderBy: { createdAt: 'desc' }
      });
    }
  }
};

DataLoader: Avoiding N+1

The N+1 problem is one of the biggest performance villains in GraphQL. DataLoader solves this with batching.

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

export function createLoaders(db: PrismaClient) {
  return {
    user: new DataLoader(async (ids: readonly string[]) => {
      const users = await db.user.findMany({
        where: { id: { in: [...ids] } }
      });

      // Map to maintain order
      const userMap = new Map(users.map((u) => [u.id, u]));
      return ids.map((id) => userMap.get(id) || null);
    }),

    post: new DataLoader(async (ids: readonly string[]) => {
      const posts = await db.post.findMany({
        where: { id: { in: [...ids] } }
      });

      const postMap = new Map(posts.map((p) => [p.id, p]));
      return ids.map((id) => postMap.get(id) || null);
    }),

    // Loader for posts by author (batch by authorId)
    postsByAuthor: new DataLoader(async (authorIds: readonly string[]) => {
      const posts = await db.post.findMany({
        where: { authorId: { in: [...authorIds] } }
      });

      // Group by authorId
      const postsByAuthor = new Map<string, any[]>();
      posts.forEach((post) => {
        const list = postsByAuthor.get(post.authorId) || [];
        list.push(post);
        postsByAuthor.set(post.authorId, list);
      });

      return authorIds.map((id) => postsByAuthor.get(id) || []);
    }),

    // Loader for comment count
    commentCount: new DataLoader(async (postIds: readonly string[]) => {
      const counts = await db.comment.groupBy({
        by: ['postId'],
        where: { postId: { in: [...postIds] } },
        _count: { id: true }
      });

      const countMap = new Map(
        counts.map((c) => [c.postId, c._count.id])
      );

      return postIds.map((id) => countMap.get(id) || 0);
    })
  };
}

// src/context.ts
import { PrismaClient } from '@prisma/client';
import { createLoaders } from './loaders';
import { verifyToken } from './auth';

const db = new PrismaClient();

export interface Context {
  db: PrismaClient;
  loaders: ReturnType<typeof createLoaders>;
  user: { id: string; email: string } | null;
}

export async function createContext({ req }: { req: any }): Promise<Context> {
  const token = req.headers.authorization?.replace('Bearer ', '');
  const user = token ? await verifyToken(token) : null;

  return {
    db,
    loaders: createLoaders(db), // New loaders per request
    user
  };
}

Queries and Mutations in Practice

Let us see how to use the API from the client side.

Queries

# Fetch paginated posts list
query ListPosts($filter: PostFilter, $page: Int) {
  posts(filter: $filter, page: $page, perPage: 10) {
    data {
      id
      title
      content
      published
      views
      createdAt
      author {
        id
        name
        avatar
      }
      categories {
        id
        name
        slug
      }
    }
    pagination {
      total
      page
      totalPages
      hasNext
      hasPrevious
    }
  }
}

# Fetch post with all details
query GetPost($id: ID!) {
  post(id: $id) {
    id
    title
    content
    published
    views
    createdAt
    updatedAt
    author {
      id
      name
      email
      avatar
    }
    categories {
      id
      name
      slug
    }
    comments {
      id
      content
      createdAt
      author {
        id
        name
        avatar
      }
    }
  }
}

# Fetch user with their posts
query UserProfile($id: ID!) {
  user(id: $id) {
    id
    name
    email
    avatar
    createdAt
    posts {
      id
      title
      published
      views
      createdAt
    }
  }
}

Mutations

# Create new post
mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    content
    published
    createdAt
  }
}

# Update existing post
mutation UpdatePost($id: ID!, $input: UpdatePostInput!) {
  updatePost(id: $id, input: $input) {
    id
    title
    content
    published
    updatedAt
  }
}

# Publish post
mutation PublishPost($id: ID!) {
  publishPost(id: $id) {
    id
    published
    updatedAt
  }
}

# Add comment
mutation AddComment($postId: ID!, $content: String!) {
  createComment(postId: $postId, content: $content) {
    id
    content
    createdAt
    author {
      id
      name
    }
  }
}

Advanced Optimizations

Persisted Queries

For production, persisted queries improve security and performance:

// src/plugins/persistedQueries.ts
import { ApolloServerPlugin } from '@apollo/server';
import { createHash } from 'crypto';

const queryStore = new Map<string, string>();

export const persistedQueriesPlugin: ApolloServerPlugin = {
  async requestDidStart() {
    return {
      async didResolveOperation({ request, document }) {
        if (request.extensions?.persistedQuery) {
          const { sha256Hash } = request.extensions.persistedQuery;

          // Check if query is registered
          if (!queryStore.has(sha256Hash)) {
            if (request.query) {
              // Register new query
              const hash = createHash('sha256')
                .update(request.query)
                .digest('hex');

              if (hash === sha256Hash) {
                queryStore.set(sha256Hash, request.query);
              }
            }
          }
        }
      }
    };
  }
};

Query Complexity

Limiting query complexity prevents abuse:

// src/plugins/queryComplexity.ts
import { GraphQLError } from 'graphql';
import {
  fieldExtensionsEstimator,
  getComplexity,
  simpleEstimator
} from 'graphql-query-complexity';

const MAX_COMPLEXITY = 1000;

export function validateQueryComplexity(schema: any, query: any, variables: any) {
  const complexity = getComplexity({
    schema,
    query,
    variables,
    estimators: [
      fieldExtensionsEstimator(),
      simpleEstimator({ defaultComplexity: 1 })
    ]
  });

  if (complexity > MAX_COMPLEXITY) {
    throw new GraphQLError(
      `Query too complex: ${complexity}. Maximum allowed: ${MAX_COMPLEXITY}`,
      { extensions: { code: 'QUERY_TOO_COMPLEX' } }
    );
  }

  return complexity;
}

Caching with Redis

// src/plugins/caching.ts
import Redis from 'ioredis';
import { createHash } from 'crypto';

const redis = new Redis(process.env.REDIS_URL);

export async function cacheResolver<T>(
  key: string,
  ttl: number,
  resolver: () => Promise<T>
): Promise<T> {
  const cached = await redis.get(key);

  if (cached) {
    return JSON.parse(cached);
  }

  const result = await resolver();

  await redis.setex(key, ttl, JSON.stringify(result));

  return result;
}

// Usage in resolvers
posts: async (_: unknown, args: any, { db }: Context) => {
  const cacheKey = `posts:${createHash('md5').update(JSON.stringify(args)).digest('hex')}`;

  return cacheResolver(cacheKey, 300, async () => {
    return db.post.findMany({ /* ... */ });
  });
}

Error Handling

// src/errors.ts
import { GraphQLError } from 'graphql';

export class ValidationError extends GraphQLError {
  constructor(message: string, field?: string) {
    super(message, {
      extensions: {
        code: 'VALIDATION_ERROR',
        field
      }
    });
  }
}

export class AuthenticationError extends GraphQLError {
  constructor(message = 'Unauthorized') {
    super(message, {
      extensions: { code: 'UNAUTHENTICATED' }
    });
  }
}

export class ForbiddenError extends GraphQLError {
  constructor(message = 'Forbidden') {
    super(message, {
      extensions: { code: 'FORBIDDEN' }
    });
  }
}

export class NotFoundError extends GraphQLError {
  constructor(resource: string) {
    super(`${resource} not found`, {
      extensions: { code: 'NOT_FOUND' }
    });
  }
}

If you want to learn more about modern backend architectures, check out the article Serverless Architecture with JavaScript which complements GraphQL very well.

Let us go! 🦅

Comments (0)

This article has no comments yet 😢. Be the first! 🚀🦅

Add comments