All skills
Skillintermediate

REST to GraphQL Migration Guide

**Migrate to GraphQL when:** - Multiple round-trips required for complex UI views - Over-fetching or under-fetching data is problematic - Supporting diverse client needs (mobile, web, desktop) - Team boundaries require federated API architecture - Real-time subscriptions are core requirements - Type safety across client-server boundary needed - API versioning complexity is growing

Claude Code Knowledge Pack7/10/2026

Overview

REST to GraphQL Migration Guide


When to Use This Guide

Migrate to GraphQL when:

  • Multiple round-trips required for complex UI views
  • Over-fetching or under-fetching data is problematic
  • Supporting diverse client needs (mobile, web, desktop)
  • Team boundaries require federated API architecture
  • Real-time subscriptions are core requirements
  • Type safety across client-server boundary needed
  • API versioning complexity is growing

Success indicators:

  • Client applications make many sequential REST calls
  • Different clients need different data shapes
  • Mobile apps suffer from bandwidth constraints
  • Frontend teams wait on backend API changes
  • Multiple REST versions exist concurrently

When NOT to Use GraphQL

Stick with REST when:

  • Simple CRUD operations with stable clients
  • File upload/download is primary use case
  • HTTP caching is critical (CDN, browser cache)
  • Team lacks GraphQL expertise and training budget
  • Existing REST API is well-designed and sufficient
  • Third-party integrations require REST endpoints
  • Query complexity would create security risks

Warning signs:

  • Team of 1-2 developers (operational overhead)
  • Primarily server-to-server communication
  • Static content delivery is the main requirement
  • No complex data relationship navigation needed

Concept Mapping: REST to GraphQL

REST ConceptGraphQL EquivalentNotes
GET /usersQuery usersRead operations
GET /users/:idQuery user(id: ID!)Single entity fetch
POST /usersMutation createUserCreate operations
PUT /users/:idMutation updateUserUpdate operations
DELETE /users/:idMutation deleteUserDelete operations
PATCH /users/:idMutation updateUserPartialPartial updates
Query params (?filter=...)Field argumentsFiltering/sorting
URL path segmentsNested field selectionData relationships
Multiple endpointsSingle queryEliminate round-trips
Webhook callbacksSubscriptionsReal-time updates
HTTP status codesErrors array + dataPartial success model
API versioningSchema evolutionDeprecation over versions
/users?include=postsusers { posts }Eager loading control
Offset paginationCursor-based connectionsRelay specification
Accept headerOperation selectionContent negotiation
OAuth/JWT tokensContext authenticationSame auth patterns

Pattern 1: GET Endpoints to Queries

REST Endpoint

// GET /api/users/:id
interface UserResponse {
  id: string;
  name: string;
  email: string;
  created_at: string;
  posts: Array<{
    id: string;
    title: string;
    published: boolean;
  }>;
}

app.get('/api/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  const posts = await db.posts.findByUserId(user.id); // N+1 risk

  res.json({
    id: user.id,
    name: user.name,
    email: user.email,
    created_at: user.createdAt.toISOString(),
    posts: posts.map(p => ({
      id: p.id,
      title: p.title,
      published: p.published
    }))
  });
});

GraphQL Schema

type User {
  id: ID!
  name: String!
  email: String!
  createdAt: DateTime!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  published: Boolean!
  author: User!
}

type Query {
  user(id: ID!): User
  users(filter: UserFilter, limit: Int = 20): [User!]!
}

input UserFilter {
  nameContains: String
  createdAfter: DateTime
}

scalar DateTime

GraphQL Resolver with DataLoader


// Batch loading to prevent N+1 queries
const createPostsByUserIdLoader = (db: Database) =>
  new DataLoader<string, Post[]>(async (userIds) => {
    const posts = await db.posts.findByUserIds([...userIds]);

    // Group posts by userId
    const postsByUserId = userIds.map(id =>
      posts.filter(post => post.userId === id)
    );

    return postsByUserId;
  });

const createUserByIdLoader = (db: Database) =>
  new DataLoader<string, User>(async (ids) => {
    const users = await db.users.findByIds([...ids]);

    // Maintain order matching input ids
    return ids.map(id => users.find(user => user.id === id));
  });

interface Context {
  db: Database;
  loaders: {
    userById: DataLoader<string, User>;
    postsByUserId: DataLoader<string, Post[]>;
  };
}

const resolvers: IResolvers<any, Context> = {
  Query: {
    user: async (_, { id }, { loaders }) => {
      return loaders.userById.load(id);
    },

    users: async (_, { filter, limit }, { db }) => {
      return db.users.find(filter, { limit });
    },
  },

  User: {
    posts: async (user, _, { loaders }) => {
      // DataLoader batches and caches these calls
      return loaders.postsByUserId.load(user.id);
    },
  },

  Post: {
    author: async (post, _, { loaders }) => {
      return loaders.userById.load(post.userId);
    },
  },
};

// Apollo Server setup

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => {
    const db = createDatabaseConnection();

    return {
      db,
      loaders: {
        userById: createUserByIdLoader(db),
        postsByUserId: createPostsByUserIdLoader(db),
      },
    };
  },
});

Client Query Examples

// Flexible field selection - client controls response shape
const MINIMAL_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
    }
  }
`;

const DETAILED_USER = gql`
  query GetUserWithPosts($id: ID!) {
    user(id: $id) {
      id
      name
      email
      createdAt
      posts {
        id
        title
        published
      }
    }
  }
`;

// Single query replacing multiple REST calls
const DASHBOARD_DATA = gql`
  query Dashboard($userId: ID!) {
    user(id: $userId) {
      name
      posts {
        id
        title
      }
    }

    # Would require separate REST endpoint
    users(filter: { createdAfter: "2025-01-01" }, limit: 5) {
      id
      name
    }
  }
`;

Pattern 2: POST/PUT/DELETE to Mutations

REST Endpoints

// POST /api/users
app.post('/api/users', async (req, res) => {
  const { name, email, password } = req.body;

  if (!name || !email) {
    return res.status(400).json({ error: 'Missing required fields' });
  }

  const user = await db.users.create({ name, email, password });
  res.status(201).json(user);
});

// PUT /api/users/:id
app.put('/api/users/:id', async (req, res) => {
  const user = await db.users.update(req.params.id, req.body);
  res.json(user);
});

// DELETE /api/users/:id
app.delete('/api/users/:id', async (req, res) => {
  await db.users.delete(req.params.id);
  res.status(204).send();
});

GraphQL Schema

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(input: UpdateUserInput!): UpdateUserPayload!
  deleteUser(id: ID!): DeleteUserPayload!
}

input CreateUserInput {
  name: String!
  email: String!
  password: String!
}

type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

input UpdateUserInput {
  id: ID!
  name: String
  email: String
}

type UpdateUserPayload {
  user: User
  errors: [UserError!]!
}

type DeleteUserPayload {
  deletedId: ID
  errors: [UserError!]!
}

type UserError {
  field: String
  message: String!
  code: ErrorCode!
}

enum ErrorCode {
  VALIDATION_ERROR
  NOT_FOUND
  UNAUTHORIZED
  INTERNAL_ERROR
}

GraphQL Mutation Resolvers

const resolvers: IResolvers<any, Context> = {
  Mutation: {
    createUser: async (_, { input }, { db, user }) => {
      try {
        // Validation
        if (!isValidEmail(input.email)) {
          return {
            user: null,
            errors: [{
              field: 'email',
              message: 'Invalid email format',
              code: 'VALIDATION_ERROR',
            }],
          };
        }

        // Check for duplicate
        const existing = await db.users.findByEmail(input.email);
        if (existing) {
          return {
            user: null,
            errors: [{
              field: 'email',
              message: 'Email already registered',
              code: 'VALIDATION_ERROR',
            }],
          };
        }

        const hashedPassword = await bcrypt.hash(input.password, 10);
        const newUser = await db.users.create({
          name: input.name,
          email: input.email,
          password: hashedPassword,
        });

        return {
          user: newUser,
          errors: [],
        };
      } catch (error) {
        return {
          user: null,
          errors: [{
            message: 'Failed to create user',
            code: 'INTERNAL_ERROR',
          }],
        };
      }
    },

    updateUser: async (_, { input }, { db, user }) => {
      if (!user || user.id !== input.id) {
        return {
          user: null,
          errors: [{
            message: 'Unauthorized',
            code: 'UNAUTHORIZED',
          }],
        };
      }

      const updated = await db.users.update(input.id, {
        ...(input.name && { name: input.name }),
        ...(input.email && { email: input.email }),
      });

      return {
        user: updated,
        errors: [],
      };
    },

    deleteUser: async (_, { id }, { db, user }) => {
      if (!user || user.id !== id) {
        return {
          deletedId: null,
          errors: [{ message: 'Unauthorized', code: 'UNAUTHORIZED' }],
        };
      }

      await db.users.delete(id);

      return {
        deletedId: id,
        errors: [],
      };
    },
  },
};

Client Mutation Examples

const CREATE_USER = gql`
  mutation CreateUser($input: CreateUserInput!) {
    createUser(input: $input) {
      user {
        id
        name
        email
        createdAt
      }
      errors {
        field
        message
        code
      }
    }
  }
`;

// Usage with error handling
const [createUser] = useMutation(CREATE_USER);

const handleSubmit = async (formData) => {
  const { data } = await createUser({
    variables: {
      input: formData,
    },
  });

  if (data.createUser.errors.length > 0) {
    // Handle validation errors
    data.createUser.errors.forEach(error => {
      setFieldError(error.field, error.message);
    });
  } else {
    // Success - use the returned user
    navigate(`/users/${data.createUser.user.id}`);
  }
};

Pattern 3: Pagination Migration

REST Offset Pagination

// GET /api/posts?page=2&limit=20
app.get('/api/posts', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 20;
  const offset = (page - 1) * limit;

  const posts = await db.posts.find({
    limit,
    offset,
  });

  const total = await db.posts.count();

  res.json({
    data: posts,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  });
});

GraphQL Cursor-Based Pagination (Relay Connections)

type Query {
  posts(
    first: Int
    after: String
    last: Int
    before: String
    filter: PostFilter
  ): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

input PostFilter {
  published: Boolean
  authorId: ID
  titleContains: String
}

Cursor Pagination Resolver


const resolvers: IResolvers = {
  Query: {
    posts: async (_, args, { db }) => {
      const { first, after, last, before, filter } = args;

      // Validate pagination args
      if (first && last) {
        throw new Error('Cannot specify both first and last');
      }

      const limit = first || last || 20;
      const isForward = !!first || !last;

      // Decode cursor to get offset
      let offset = 0;
      if (after) {
        offset = decodeCursor(after) + 1;
      } else if (before) {
        offset = Math.max(0, decodeCursor(before) - limit);
      }

      // Fetch one extra to determine hasNextPage
      const posts = await db.posts.find({
        filter,
        limit: limit + 1,
        offset,
        orderBy: { createdAt: isForward ? 'DESC' : 'ASC' },
      });

      const hasMore = posts.length > limit;
      const nodes = hasMore ? posts.slice(0, limit) : posts;

      if (!isForward) {
        nodes.reverse();
      }

      const edges = nodes.map((post, index) => ({
        node: post,
        cursor: encodeCursor(offset + index),
      }));

      const totalCount = await db.posts.count(filter);

      return {
        edges,
        pageInfo: {
          hasNextPage: isForward ? hasMore : offset > 0,
          hasPreviousPage: !isForward ? hasMore : offset > 0,
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor,
        },
        totalCount,
      };
    },
  },
};

// cursor-utils.ts

  return Buffer.from(`cursor:${offset}`).toString('base64');
};

  const decoded = Buffer.from(cursor, 'base64').toString('utf-8');
  return parseInt(decoded.replace('cursor:', ''));
};

Client Pagination Query

const POSTS_QUERY = gql`
  query Posts($first: Int!, $after: String, $filter: PostFilter) {
    posts(first: $first, after: $after, filter: $filter) {
      edges {
        node {
          id
          title
          published
          author {
            name
          }
        }
        cursor
      }
      pageInfo {
        hasNextPage
        endCursor
      }
      totalCount
    }
  }
`;

// Infinite scroll implementation
const PostList = () => {
  const { data, loading, fetchMore } = useQuery(POSTS_QUERY, {
    variables: { first: 20 },
  });

  const loadMore = () => {
    fetchMore({
      variables: {
        after: data.posts.pageInfo.endCursor,
      },
      updateQuery: (prev, { fetchMoreResult }) => {
        if (!fetchMoreResult) return prev;

        return {
          posts: {
            ...fetchMoreResult.posts,
            edges: [
              ...prev.posts.edges,
              ...fetchMoreResult.posts.edges,
            ],
          },
        };
      },
    });
  };

  return (
    <div>
      {data?.posts.edges.map(({ node }) => (
        
      ))}

      {data?.posts.pageInfo.hasNextPage && (
        <button onClick={loadMore}>Load More</button>
      )}
    </div>
  );
};