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:
- Replace
includechains first. Queries where you setinclude: 3or higher benefit most from GraphQL's nested resolution. - Keep the Sync API on REST. There is no GraphQL replacement. If you use sync, that code stays on REST.
- Update error handling. GraphQL returns
200 OKeven when queries fail. Errors appear in theerrorsarray alongside partialdata. Your error handling must check both. - Watch the complexity budget. A query that worked fine with REST's
include: 2might 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
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.
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.
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.
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.
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.
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.
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.
Prefer collection queries over single-entry queries. Even when fetching a single entry, using
blogPostCollection(where: { slug: "my-post" }, limit: 1)is more flexible thanblogPost(id: "...")because it lets you filter by any field, not just the entry ID.