Contentful

Contentful GraphQL vs REST API Comparison

A practical comparison of Contentful's GraphQL and REST APIs covering query syntax, performance, caching, and when to use each for Node.js applications.

Contentful GraphQL vs REST API Comparison

Contentful gives you two ways to fetch content: a mature REST API and a GraphQL Content API. I have used both extensively in production Node.js applications, and the choice between them is not as straightforward as the "GraphQL is always better" crowd would have you believe. Each API has genuine strengths, and picking the wrong one for your use case can cost you in performance, complexity, or both.

This article walks through both APIs in practical detail, compares them head-to-head with working code, and gives you a clear framework for deciding which to use.

Understanding Contentful's Two APIs

Contentful's REST Content Delivery API has been around since the platform launched. It follows standard REST conventions with JSON responses, query parameters for filtering, and an includes mechanism for resolving linked entries.

The GraphQL Content API arrived later and auto-generates a GraphQL schema from your content model. Every content type becomes a queryable type, every field becomes a schema field, and linked references become nested objects you can query in a single request.

Both APIs hit the same underlying data. The difference is how you ask for it and what you get back.

GraphQL Schema Auto-Generation

When you define a content type in Contentful — say a blogPost with fields for title, slug, body, author, and category — Contentful automatically generates GraphQL types:

type BlogPost {
  sys: Sys!
  title: String
  slug: String
  body: String
  author: Author
  category: [String]
  linkedFrom: BlogPostLinkingCollections
}

type BlogPostCollection {
  total: Int!
  skip: Int!
  limit: Int!
  items: [BlogPost]!
}

You do not write these types. They exist the moment you publish your content model. This is one of GraphQL's biggest wins with Contentful — your schema is always in sync with your content model without any manual maintenance.

Querying with GraphQL

Basic Query Syntax

A basic GraphQL query against Contentful looks like this:

query {
  blogPostCollection(limit: 10, order: publishDate_DESC) {
    total
    items {
      title
      slug
      publishDate
      synopsis
    }
  }
}

You request exactly the fields you need. No more, no less. Compare this to the REST equivalent, which returns every field on the entry plus system metadata whether you want it or not.

Variables and Fragments

For production code, use variables to parameterize queries and fragments to reuse field selections:

fragment BlogPostFields on BlogPost {
  title
  slug
  publishDate
  synopsis
  category
}

query GetPosts($limit: Int!, $skip: Int!, $category: String) {
  blogPostCollection(
    limit: $limit
    skip: $skip
    where: { category: $category }
    order: publishDate_DESC
  ) {
    total
    items {
      ...BlogPostFields
    }
  }
}

Variables keep your queries clean and prevent string interpolation vulnerabilities. Fragments eliminate duplication when you query the same type in multiple places.

Filtering and Ordering

GraphQL filtering in Contentful uses a where argument with operators appended to field names:

query {
  blogPostCollection(
    where: {
      title_contains: "Node.js"
      publishDate_gte: "2025-01-01"
      category: "tutorials"
    }
    order: publishDate_DESC
  ) {
    items {
      title
      publishDate
    }
  }
}

Available operators include _exists, _ne, _in, _nin, _contains, _not_contains, _lt, _lte, _gt, and _gte. These map closely to the REST API's query parameter operators, so the filtering capabilities are essentially equivalent.

Linked References and Nested Queries

This is where GraphQL genuinely shines. Consider a blog post that references an author entry and multiple tag entries. In GraphQL:

query {
  blogPost(id: "abc123") {
    title
    body
    author {
      name
      bio
      avatar {
        url
        width
        height
      }
    }
    tagsCollection {
      items {
        name
        slug
      }
    }
  }
}

One request. One response. Every linked reference resolved and nested exactly where you expect it. With REST, you either make multiple requests or use the includes parameter and manually resolve references from a flat array — a tedious and error-prone process.

Pagination

Both APIs support skip and limit for pagination:

query GetPage($skip: Int!, $limit: Int!) {
  blogPostCollection(skip: $skip, limit: $limit) {
    total
    skip
    limit
    items {
      title
      slug
    }
  }
}

The total field tells you the total number of matching entries, which you need for calculating page counts. The maximum limit is 1000 for both APIs, but GraphQL's query complexity limits (discussed below) mean you will often hit practical limits well below that when querying nested references.

REST API: Still Relevant

The Sync API

The REST API has a killer feature that GraphQL lacks entirely: the Sync API. It lets you do an initial full sync of your content, then make subsequent requests that return only entries that changed since your last sync:

var contentful = require("contentful");

var client = contentful.createClient({
  space: process.env.CONTENTFUL_SPACE,
  accessToken: process.env.CONTENTFUL_TOKEN
});

function initialSync(callback) {
  client.sync({ initial: true })
    .then(function(response) {
      // response.entries contains all entries
      // response.nextSyncToken for subsequent syncs
      callback(null, {
        entries: response.entries,
        token: response.nextSyncToken
      });
    })
    .catch(function(err) {
      callback(err);
    });
}

function deltaSync(syncToken, callback) {
  client.sync({ nextSyncToken: syncToken })
    .then(function(response) {
      // Only changed/deleted entries since last sync
      callback(null, {
        entries: response.entries,
        deletedEntries: response.deletedEntries,
        token: response.nextSyncToken
      });
    })
    .catch(function(err) {
      callback(err);
    });
}

If you are building a site that caches content locally or needs to stay in sync with Contentful without polling every entry, the Sync API is indispensable. There is no GraphQL equivalent.

Simpler Caching with ETags

REST responses include ETag headers, making conditional requests trivial. You can cache a response, send the ETag on the next request, and get a 304 Not Modified if nothing changed. GraphQL POST requests do not support this pattern natively.

The Includes Parameter

REST's include parameter (values 0-10) controls how many levels of linked entries get resolved in a single response:

client.getEntries({
  content_type: "blogPost",
  include: 2,
  limit: 10
}).then(function(response) {
  // response.includes contains linked entries
  // response.items contains blog posts with resolved links
});

The Contentful JavaScript SDK automatically resolves these links for you, turning the flat includes array into nested objects on your entries. This is convenient but means you always receive all fields on linked entries, not just the ones you need.

Setting Up a GraphQL Client

You do not need a heavy GraphQL client library for Contentful. A simple HTTP request works fine:

var https = require("https");

var SPACE_ID = process.env.CONTENTFUL_SPACE;
var ACCESS_TOKEN = process.env.CONTENTFUL_TOKEN;
var GRAPHQL_URL = "https://graphql.contentful.com/content/v1/spaces/" + SPACE_ID;

function queryContentful(query, variables, callback) {
  var postData = JSON.stringify({
    query: query,
    variables: variables || {}
  });

  var options = {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + ACCESS_TOKEN,
      "Content-Length": Buffer.byteLength(postData)
    }
  };

  var url = new URL(GRAPHQL_URL);
  options.hostname = url.hostname;
  options.path = url.pathname;

  var req = https.request(options, function(res) {
    var body = "";
    res.on("data", function(chunk) { body += chunk; });
    res.on("end", function() {
      var parsed = JSON.parse(body);
      if (parsed.errors) {
        callback(new Error(parsed.errors[0].message));
      } else {
        callback(null, parsed.data);
      }
    });
  });

  req.on("error", function(err) { callback(err); });
  req.write(postData);
  req.end();
}

For a slightly nicer developer experience, graphql-request is a lightweight option:

var { request } = require("graphql-request");

var endpoint = "https://graphql.contentful.com/content/v1/spaces/" + SPACE_ID;
var headers = { Authorization: "Bearer " + ACCESS_TOKEN };

function fetchPosts(limit, callback) {
  var query = '{ blogPostCollection(limit: ' + limit + ') { items { title slug } } }';

  request(endpoint, query, {}, headers)
    .then(function(data) {
      callback(null, data.blogPostCollection.items);
    })
    .catch(function(err) {
      callback(err);
    });
}

Avoid Apollo Client for Contentful unless you are building a React app that benefits from Apollo's cache. For server-side Node.js, it adds unnecessary complexity and bundle size.

Contentful GraphQL Playground

Contentful provides a built-in GraphQL playground at:

https://graphql.contentful.com/content/v1/spaces/{SPACE_ID}/explore?access_token={ACCESS_TOKEN}

Use it to explore your schema, test queries, and verify filter syntax before writing code. The playground supports introspection, so you can browse every type, field, and argument available in your auto-generated schema.

Query Complexity Limits

Contentful imposes a query complexity limit of 11,000 points on GraphQL queries. Every field you request costs points, and nested collections multiply the cost. A query that fetches 100 blog posts with 5 linked tags each could easily exceed the limit.

You can check the complexity cost of a query by including the extensions field in the response or by looking at the x-contentful-graphql-query-cost response header.

Practical rule of thumb: keep your limit values low when querying nested collections. Fetching 100 top-level entries with 3 levels of nested references will fail. Fetch 20 at a time instead.

Rich Text in GraphQL

Rich Text fields in GraphQL return two key properties: json (the document structure) and links (referenced assets and entries embedded in the rich text):

query {
  blogPost(id: "abc123") {
    body {
      json
      links {
        assets {
          block {
            sys { id }
            url
            title
            width
            height
          }
        }
        entries {
          inline {
            sys { id }
            __typename
          }
          block {
            sys { id }
            __typename
            ... on CodeBlock {
              language
              code
            }
          }
        }
      }
    }
  }
}

In REST, Rich Text comes back as a nested JSON structure where embedded entries and assets are referenced by sys.id, and you must resolve them from the includes array. GraphQL's approach is cleaner because the links are co-located with the document.

Performance Comparison

Here is what I have measured across real-world projects:

Response size: GraphQL wins significantly. A typical blog post listing that returns 10 entries with title, slug, and date might be 2KB via GraphQL versus 15KB via REST, because REST includes every field, system metadata, and locale information.

Latency: Roughly equivalent for simple queries. For queries involving multiple linked references, GraphQL is faster because it resolves everything in one request. REST would require either a single request with high include depth (larger payload) or multiple sequential requests.

Rate limiting: Both APIs share the same rate limits (55 requests per second for the CDA). GraphQL's ability to combine what would be multiple REST requests into one query means you hit rate limits less often.

CDN caching: REST has an advantage. GET requests with query parameters are cached by Contentful's CDN. GraphQL uses POST requests, which are not cached by default. Contentful does offer persisted queries to work around this, but it requires additional setup.

Complete Working Example

Here is an Express.js application that fetches the same data using both APIs, comparing response sizes and request counts:

var express = require("express");
var contentful = require("contentful");
var https = require("https");

var app = express();

var SPACE_ID = process.env.CONTENTFUL_SPACE;
var ACCESS_TOKEN = process.env.CONTENTFUL_TOKEN;
var GRAPHQL_URL = "https://graphql.contentful.com/content/v1/spaces/" + SPACE_ID;

// REST client setup
var restClient = contentful.createClient({
  space: SPACE_ID,
  accessToken: ACCESS_TOKEN
});

// GraphQL helper
function gqlQuery(query, variables, callback) {
  var postData = JSON.stringify({ query: query, variables: variables || {} });

  var options = {
    method: "POST",
    hostname: "graphql.contentful.com",
    path: "/content/v1/spaces/" + SPACE_ID,
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + ACCESS_TOKEN,
      "Content-Length": Buffer.byteLength(postData)
    }
  };

  var req = https.request(options, function(res) {
    var body = "";
    res.on("data", function(chunk) { body += chunk; });
    res.on("end", function() {
      var parsed = JSON.parse(body);
      if (parsed.errors) {
        return callback(new Error(parsed.errors[0].message));
      }
      callback(null, parsed.data, body.length);
    });
  });

  req.on("error", function(err) { callback(err); });
  req.write(postData);
  req.end();
}

// Fetch blog posts via REST
function fetchPostsREST(callback) {
  var startTime = Date.now();
  var requestCount = 0;

  restClient.getEntries({
    content_type: "blogPost",
    include: 2,
    limit: 10,
    order: "-fields.publishDate"
  }).then(function(response) {
    requestCount++;
    var rawSize = JSON.stringify(response).length;
    var elapsed = Date.now() - startTime;

    var posts = response.items.map(function(item) {
      return {
        title: item.fields.title,
        slug: item.fields.slug,
        publishDate: item.fields.publishDate,
        synopsis: item.fields.synopsis,
        author: item.fields.author
          ? item.fields.author.fields.name
          : null,
        category: item.fields.category
      };
    });

    callback(null, {
      api: "REST",
      posts: posts,
      metrics: {
        responseSize: rawSize,
        requestCount: requestCount,
        latencyMs: elapsed
      }
    });
  }).catch(function(err) {
    callback(err);
  });
}

// Fetch blog posts via GraphQL
function fetchPostsGraphQL(callback) {
  var startTime = Date.now();

  var query = [
    "query GetPosts($limit: Int!) {",
    "  blogPostCollection(limit: $limit, order: publishDate_DESC) {",
    "    total",
    "    items {",
    "      title",
    "      slug",
    "      publishDate",
    "      synopsis",
    "      author { name }",
    "      category",
    "    }",
    "  }",
    "}"
  ].join("\n");

  gqlQuery(query, { limit: 10 }, function(err, data, rawSize) {
    if (err) return callback(err);

    var elapsed = Date.now() - startTime;
    var posts = data.blogPostCollection.items.map(function(item) {
      return {
        title: item.title,
        slug: item.slug,
        publishDate: item.publishDate,
        synopsis: item.synopsis,
        author: item.author ? item.author.name : null,
        category: item.category
      };
    });

    callback(null, {
      api: "GraphQL",
      posts: posts,
      metrics: {
        responseSize: rawSize,
        requestCount: 1,
        latencyMs: elapsed
      }
    });
  });
}

// Comparison endpoint
app.get("/api/compare", function(req, res) {
  var results = {};

  fetchPostsREST(function(err, restResult) {
    if (err) return res.status(500).json({ error: err.message });
    results.rest = restResult;

    fetchPostsGraphQL(function(err, gqlResult) {
      if (err) return res.status(500).json({ error: err.message });
      results.graphql = gqlResult;

      results.comparison = {
        sizeReduction: Math.round(
          (1 - gqlResult.metrics.responseSize / restResult.metrics.responseSize) * 100
        ) + "%",
        restRequests: restResult.metrics.requestCount,
        graphqlRequests: gqlResult.metrics.requestCount,
        sameData: JSON.stringify(restResult.posts) === JSON.stringify(gqlResult.posts)
      };

      res.json(results);
    });
  });
});

// Individual endpoints
app.get("/api/posts/rest", function(req, res) {
  fetchPostsREST(function(err, result) {
    if (err) return res.status(500).json({ error: err.message });
    res.json(result);
  });
});

app.get("/api/posts/graphql", function(req, res) {
  fetchPostsGraphQL(function(err, result) {
    if (err) return res.status(500).json({ error: err.message });
    res.json(result);
  });
});

var PORT = process.env.PORT || 3000;
app.listen(PORT, function() {
  console.log("Comparison server running on port " + PORT);
});

Hit /api/compare and you will see both results side-by-side with metrics. In my testing, the GraphQL response is typically 70-85% smaller than the REST response for the same data, with comparable latency.

Migrating Between REST and GraphQL

If you are considering a migration, do it incrementally. Both APIs can coexist in the same application. Start by moving your most expensive queries — the ones fetching deeply nested references — to GraphQL while keeping simpler lookups on REST.

Key migration considerations:

  1. Replace include chains first. Queries where you set include: 3 or higher benefit most from GraphQL's nested resolution.
  2. Keep the Sync API on REST. There is no GraphQL replacement. If you use sync, that code stays on REST.
  3. Update error handling. GraphQL returns 200 OK even when queries fail. Errors appear in the errors array alongside partial data. Your error handling must check both.
  4. Watch the complexity budget. A query that worked fine with REST's include: 2 might exceed GraphQL's 11,000-point complexity limit if you request too many fields on nested types.

Using Both APIs Together

The optimal approach for many projects is using both APIs:

  • GraphQL for page rendering where you need specific fields from multiple linked entries
  • REST for content synchronization via the Sync API
  • REST for webhook-triggered cache invalidation where you need ETag support
  • GraphQL for search and filtering where response size matters

This is not a compromise. It is leveraging each API's strengths where they matter most.

Common Issues and Troubleshooting

Query complexity exceeded. Your query requests too many fields across too many nested levels. Reduce limit values on collections, request fewer fields, or split into multiple queries. Check the x-contentful-graphql-query-cost header to see exactly how many points your query costs.

Null fields on linked entries. If a linked entry is in draft state, GraphQL returns null for it. This catches people off guard because REST with the include parameter simply omits unpublished entries from the includes array. Add null checks throughout your GraphQL response handling.

Locale handling differences. REST returns all locales by default (unless you specify locale=*), while GraphQL requires you to pass a locale argument. If you are working with multiple locales, be aware that GraphQL requires a separate query per locale or use of the locales collection.

Rate limiting with POST requests. Because GraphQL uses POST, aggressive clients sometimes retry failed queries more readily than GET requests. Implement exponential backoff and respect the X-Contentful-RateLimit-Reset header. Contentful's rate limit of 55 requests per second applies equally to both APIs.

Environment parameter missing. GraphQL requires the environment parameter in the URL for non-master environments: /content/v1/spaces/{spaceId}/environments/{environmentId}. Forgetting this when using feature branches or staging environments returns confusing schema errors.

Best Practices

  1. Use GraphQL for frontend-facing queries. When response size directly affects user experience (page load time, mobile data usage), GraphQL's precise field selection delivers measurable improvements.

  2. Keep REST for background jobs. Sync operations, bulk exports, and scheduled tasks benefit from REST's Sync API and simpler retry semantics with GET requests.

  3. Set up persisted queries in production. Contentful supports persisted GraphQL queries that use GET requests, enabling CDN caching. This eliminates GraphQL's biggest caching disadvantage.

  4. Monitor query complexity. Log the complexity cost of your GraphQL queries. As your content model grows and editors add more linked entries, previously safe queries can start failing. Build alerting around this.

  5. Cache GraphQL responses at the application layer. Since CDN caching is limited for POST requests, implement application-level caching with TTLs appropriate for your content update frequency. A simple in-memory cache with a 60-second TTL handles most cases.

  6. Use fragments for consistency. Define GraphQL fragments for each content type and reuse them across queries. This ensures consistent field selection and makes schema changes easier to propagate.

  7. Type-check your GraphQL responses. The auto-generated schema is a contract. Use it to generate TypeScript types or validation schemas so your code breaks at build time, not at runtime, when someone changes the content model.

  8. Prefer collection queries over single-entry queries. Even when fetching a single entry, using blogPostCollection(where: { slug: "my-post" }, limit: 1) is more flexible than blogPost(id: "...") because it lets you filter by any field, not just the entry ID.

References

Powered by Contentful