← Back to DevBytes

Migrating from REST to GraphQL: Step-by-Step Guide

Understanding the REST to GraphQL Migration

Migrating from REST to GraphQL represents a fundamental shift in how clients communicate with your backend services. REST (Representational State Transfer) has been the dominant API architecture for over a decade, relying on multiple endpoints that return fixed data structures. GraphQL, developed by Facebook in 2012 and open-sourced in 2015, introduces a query language that allows clients to request precisely the data they need from a single endpoint.

This tutorial walks you through a complete, practical migration path—from analyzing your existing REST API to deploying a fully functional GraphQL layer. You'll learn how to approach the migration incrementally, avoid common pitfalls, and ultimately deliver a more flexible, performant API to your consumers.

What Is GraphQL and How It Differs from REST

In REST, you typically hit multiple endpoints to assemble a view. For example, to render a user profile with their recent orders, you might call /users/123 and then /users/123/orders. Each endpoint returns a fixed payload defined by the server. This leads to over-fetching (receiving fields you don't need) or under-fetching (requiring additional requests to get related data).

GraphQL replaces this model with a single endpoint (usually /graphql) that accepts a query string describing exactly which fields and relationships the client wants. The server then resolves that query against a strongly-typed schema, returning only the requested shape. The schema acts as a contract, enabling powerful developer tooling, introspection, and type safety across the stack.

Key conceptual differences:

Why Migrating to GraphQL Matters

Teams adopt GraphQL for concrete reasons that go beyond hype. The most impactful benefits include:

Step-by-Step Migration Guide

We'll follow a proven, incremental approach: build a GraphQL wrapper around your existing REST services first, then gradually replace or refactor the underlying implementations. This avoids a risky "big bang" rewrite and keeps both APIs operational during the transition.

Step 1: Audit Your Existing REST API

Begin by cataloging every REST endpoint, its request parameters, response shape, and authentication requirements. Document which frontend views consume each endpoint and what data they actually use. This audit reveals the "real" data requirements versus the theoretical ones.

Create a simple mapping document. For each endpoint, list:

This audit becomes the foundation for designing your GraphQL schema. The fields that clients actually use should map directly to GraphQL type fields; fields that are never consumed can be left out of the initial schema.

Step 2: Design Your GraphQL Schema

With the audit complete, design your schema using the GraphQL Schema Definition Language (SDL). Start with the core types that mirror your REST resources, then add relationships that replace chained REST calls.

Here's an example schema based on a typical e-commerce REST API:

# schema.graphql

type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  product(id: ID!): Product
  products(category: String, limit: Int): [Product!]!
  order(id: ID!): Order
}

type User {
  id: ID!
  email: String!
  displayName: String
  avatarUrl: String
  createdAt: String
  # Relationship replaces GET /users/:id/orders
  orders(status: String, limit: Int): [Order!]!
  # Relationship replaces GET /users/:id/reviews
  reviews(limit: Int): [Review!]!
}

type Order {
  id: ID!
  status: String!
  total: Float!
  createdAt: String!
  items: [OrderItem!]!
  # Nested relationship
  shippingAddress: Address
}

type OrderItem {
  id: ID!
  quantity: Int!
  unitPrice: Float!
  product: Product!
}

type Product {
  id: ID!
  name: String!
  description: String
  price: Float!
  imageUrl: String
  inStock: Boolean!
  category: String
  reviews(limit: Int): [Review!]!
}

type Review {
  id: ID!
  rating: Int!
  comment: String
  author: User!
  createdAt: String!
}

type Address {
  street: String
  city: String
  state: String
  zipCode: String
  country: String
}

type Mutation {
  createOrder(input: CreateOrderInput!): CreateOrderPayload!
  updateUserProfile(input: UpdateProfileInput!): UpdateProfilePayload!
  addReview(input: AddReviewInput!): AddReviewPayload!
}

input CreateOrderInput {
  userId: ID!
  items: [OrderItemInput!]!
  shippingAddressId: ID
}

input OrderItemInput {
  productId: ID!
  quantity: Int!
}

type CreateOrderPayload {
  order: Order
  errors: [String!]
}

input UpdateProfileInput {
  userId: ID!
  displayName: String
  email: String
}

type UpdateProfilePayload {
  user: User
  errors: [String!]
}

input AddReviewInput {
  productId: ID!
  rating: Int!
  comment: String
}

type AddReviewPayload {
  review: Review
  errors: [String!]
}

Notice how relationships like User.orders and OrderItem.product flatten the nested REST calls into a single queryable graph. The client can now traverse from user to orders to items to product details in one request.

Step 3: Set Up a GraphQL Server as a Wrapper

Rather than rewriting your entire backend, stand up a GraphQL server that acts as a proxy. This server accepts GraphQL queries, calls your existing REST endpoints under the hood, and reshapes the responses to match the GraphQL schema. This approach is often called the "GraphQL gateway" or "wrapper" pattern.

We'll use Node.js with Apollo Server and the node-fetch library (or built-in fetch in Node 18+). Here's a complete, runnable example:

// server.js - GraphQL wrapper over REST endpoints

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import gql from 'graphql-tag';
import fetch from 'node-fetch';

// ---------- Type Definitions ----------
const typeDefs = gql`
  type Query {
    user(id: ID!): User
    users(limit: Int, offset: Int): [User!]!
    product(id: ID!): Product
  }

  type User {
    id: ID!
    email: String!
    displayName: String
    avatarUrl: String
    orders(status: String): [Order!]!
  }

  type Order {
    id: ID!
    status: String!
    total: Float!
    items: [OrderItem!]!
  }

  type OrderItem {
    id: ID!
    quantity: Int!
    unitPrice: Float!
    product: Product!
  }

  type Product {
    id: ID!
    name: String!
    price: Float!
    inStock: Boolean!
  }
`;

// ---------- REST API Base URL ----------
const REST_API_BASE = 'https://api.example.com/v1';

// ---------- Resolvers wrapping REST calls ----------
const resolvers = {
  Query: {
    user: async (_parent, args, _context, _info) => {
      const response = await fetch(`${REST_API_BASE}/users/${args.id}`);
      if (!response.ok) {
        throw new Error(`REST user fetch failed with status ${response.status}`);
      }
      const userData = await response.json();
      return userData;
    },

    users: async (_parent, args, _context, _info) => {
      const params = new URLSearchParams();
      if (args.limit) params.append('limit', args.limit.toString());
      if (args.offset) params.append('offset', args.offset.toString());
      const response = await fetch(`${REST_API_BASE}/users?${params.toString()}`);
      const usersData = await response.json();
      return usersData;
    },

    product: async (_parent, args, _context, _info) => {
      const response = await fetch(`${REST_API_BASE}/products/${args.id}`);
      const productData = await response.json();
      return productData;
    },
  },

  User: {
    orders: async (parent, args, _context, _info) => {
      // parent is the resolved User object; parent.id comes from the REST response
      const params = new URLSearchParams();
      if (args.status) params.append('status', args.status);
      const response = await fetch(
        `${REST_API_BASE}/users/${parent.id}/orders?${params.toString()}`
      );
      const ordersData = await response.json();
      return ordersData;
    },
  },

  Order: {
    items: async (parent, _args, _context, _info) => {
      const response = await fetch(`${REST_API_BASE}/orders/${parent.id}/items`);
      const itemsData = await response.json();
      return itemsData;
    },
  },

  OrderItem: {
    product: async (parent, _args, _context, _info) => {
      // parent is the OrderItem; parent.productId comes from the REST response
      const response = await fetch(`${REST_API_BASE}/products/${parent.productId}`);
      const productData = await response.json();
      return productData;
    },
  },
};

// ---------- Server Initialization ----------
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: true, // Enable in non-production for schema discovery
});

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});

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

This wrapper approach gives you immediate GraphQL capabilities without touching the existing REST codebase. The REST endpoints remain operational, so clients can migrate at their own pace. The resolver functions are thin adapters that translate between GraphQL arguments and REST parameters, then pass the REST JSON response through as the resolved value. GraphQL automatically applies field selection on top, so even though the REST endpoint might return 20 fields, only the requested fields reach the client.

Step 4: Handle Mutations with REST POST/PUT/PATCH Calls

Mutations in GraphQL map naturally to HTTP verbs beyond GET. Your resolver functions for mutations will call the corresponding REST endpoints using POST, PUT, or PATCH methods, passing the GraphQL input arguments as the request body.

Here's how to implement mutations in the wrapper pattern:

// Add to your typeDefs
const mutationTypeDefs = gql`
  type Mutation {
    createOrder(input: CreateOrderInput!): CreateOrderPayload!
    updateUserProfile(input: UpdateProfileInput!): UpdateProfilePayload!
  }

  input CreateOrderInput {
    userId: ID!
    items: [OrderItemInput!]!
    shippingAddressId: ID
  }

  input OrderItemInput {
    productId: ID!
    quantity: Int!
  }

  type CreateOrderPayload {
    order: Order
    errors: [String!]
  }

  input UpdateProfileInput {
    userId: ID!
    displayName: String
    email: String
  }

  type UpdateProfilePayload {
    user: User
    errors: [String!]
  }
`;

// Add to your resolvers map
const mutationResolvers = {
  Mutation: {
    createOrder: async (_parent, args, _context, _info) => {
      try {
        const response = await fetch(`${REST_API_BASE}/orders`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            userId: args.input.userId,
            items: args.input.items,
            shippingAddressId: args.input.shippingAddressId,
          }),
        });

        if (!response.ok) {
          const errorBody = await response.json();
          return {
            order: null,
            errors: errorBody.messages || [`HTTP ${response.status}`],
          };
        }

        const orderData = await response.json();
        return { order: orderData, errors: [] };
      } catch (error) {
        return {
          order: null,
          errors: [error.message],
        };
      }
    },

    updateUserProfile: async (_parent, args, _context, _info) => {
      try {
        const response = await fetch(`${REST_API_BASE}/users/${args.input.userId}`, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            displayName: args.input.displayName,
            email: args.input.email,
          }),
        });

        if (!response.ok) {
          const errorBody = await response.json();
          return {
            user: null,
            errors: errorBody.messages || [`HTTP ${response.status}`],
          };
        }

        const userData = await response.json();
        return { user: userData, errors: [] };
      } catch (error) {
        return {
          user: null,
          errors: [error.message],
        };
      }
    },
  },
};

Notice the payload pattern: mutations return a dedicated payload type that wraps both the success result and potential errors. This allows partial success handling and richer error information than HTTP status codes alone. The REST error responses are translated into the errors array, preserving error details for the client.

Step 5: Add Authentication and Context

Most REST APIs use token-based authentication (Bearer tokens in the Authorization header). In your GraphQL wrapper, you extract the token from the incoming GraphQL request and forward it to the REST calls. Apollo Server's context function is the ideal place for this.

// Enhanced server setup with authentication context

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

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

const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => {
    // Extract the Authorization header from the incoming GraphQL request
    const authHeader = req.headers.authorization || '';
    const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;

    return {
      token,
      restHeaders: {
        'Authorization': authHeader,
        'Content-Type': 'application/json',
        'X-Forwarded-For': req.headers['x-forwarded-for'] || req.socket.remoteAddress,
      },
    };
  },
  listen: { port: 4000 },
});

Then update your resolver functions to use the context:

// Updated resolver using context for authentication
const resolvers = {
  Query: {
    user: async (_parent, args, context, _info) => {
      const response = await fetch(`${REST_API_BASE}/users/${args.id}`, {
        headers: context.restHeaders,
      });
      if (response.status === 401) {
        throw new GraphQLError('Authentication required', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      if (!response.ok) {
        throw new Error(`REST user fetch failed with status ${response.status}`);
      }
      return response.json();
    },
    // ... other query resolvers updated similarly
  },

  User: {
    orders: async (parent, args, context, _info) => {
      const response = await fetch(
        `${REST_API_BASE}/users/${parent.id}/orders?status=${args.status || ''}`,
        { headers: context.restHeaders }
      );
      return response.json();
    },
  },
  // ... remaining resolvers updated similarly
};

This pattern ensures that authentication flows transparently from the GraphQL client through the gateway to the underlying REST services. The same token validates at both layers, and 401 responses are surfaced as proper GraphQL errors with extension codes that clients can handle programmatically.

Step 6: Optimize with DataLoader to Avoid N+1 Queries

A critical problem emerges when you naively wrap REST endpoints: the N+1 query problem. If a GraphQL query requests 20 users and each user's orders, your resolver will make 1 call for the users list plus 20 individual calls for each user's orders—that's 21 REST requests. DataLoader batches and caches these calls, collapsing them into far fewer requests.

Here's how to integrate DataLoader into your wrapper:

// dataloaders.js - Create batched loaders for REST endpoints

import DataLoader from 'dataloader';
import fetch from 'node-fetch';

const REST_API_BASE = 'https://api.example.com/v1';

// Shared headers object — populated from context
function createLoaders(restHeaders) {
  // Batch orders by user ID: single GET with ?userIds=1,2,3,... if your REST API supports it
  // If not, DataLoader still deduplicates identical keys within a single tick
  const ordersByUserLoader = new DataLoader(async (userIds) => {
    // Attempt a bulk fetch if the REST API supports filtering by multiple user IDs
    const params = new URLSearchParams();
    userIds.forEach(id => params.append('userIds', id));
    
    try {
      const response = await fetch(`${REST_API_BASE}/orders?${params.toString()}`, {
        headers: restHeaders,
      });
      const allOrders = await response.json();
      
      // Group orders by userId to match DataLoader's key ordering
      const ordersMap = new Map();
      for (const order of allOrders) {
        if (!ordersMap.has(order.userId)) {
          ordersMap.set(order.userId, []);
        }
        ordersMap.get(order.userId).push(order);
      }
      
      return userIds.map(id => ordersMap.get(id) || []);
    } catch (error) {
      // Fallback: make individual requests if bulk endpoint unavailable
      // DataLoader will still deduplicate concurrent requests for the same userId
      const results = await Promise.all(
        userIds.map(async (id) => {
          const res = await fetch(`${REST_API_BASE}/users/${id}/orders`, {
            headers: restHeaders,
          });
          return res.json();
        })
      );
      return results;
    }
  });

  // Batch products by ID
  const productLoader = new DataLoader(async (productIds) => {
    // If REST supports ?ids=1,2,3 parameter
    const params = new URLSearchParams();
    productIds.forEach(id => params.append('ids', id));
    
    const response = await fetch(`${REST_API_BASE}/products?${params.toString()}`, {
      headers: restHeaders,
    });
    const products = await response.json();
    
    // Maintain key ordering
    const productMap = new Map(products.map(p => [p.id, p]));
    return productIds.map(id => productMap.get(id) || null);
  });

  // Batch order items by order ID
  const itemsByOrderLoader = new DataLoader(async (orderIds) => {
    const params = new URLSearchParams();
    orderIds.forEach(id => params.append('orderIds', id));
    
    const response = await fetch(`${REST_API_BASE}/order-items?${params.toString()}`, {
      headers: restHeaders,
    });
    const allItems = await response.json();
    
    const itemsMap = new Map();
    for (const item of allItems) {
      if (!itemsMap.has(item.orderId)) {
        itemsMap.set(item.orderId, []);
      }
      itemsMap.get(item.orderId).push(item);
    }
    
    return orderIds.map(id => itemsMap.get(id) || []);
  });

  return {
    ordersByUserLoader,
    productLoader,
    itemsByOrderLoader,
  };
}

export { createLoaders };

Now update your server context to create fresh loaders per request (important for cache isolation):

// Updated context with DataLoader instances

const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => {
    const authHeader = req.headers.authorization || '';
    const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
    const restHeaders = {
      'Authorization': authHeader,
      'Content-Type': 'application/json',
    };

    // Create fresh loaders per request to avoid cross-request caching
    const loaders = createLoaders(restHeaders);

    return {
      token,
      restHeaders,
      loaders,
    };
  },
  listen: { port: 4000 },
});

Finally, update your resolvers to use the loaders:

// Resolvers using DataLoader — eliminates N+1 REST calls

const resolvers = {
  Query: {
    user: async (_parent, args, context, _info) => {
      const response = await fetch(`${REST_API_BASE}/users/${args.id}`, {
        headers: context.restHeaders,
      });
      return response.json();
    },

    users: async (_parent, args, context, _info) => {
      const params = new URLSearchParams();
      if (args.limit) params.append('limit', args.limit.toString());
      if (args.offset) params.append('offset', args.offset.toString());
      const response = await fetch(`${REST_API_BASE}/users?${params.toString()}`, {
        headers: context.restHeaders,
      });
      return response.json();
    },
  },

  User: {
    // Uses DataLoader instead of individual fetch per user
    orders: async (parent, args, context, _info) => {
      const allOrders = await context.loaders.ordersByUserLoader.load(parent.id);
      // Optional: filter by status in-memory if REST bulk endpoint doesn't support it
      if (args.status) {
        return allOrders.filter(order => order.status === args.status);
      }
      return allOrders;
    },
  },

  Order: {
    items: async (parent, _args, context, _info) => {
      return context.loaders.itemsByOrderLoader.load(parent.id);
    },
  },

  OrderItem: {
    product: async (parent, _args, context, _info) => {
      return context.loaders.productLoader.load(parent.productId);
    },
  },
};

With DataLoader, requesting 20 users and their orders now makes at most 2 REST calls (one for users, one batched call for orders across all user IDs) instead of 21. This dramatically reduces latency and load on your REST backend.

Step 7: Gradual Backend Replacement

Once the GraphQL wrapper is stable and clients are migrated, you can begin replacing REST endpoints with direct database queries or dedicated GraphQL microservices. The beauty of the wrapper approach is that you can do this one resolver at a time.

For example, to replace the User.orders resolver with a direct database query:

// Before: wrapper resolver calling REST
User: {
  orders: async (parent, args, context, _info) => {
    const response = await fetch(`${REST_API_BASE}/users/${parent.id}/orders`, {
      headers: context.restHeaders,
    });
    return response.json();
  },
}

// After: direct database resolver (no REST dependency)
import { db } from './database.js';

User: {
  orders: async (parent, args, context, _info) => {
    const rows = await db.query(
      'SELECT * FROM orders WHERE user_id = $1 AND status = COALESCE($2, status)',
      [parent.id, args.status || null]
    );
    return rows;
  },
}

The GraphQL schema remains unchanged. Clients see no difference. You can ship this change in a routine deployment, monitor for regressions, and gradually phase out the REST endpoint when its traffic drops to zero.

Step 8: Client-Side Migration

On the client side, introduce GraphQL queries alongside existing REST calls. A common strategy is to create a feature flag that toggles between REST and GraphQL data fetching, allowing A/B testing and gradual rollout.

Here's a simplified React example showing the transition:

// Before: REST-based data fetching
async function fetchUserProfile(userId) {
  const userRes = await fetch(`/api/users/${userId}`);
  const user = await userRes.json();
  
  const ordersRes = await fetch(`/api/users/${userId}/orders`);
  const orders = await ordersRes.json();
  
  // Fetch product details for each order item — N+1 nightmare
  const enrichedOrders = await Promise.all(
    orders.map(async (order) => {
      const itemsRes = await fetch(`/api/orders/${order.id}/items`);
      const items = await itemsRes.json();
      
      const enrichedItems = await Promise.all(
        items.map(async (item) => {
          const productRes = await fetch(`/api/products/${item.productId}`);
          const product = await productRes.json();
          return { ...item, product };
        })
      );
      
      return { ...order, items: enrichedItems };
    })
  );
  
  return { user, orders: enrichedOrders };
}

// After: Single GraphQL query — all relationships resolved in one request
const USER_PROFILE_QUERY = `
  query UserProfile($userId: ID!) {
    user(id: $userId) {
      id
      email
      displayName
      avatarUrl
      orders {
        id
        status
        total
        createdAt
        items {
          id
          quantity
          unitPrice
          product {
            id
            name
            price
            imageUrl
          }
        }
      }
    }
  }
`;

async function fetchUserProfileGraphQL(userId) {
  const response = await fetch('/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      query: USER_PROFILE_QUERY,
      variables: { userId },
    }),
  });
  
  const { data, errors } = await response.json();
  if (errors?.length) {
    throw new Error(errors[0].message);
  }
  return data.user;
}

The GraphQL version replaces multiple round-trips with a single request. The client code becomes simpler—no manual orchestration of dependent calls, no error handling for each intermediate step. The query document serves as self-documenting intent.

Best Practices for REST-to-GraphQL Migration

Common Pitfalls to Avoid

Conclusion

Migrating from REST to GraphQL is a strategic evolution, not a disruptive replacement. By wrapping your existing REST API with a GraphQL gateway, you unlock client-driven data fetching, eliminate wasteful round-trips, and provide a self-documenting, strongly-typed API contract—all without rewriting your backend. The incremental approach outlined here lets you deliver value immediately, migrate clients at their own pace, and gradually replace underlying implementations as you see fit. Start with the audit, design a thoughtful schema, implement the wrapper with DataLoader from day one, and let your REST endpoints fade away naturally as traffic moves to the GraphQL layer. The result is a more flexible, performant, and developer-friendly API that serves both your current needs and your future architecture.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles