GraphQL Complete Guide 2026: Build APIs with Apollo, Pothos, and React Query

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

GraphQL in 2026: Still Relevant, More Powerful

REST is great for simple CRUD. GraphQL wins when: your frontend needs flexible data fetching, you have multiple clients with different needs, or you're building a public API used by third parties.

Schema-First vs Code-First

Schema-first: Write SDL, then implement resolvers. Good for API design first. Code-first: Write TypeScript classes/functions, generate SDL. Better type safety.

# Code-first with Pothos (recommended in 2026)
npm install @pothos/core graphql graphql-yoga
npm install @pothos/plugin-prisma @pothos/plugin-relay @pothos/plugin-errors

Code-First Schema with Pothos

// src/schema/builder.ts
import SchemaBuilder from '@pothos/core'
import PrismaPlugin from '@pothos/plugin-prisma'
import ErrorsPlugin from '@pothos/plugin-errors'
import type PrismaTypes from '@pothos/plugin-prisma/generated'
import { prisma } from '../lib/db'

export const builder = new SchemaBuilder<{
  PrismaTypes: PrismaTypes
  Context: { userId: string | null }
  Scalars: {
    DateTime: { Input: Date; Output: Date }
  }
}>({
  plugins: [PrismaPlugin, ErrorsPlugin],
  prisma: { client: prisma },
  errors: { defaultTypes: [Error] },
})

builder.queryType({})
builder.mutationType({})
// src/schema/user.ts
import { builder } from './builder'

const User = builder.prismaObject('User', {
  fields: (t) => ({
    id: t.exposeID('id'),
    email: t.exposeString('email'),
    name: t.exposeString('name'),
    createdAt: t.expose('createdAt', { type: 'DateTime' }),
    posts: t.relation('posts'),
  }),
})

builder.queryFields((t) => ({
  user: t.prismaField({
    type: 'User',
    nullable: true,
    args: { id: t.arg.string({ required: true }) },
    resolve: (query, _, { id }) =>
      prisma.user.findUnique({ ...query, where: { id } }),
  }),

  users: t.prismaConnection({
    type: 'User',
    cursor: 'id',
    resolve: (query) => prisma.user.findMany({ ...query }),
  }),
}))

builder.mutationFields((t) => ({
  createUser: t.prismaField({
    type: 'User',
    args: {
      email: t.arg.string({ required: true }),
      name: t.arg.string({ required: true }),
      password: t.arg.string({ required: true }),
    },
    resolve: async (query, _, args) => {
      const hash = await bcrypt.hash(args.password, 12)
      return prisma.user.create({
        ...query,
        data: { email: args.email, name: args.name, passwordHash: hash },
      })
    },
  }),
}))

Apollo Server 4 Setup

// src/server.ts
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { schema } from './schema'
import { verifyToken } from './lib/auth'

const server = new ApolloServer({
  schema,
  introspection: process.env.NODE_ENV !== 'production',
  formatError: (formattedError, error) => {
    // Log internally, send safe message to client
    console.error(error)
    if (process.env.NODE_ENV === 'production') {
      return { message: 'An error occurred', extensions: formattedError.extensions }
    }
    return formattedError
  },
})

const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => {
    const token = req.headers.authorization?.replace('Bearer ', '')
    const user = token ? verifyToken(token) : null
    return { userId: user?.sub ?? null }
  },
  listen: { port: 4000 },
})

console.log(`GraphQL server ready at ${url}`)

The N+1 Problem and DataLoader

// PROBLEM: This makes N+1 queries
const Post = builder.prismaObject('Post', {
  fields: (t) => ({
    id: t.exposeID('id'),
    title: t.exposeString('title'),
    author: t.field({
      type: User,
      // For 100 posts, this makes 100 separate queries!
      resolve: (post) => prisma.user.findUnique({ where: { id: post.authorId } }),
    }),
  }),
})

// SOLUTION: DataLoader batches queries
import DataLoader from 'dataloader'

function createLoaders() {
  return {
    userLoader: new DataLoader<string, User>(async (ids) => {
      const users = await prisma.user.findMany({
        where: { id: { in: ids as string[] } },
      })
      const userMap = new Map(users.map(u => [u.id, u]))
      return ids.map(id => userMap.get(id) ?? new Error(`User ${id} not found`))
    }),
  }
}

// Add to context, use in resolvers
const Post = builder.prismaObject('Post', {
  fields: (t) => ({
    author: t.field({
      type: User,
      resolve: (post, _, ctx) => ctx.loaders.userLoader.load(post.authorId),
      // Now 100 posts = 1 batched query
    }),
  }),
})

Subscriptions (Real-Time)

import { createPubSub } from 'graphql-yoga'

const pubSub = createPubSub<{
  newPost: [userId: string, payload: Post]
  postUpdated: [postId: string, payload: Post]
}>()

builder.subscriptionFields((t) => ({
  newPost: t.field({
    type: Post,
    args: { authorId: t.arg.string() },
    subscribe: (_, { authorId }) => pubSub.subscribe('newPost', authorId!),
    resolve: (payload) => payload,
  }),
}))

// Trigger subscription from mutation
builder.mutationFields((t) => ({
  createPost: t.field({
    type: Post,
    args: {
      title: t.arg.string({ required: true }),
      content: t.arg.string({ required: true }),
    },
    resolve: async (_, args, ctx) => {
      const post = await prisma.post.create({
        data: { ...args, authorId: ctx.userId! },
      })
      pubSub.publish('newPost', ctx.userId!, post)  // Trigger subscription
      return post
    },
  }),
}))

React Frontend with GraphQL + React Query

// Using @tanstack/react-query with graphql-request (lightweight)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { request } from 'graphql-request'

const GRAPHQL_URL = '/api/graphql'

// Type-safe hooks
function useUser(id: string) {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => request<{ user: User }>(GRAPHQL_URL, GET_USER_QUERY, { id }),
    select: (data) => data.user,
  })
}

function useCreateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (input: CreateUserInput) =>
      request<{ createUser: User }>(GRAPHQL_URL, CREATE_USER_MUTATION, { input }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] })
    },
  })
}

// Usage
function UserProfile({ id }: { id: string }) {
  const { data: user, isLoading, error } = useUser(id)
  const createUser = useCreateUser()

  if (isLoading) return <Skeleton />
  if (error) return <ErrorMessage error={error} />

  return <div>{user?.name}</div>
}

Performance: Persisted Queries

// Reduce request payload size by sending query hashes
// Client sends hash, server looks up full query

// Apollo Server persisted queries
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries"
import { sha256 } from 'crypto-hash'

const persistedQueriesLink = createPersistedQueryLink({
  sha256,
  useGETForHashedQueries: true,  // Cache via CDN
})

const client = new ApolloClient({
  link: persistedQueriesLink.concat(httpLink),
  cache: new InMemoryCache(),
})

REST vs GraphQL Decision Guide

ScenarioUse RESTUse GraphQL
Simple CRUD
Multiple client types (web + mobile)
Deeply nested data
Public third-party API
Real-time with WebSockets
File uploads
Team unfamiliar with GQL

GraphQL is a tool, not a religion. Use it when it solves your specific over-fetching or under-fetching problem.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro