---
title: "REST to GraphQL Migration Guide"
description: "**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"
type: skill
canonical_url: https://claudary.paisolsolutions.com/skills/migration-from-rest
source: "Claudary"
difficulty: intermediate
author: "Claude Code Knowledge Pack"
date: 2026-07-10T11:31:00.459Z
license: CC-BY-4.0
attribution: "REST to GraphQL Migration Guide — Claudary (https://claudary.paisolsolutions.com/skills/migration-from-rest)"
---

# 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

## 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 Concept | GraphQL Equivalent | Notes |
|--------------|-------------------|-------|
| GET /users | Query users | Read operations |
| GET /users/:id | Query user(id: ID!) | Single entity fetch |
| POST /users | Mutation createUser | Create operations |
| PUT /users/:id | Mutation updateUser | Update operations |
| DELETE /users/:id | Mutation deleteUser | Delete operations |
| PATCH /users/:id | Mutation updateUserPartial | Partial updates |
| Query params (?filter=...) | Field arguments | Filtering/sorting |
| URL path segments | Nested field selection | Data relationships |
| Multiple endpoints | Single query | Eliminate round-trips |
| Webhook callbacks | Subscriptions | Real-time updates |
| HTTP status codes | Errors array + data | Partial success model |
| API versioning | Schema evolution | Deprecation over versions |
| /users?include=posts | users { posts } | Eager loading control |
| Offset pagination | Cursor-based connections | Relay specification |
| Accept header | Operation selection | Content negotiation |
| OAuth/JWT tokens | Context authentication | Same auth patterns |

---

## Pattern 1: GET Endpoints to Queries

### REST Endpoint

```typescript
// 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

```graphql
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

```typescript
import DataLoader from 'dataloader';
import { IResolvers } from '@graphql-tools/utils';

// 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
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

const server = new ApolloServer<Context>({
  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

```typescript
// 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

```typescript
// 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

```graphql
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

```typescript
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

```typescript
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

```typescript
// 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)

```graphql
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

```typescript
import { encodeCursor, decodeCursor } from './cursor-utils';

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
export const encodeCursor = (offset: number): string => {
  return Buffer.from(`cursor:${offset}`).toString('base64');
};

export const decodeCursor = (cursor: string): number => {
  const decoded = Buffer.from(cursor, 'base64').toString('utf-8');
  return parseInt(decoded.replace('cursor:', ''));
};
```

### Client Pagination Query

```typescript
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 }) => (
        <PostCard key={node.id} post={node} />
      ))}

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

---

---

Source: [Claudary](https://claudary.paisolsolutions.com/skills/migration-from-rest) · https://claudary.paisolsolutions.com
