A common framing of the REST-vs-GraphQL debate goes: "REST forces you to over-fetch; GraphQL lets you ask for exactly what you need." That framing is incomplete. Field selection in REST has been part of widely-used specifications for over a decade.
We'll go through what differs between REST and GraphQL in 2026, then look at Strapi v5 specifically, where both APIs are available out of the box and the choice has practical consequences.
TL;DR
openapi-fetch, REST also gets end-to-end TypeScript types without an Apollo runtime.The over-fetching argument goes like this: a REST endpoint returns a fixed payload. If the page only needs the article title and slug, but the endpoint returns the full body, the byline, the cover image, and twelve other fields, the client paid for data it did not use. GraphQL solves this by letting the client name the fields it wants.
The same control exists in REST.
?fields[articles]=title,slug returns title and slug.$select, $expand, and $filter since 2007.Accept header and media types.?fields[0]=title&fields[1]=slug.A Strapi request like:
1GET /api/articles?fields[0]=title&fields[1]=slug&populate[author][fields][0]=namereturns articles with the title, slug, and author name (plus the auto-included id and documentId that Strapi adds to every entry). The data shape is the same as the equivalent GraphQL selection.
That URL is the LHS bracket syntax, the format Strapi accepts on the wire. It is awkward to read once you start nesting populate inside fields inside another populate. Most code does not write it by hand. It writes a plain JavaScript object and lets qs serialize it:
1import qs from "qs";
2
3const query = qs.stringify(
4 {
5 fields: ["title", "slug"],
6 populate: {
7 author: { fields: ["name"] },
8 },
9 },
10 { encodeValuesOnly: true },
11);
12// → "fields[0]=title&fields[1]=slug&populate[author][fields][0]=name"
13
14await fetch(`/api/articles?${query}`);The @strapi/client SDK does the same thing under the hood, and accepts the same shape:
1import { strapi } from "@strapi/client";
2
3const client = strapi({ baseURL: "http://localhost:1337/api" });
4const articles = await client.collection("articles").find({
5 fields: ["title", "slug"],
6 populate: { author: { fields: ["name"] } },
7});So the bracket form is what the server sees, but it is not what the developer types.
Field selection alone is not the reason to pick GraphQL. The next sections look at what else the two APIs differ on.
REST needs nothing on day one. An HTTP framework gives you routes and handlers. Every tool that exists, including curl, the browser address bar, Postman, your shell, your CI runner, and your CDN, already speaks HTTP.
curl https://api.example.com/articles/42?fields[0]=title&fields[1]=slugThat is the whole client.
GraphQL is more parts. On the server you need a library: Apollo Server, GraphQL Yoga, Mercurius, gqlgen, or the framework's plugin (Strapi's GraphQL plugin uses Apollo Server under the hood). You define a schema, write resolvers, and wire the two together. On the client you usually want Apollo Client, urql, or Relay. A raw fetch works, but the moment you want a shared cache for the same User object across many components, optimistic updates on a mutation, or query deduplication, you are back to a real client library. To get TypeScript types from your queries you also need GraphQL Code Generator and a build step that runs it.
None of this is hard. It is just more.
REST uses HTTP status codes. 404 means the resource does not exist. 401 means the caller is not signed in. 403 means signed in but not allowed. 429 means rate-limited. Every monitoring tool, every retry library, every load balancer, every CDN already understands these.
GraphQL replies with 200 OK for almost everything, including failed queries. The actual error sits in the body:
1{
2 "data": null,
3 "errors": [
4 { "message": "Forbidden", "extensions": { "code": "FORBIDDEN" } }
5 ]
6}The HTTP response was successful. The query failed. The two pieces of information live in different places. This is the standard pattern for GraphQL over HTTP and is how Apollo Server, GraphQL Yoga, Mercurius, and Strapi's GraphQL plugin all reply by default.
The practical effect is that every monitoring dashboard that groups by HTTP status code shows your error rate as zero while you actually have a real one. Datadog, New Relic, and Sentry all need to be taught to read the response body to know an error happened. Cloudflare's WAF rules that fire on 5xx will not fire on a GraphQL error. Retry libraries that back off on 5xx will retry happily on a permanent failure that came back as 200.
This is solvable. It is extra work that has to be added on top of any GraphQL deployment.
REST wins on caching by a wide margin.
A REST GET is cacheable by default. The URL is the cache key. CDNs, browsers, and reverse proxies have been doing this since 1997. With one header, Cache-Control: public, max-age=60, stale-while-revalidate=300, the response sits at the edge for sixty seconds. With ETag plus If-None-Match, the server replies 304 Not Modified with no body. With Vary: Authorization you can cache differently per signed-in user. None of this requires a server change. None of it requires a client change.
A GraphQL request is a POST to /graphql with the query in the body. By default POST is not cacheable. The URL is always /graphql, so the URL alone tells the cache nothing; the body is what changes. CDNs do not cache it.
The community workaround is Automatic Persisted Queries (APQ). The client hashes the query with SHA-256, sends only the hash, and on first contact registers the query body with the server. Subsequent requests use a GET with the hash in the query string, which a CDN can cache.
APQ works. It is also not free:
POST round trip to register the hash, then a retry.The other option is client-side caching (Apollo InMemoryCache, Relay store). This is a real win for single-page apps where the same User object appears in many components and you want them to stay in sync. It also ships a meaningful chunk of code to the browser, and you write per-query configuration to handle pagination merging, partial cache hits, and telling the cache that two queries returned the same User (for example, getUser(id: 1) and currentUser are the same record). And none of it helps your CDN.
For a read-heavy public API (a blog, a marketing site, a product catalog), REST plus a CDN is hard to beat. For a logged-in dashboard where every response is per-user, edge caching does not apply and the gap closes.
A REST handler is a function from request to response. You can read it top to bottom. You can write the SQL by hand, add a Redis cache, log timing on the slow part. Each endpoint is its own thing, optimized for its own access pattern.
A GraphQL execution is a tree of resolver calls. Each field has its own resolver. The article resolver does not know whether the caller asked for the author. The author resolver does not know whether the caller asked for the author's email. So each resolver has to fetch its own field on its own, without seeing the rest of the query. That is where the N+1 problem in the next section comes from.
A naive resolver setup:
1const resolvers = {
2 Query: {
3 posts: () => db.query("SELECT * FROM posts LIMIT 20"),
4 },
5 Post: {
6 author: (post) =>
7 db.query("SELECT * FROM users WHERE id = ?", [post.authorId]),
8 },
9};A query of { posts { title author { name } } } triggers 21 SQL queries: one for the twenty posts, then one per post for each author. If the post also has comments and each comment has an author, you can reach a hundred queries on a single request. This is not a bug. It is how GraphQL execution works.
The standard fix is DataLoader. You wrap each data source in a loader, and within one tick the loader collects keys and runs a single batched query. It works, but:
WHERE id IN (...). Filtered subqueries, paginated child collections, conditional joins. DataLoader does not help there.A REST endpoint with ?include=author,comments.author lets you write one SQL query with the right joins, tuned to the access pattern, with predictable cost.
Because GraphQL clients can compose any nested query, a careless or malicious client can submit:
1query Bomb {
2 users {
3 friends {
4 friends {
5 friends {
6 friends {
7 id
8 }
9 }
10 }
11 }
12 }
13}If users returns a thousand rows and each friends has a hundred entries, you have ten billion fields to resolve. Every production GraphQL deployment has to defend against this. To defend, you bolt on:
friends1: friends(limit:1) friends2: friends(limit:2) ... enumeration tricks.REST has none of these because every endpoint is fixed at design time. The server decided in advance what /articles returns; the client cannot compose a new query on the fly.
REST authorization is per-route. The route GET /admin/users has one permission check; the handler enforces it once. The rule sits in middleware on a route, and the rule lives in one file the new hire can find.
GraphQL authorization is per-field. A query like { user(id: 1) { email internalNotes adminFlag } } has one top-level resolver, but every field can leak data on its own. A single "is the caller allowed to view this user?" check at the route handler covers every field in REST. In GraphQL, that check has to repeat for every field a query selects, because each field is its own data path.
You end up with one of:
@auth(requires: ADMIN) on fields. Better, but the runtime has to enforce them.graphql-shield, layered over the resolver tree.Strapi v5's GraphQL plugin handles this with resolversConfig. The plugin lets you attach middlewares and policies to specific resolvers in the schema, including nested ones, so you can run a permission check for every field that needs one. The flip side is that you now have to think about authorization on every field that touches a sensitive value, not once per route.
A REST request like GET /articles shows up in your logs and your tracing tool as one operation: one URL, one latency number, one error count. Datadog, New Relic, and OpenTelemetry libraries for Express, Koa, and Fastify all do this out of the box.
GraphQL needs per-resolver tracing setup. Without it, every request is one span called POST /graphql, which tells you nothing about what was slow. With it, you get a tree of nested resolver spans, which is useful but extra work. Tracking which endpoint is slow becomes tracking which field of which operation is slow, and the same field can be fast in one query and slow in another depending on where it sits in the tree.
Federation is where GraphQL has the strongest argument, but the architecture has specific preconditions. The case studies (Netflix, Shopify) come from teams with dozens of backend services and dedicated platform engineers. A team without those preconditions takes on the complexity without the payoff.
Sam Newman's BFF pattern: do not expose your microservices directly. Build one aggregation layer per client (web, iOS, Android), each tuned to that client's data needs. The BFF makes parallel calls to internal services and returns exactly what the UI needs.
REST plus a BFF is a fine answer to the "many clients, many services" problem. Each BFF is owned by the team that owns the corresponding frontend. The BFFs themselves call internal REST or gRPC services. The client side stays simple: one GET /home-screen returns everything the home screen needs, fully cacheable, fully typed via OpenAPI.
The downside is that you write and maintain N BFFs.
Instead of writing N BFFs, you can run one GraphQL server and let each client write its own queries. This is a real win when client data needs are similar in shape but different in detail, and one team owns both the data graph and the schema.
Federation is for the case where many backend teams each own a slice of the graph and want to compose them into one client-facing API without one team becoming the bottleneck. Each team runs a subgraph. A router (Apollo Router, Hive Gateway, Cosmo) reads the composed supergraph schema, plans queries that cross subgraphs, and orchestrates the calls.
Netflix is the canonical example. Their architecture has been described publicly in posts and talks: dozens of Domain Graph Services owned by individual teams, one supergraph used by every UI surface, independent deploys per team. The motivation was that each UI team owning its own API surface no longer scaled past a certain organization size.
The preconditions for that win:
Shopify is the other heavyweight. They have moved their Admin platform onto GraphQL as the primary API and require new public App Store apps to use it. The reasoning is the same shape as Netflix's: a huge content surface (products, variants, metafields, inventory, orders, fulfillments), many client surfaces, and the operational headcount to run the platform.
If you have one backend team, one database, one or two clients, and no need for independent service ownership, federation is a complexity tax with no payback. You are paying for distributed-system overhead (supergraph composition, subgraph health monitoring, query planning, cross-service tracing) when a Postgres database and a single REST app would do the job.
Stripe, Twilio, AWS, GitHub (REST), DigitalOcean, Heroku, Cloudflare, OpenAI, and Anthropic all expose REST as the primary public API. Stripe uses GraphQL internally for their dashboard but does not expose it publicly. The reasoning is that a public API is consumed by thousands of clients across many languages, and REST is easier to cache at the edge, rate-limit per endpoint, document via OpenAPI for SDK generation, debug from any environment, and secure.
A common claim is "GraphQL gives you end-to-end type safety." It does. So does REST plus OpenAPI. So does tRPC. These are three roads to the same place.
In 2026 the OpenAPI ecosystem is genuinely good. The two libraries that matter for typed REST clients:
openapi-typescript is a CLI that reads an OpenAPI 3.0 or 3.1 spec (a JSON or YAML document describing every endpoint, every parameter, every response shape) and emits a single TypeScript file containing a paths type. That paths type is a giant object literal: keys are URL templates (/articles/{id}), values describe the methods, params, and response shapes for each one.openapi-fetch is a tiny (around 6 KB) wrapper around the platform fetch. It takes that paths type as a generic and gives you a typed client. The published package is openapi-fetch on npm, currently on v7, and works with Node 18 or higher and TypeScript 4.7 or higher.How it works:
1import createClient from "openapi-fetch";
2import type { paths } from "./api-schema"; // generated by openapi-typescript
3
4const client = createClient<paths>({ baseUrl: "https://api.example.com" });
5
6const { data, error } = await client.GET("/articles/{id}", {
7 params: { path: { id: "42" } },
8});What TypeScript knows here, purely from the paths type, with zero runtime overhead:
"/articles/{id}" must be a real path on the API. A typo gets a compile error.params.path.id shape is required because the path template has {id} in it. If the route also took query parameters, params.query would be required and typed.POST, PUT, PATCH), the call site requires a body argument typed against the spec.data is typed as the success-response shape from the spec.error is typed as the error-response shape from the spec.data or error is defined, never both. The check is enforced at the type level.At runtime it is a fetch call with the URL substituted and headers set; there is no client-side schema validation, no proxy, no magic. The whole library is a typed wrapper.
The end result is the same "compile error if the API changes" guarantee that GraphQL Code Generator gives you, with no codegen pipeline to maintain beyond a single CLI step that runs in CI.
The mature path on the GraphQL side. Point graphql-codegen at the schema (introspection or SDL file). Configure plugins: typescript, typescript-operations, typed-document-node, plus framework adapters if you want hooks. Each .graphql file or inline gql\...`template produces typed query and mutation hooks. Newer libraries likegql.tada` skip codegen entirely and infer types from a schema source-of-truth at compile time.
This works well. You also run a code-generation step as part of your build, and the generated TypeScript files can get large in big projects.
The third option, often forgotten. If both client and server are TypeScript and live in the same repo, tRPC gives you compile-time type safety with no schema language, no codegen, and almost no runtime overhead. It is not a replacement for a public API, because there is no schema artifact for non-TypeScript consumers, but for an internal Next.js or Remix backend it is often the right call over either REST or GraphQL.
For type safety alone, REST plus OpenAPI is at parity with or better than GraphQL for most teams. The OpenAPI document is human-readable, doubles as documentation, and can drive a mock server (Prism, Stoplight) and contract tests (Dredd, Schemathesis). openapi-fetch needs no build step. HTTP semantics (status codes, headers) are first-class in the type system. You do not pay the GraphQL runtime tax for the type benefit.
GraphQL still has a typing edge in two narrow cases:
Outside those two cases, REST plus OpenAPI is at parity or better.
REST handles uploads with a plain multipart/form-data POST to /upload. Every framework supports it natively.
GraphQL has no spec-level upload type. The community pattern is the GraphQL multipart request specification, which encodes files as multipart/form-data with a JSON operations field and a map field. Apollo Server removed its built-in upload integration in 2021 over CSRF concerns. The currently recommended pattern is the same one most REST APIs already use: a mutation hands the client a presigned upload URL, the client PUTs the file directly to the storage provider.
Strapi's GraphQL plugin does not handle media uploads at all. Files go through the REST POST /upload endpoint regardless of which API you use for the rest of the app. Even a Strapi project that uses GraphQL for everything else writes REST calls for uploads.
REST plus WebSockets, REST plus Server-Sent Events, or REST plus long-polling are all viable, with mature libraries (Socket.io, ws, native EventSource). They are decoupled from your data API.
GraphQL Subscriptions are part of the spec, transported over WebSocket (graphql-ws) or SSE. They feel natural in a GraphQL stack. They also have rough edges: Apollo Federation's subscription support has been historically problematic, and authentication over WebSocket needs its own conventions. For most apps that need real-time updates of a few resources, plain SSE or a focused WebSocket is simpler than spinning up subscription infrastructure.
The internet is full of benchmarks "proving" one is faster than the other. Most are useless because they ignore caching.
A fair summary at the protocol level:
304 Not Modified or a CDN cache hit is in single-digit milliseconds. GraphQL POST requests bypass all of it unless you have set up APQ plus GET conversion plus edge caching, and the hit ratio is typically lower.At the protocol level, the differences are small and dominated by your database access patterns and your caching strategy. Choose for fit, not for benchmarks.
For measured numbers against a real Strapi v5 app, see section 3.5 below.
REST is the standard for public APIs because every property that matters for public consumers favors it:
429 and standard Retry-After.The set of major public GraphQL APIs is small. GitHub offers GraphQL alongside REST. Shopify's Admin and Storefront APIs offer both, and Shopify is now mandating GraphQL for new apps because their domain (products, variants, metafields, inventory, orders, fulfillments) is graph-shaped and they have the platform team to operate it. Stripe, Twilio, AWS, OpenAI, Cloudflare, Anthropic, none of them expose a public GraphQL API.
That is not because they are behind the times. It is because REST's properties are exactly what public-API consumers want.
Strapi v5 ships both APIs out of the box. REST is on by default. GraphQL is @strapi/plugin-graphql, installed and configured separately. Both are auto-generated from your content types.
The examples below use the seeded blog content that Strapi v5 generates when you answer "Yes" to "Start with an example structure & data?" during npx create-strapi@latest. You get Article, Author, and Category content types with realistic data. The same project is the starting point for Part 1 of the GraphQL series, if you want a step-by-step build.
Scaffold a new Strapi project:
npx create-strapi-app@latest serverReasonable answers to the prompts:
| Prompt | Answer |
|---|---|
| Strapi cloud login | Skip |
| "Do you want to use the default database (SQLite)?" | Yes |
| "Start with an example structure & data?" | Yes |
| "Use TypeScript?" | Yes |
| "Install dependencies with npm?" | Yes |
| "Would you like to initialize a git repository?" | Yes |
Strapi v5.44.0 🚀 Let's create your new project
🚀 Welcome to Strapi! Ready to bring your project to life?
Create a free account and get:
30 days of access to the Growth plan, which includes:
✨ Strapi AI: content-type builder, media library and translations
✅ Live Preview
✅ Single Sign-On (SSO) login
✅ Content History
✅ Releases
? Please log in or sign up. Skip
? Do you want to use the default database (sqlite) ? Yes
? Start with an example structure & data? Yes
? Start with Typescript? Yes
? Install dependencies with npm? Yes
? Initialize a git repository? Yes
Strapi Move into the project, install the GraphQL plugin, start the dev server:
cd server
npm install @strapi/plugin-graphql
npm run developOpen http://localhost:1337/admin and fill in the one-time form to create an admin user.
Two more one-time setup steps before the API tests below will return data:
Publish the seeded articles. The example data ships with draftAndPublish enabled on Article, so every seeded article starts as a draft. Strapi's APIs only return published entries to public callers. In the admin, click Content Manager → Article, tick the header checkbox to select every row, click Publish in the bulk-action bar, then confirm in the modal. Author and Category do not have draft mode and are queryable as soon as you grant permissions.
Grant public read permissions. The Public role usually has these on a fresh install, but check anyway. Open Settings → Users & Permissions Plugin → Roles → Public. Expand Article, confirm find and findOne are enabled. Repeat for Author and Category. Save.
You now have two interactive endpoints to test against:
http://localhost:1337/api/<collection> is the REST API. Hit it with curl, Postman, Bruno, or your browser.http://localhost:1337/graphql is both the GraphQL endpoint and (in development) the Apollo Sandbox UI. Open it in a browser and you get autocompletion, schema introspection, and a Run button. Every GraphQL query in this post can be pasted into the Sandbox and run there instead of curl.The REST API uses LHS bracket syntax for query parameters. That bracket form is what hits the wire. In application code you usually write a JavaScript object and let qs (which Strapi already uses internally) serialize it. Each REST example below shows both: the curl form you can paste into a terminal, and the qs form you would write in code.
If you would rather build the URL visually before reaching for qs, the Strapi docs have an Interactive Query Builder that takes the endpoint and parameter object and produces the full query-string URL on the page.
A few things you need before reading the comparisons that follow:
documentId (string). It is what you query by, mutate by, and use when setting relations.eq, ne, containsi, notNull, and so on. No dollar sign. REST uses $eq, $ne, $containsi, $notNull. Same set, different syntax.?status=draft or ?status=published. GraphQL takes status: DRAFT or status: PUBLISHED (an enum, no quotes).articles { ... }) or as a connection (articles_connection { nodes { ... } pageInfo { ... } }). pageInfo exposes page, pageSize, total, and pageCount. There is no aggregate field in 5.44.0; some older write-ups list one with count, avg, and groupBy.fields (REST) only works on scalar attributes: string, text, richtext, enumeration, email, password, uid, plus numbers, dates, booleans. Relations, components, media, and dynamic zones go through populate. GraphQL handles all of this through nested selection in the query body.
populate=* (REST) fetches every relation one level deep whether the page needs it or not. It is fine in development, not good practice in production. The Strapi post Demystifying Strapi's Populate and Filtering goes deeper.
Each topic shows the REST request (curl, a clickable link to run in your browser, and the qs form for code) and the GraphQL request (the query plus a curl POST so you can run it from a terminal). All examples assume the seeded articles have been published and the public role has find and findOne on Article, Author, and Category, as in the setup section above.
The post only shows GET examples on the REST side. The same patterns (
fields,populate,filters,sort,pagination,locale,status) work on POST, PUT, and DELETE through the request body and URL. Mutations on the GraphQL side use the same query-argument syntax as queries, just undermutation { ... }.
Return only title and slug.
REST:
curl 'http://localhost:1337/api/articles?fields[0]=title&fields[1]=slug'1import qs from "qs";
2
3qs.stringify(
4 { fields: ["title", "slug"] },
5 { encodeValuesOnly: true },
6);
7// → "fields[0]=title&fields[1]=slug"GraphQL:
1{
2 articles {
3 title
4 slug
5 }
6}curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ articles { title slug } }"}'You can also paste the GraphQL query into the Apollo Sandbox at http://localhost:1337/graphql and hit Run.
REST also includes id and documentId on each entry whether you ask for them or not. GraphQL only returns the fields you select.
Return articles with the author's name and the category's name.
REST:
curl 'http://localhost:1337/api/articles?fields[0]=title&populate[author][fields][0]=name&populate[category][fields][0]=name'1qs.stringify(
2 {
3 fields: ["title"],
4 populate: {
5 author: { fields: ["name"] },
6 category: { fields: ["name"] },
7 },
8 },
9 { encodeValuesOnly: true },
10);GraphQL:
1{
2 articles {
3 title
4 author { name }
5 category { name }
6 }
7}curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ articles { title author { name } category { name } } }"}'REST is explicit: every relation has to be named under populate. GraphQL is implicit: nesting author { ... } in the selection auto-resolves the relation. The two paths can hit the database differently, so the same logical query can have different performance profiles.
Find articles whose title contains "internet", case-insensitive.
REST:
curl 'http://localhost:1337/api/articles?filters[title][$containsi]=internet'1qs.stringify(
2 { filters: { title: { $containsi: "internet" } } },
3 { encodeValuesOnly: true },
4);GraphQL:
1{
2 articles(filters: { title: { containsi: "internet" } }) {
3 documentId
4 title
5 }
6}curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ articles(filters: { title: { containsi: \"internet\" } }) { documentId title } }"}'Find articles whose category slug is news.
REST:
curl 'http://localhost:1337/api/articles?filters[category][slug][$eq]=news'1qs.stringify(
2 { filters: { category: { slug: { $eq: "news" } } } },
3 { encodeValuesOnly: true },
4);GraphQL:
1{
2 articles(filters: { category: { slug: { eq: "news" } } }) {
3 title
4 category { name }
5 }
6}curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ articles(filters: { category: { slug: { eq: \"news\" } } }) { title category { name } } }"}'Filters that traverse multiple relations can be slow on large tables. The query planner has to join across each relation in the path, and the deeper the path, the more joins.
Sort by title ascending, page 1, three per page.
REST:
curl 'http://localhost:1337/api/articles?sort[0]=title:asc&pagination[page]=1&pagination[pageSize]=3'1qs.stringify(
2 {
3 sort: ["title:asc"],
4 pagination: { page: 1, pageSize: 3 },
5 },
6 { encodeValuesOnly: true },
7);GraphQL:
1{
2 articles(
3 sort: "title:asc"
4 pagination: { page: 1, pageSize: 3 }
5 ) {
6 title
7 }
8}curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ articles(sort: \"title:asc\", pagination: { page: 1, pageSize: 3 }) { title } }"}'REST returns a meta.pagination object with page, pageSize, pageCount, and total. To get the same totals from GraphQL you query articles_connection and select pageInfo.
Title, slug, author name, cover URL, only published, sorted newest first, ten per page, English locale.
REST:
1import qs from "qs";
2
3const query = qs.stringify(
4 {
5 fields: ["title", "slug"],
6 populate: {
7 author: { fields: ["name"] },
8 cover: { fields: ["url"] },
9 },
10 filters: { publishedAt: { $notNull: true } },
11 sort: ["publishedAt:desc"],
12 pagination: { pageSize: 10 },
13 locale: "en",
14 },
15 { encodeValuesOnly: true },
16);
17
18const res = await fetch(`http://localhost:1337/api/articles?${query}`);
19const articles = await res.json();GraphQL:
1query GetArticles {
2 articles(
3 filters: { publishedAt: { notNull: true } }
4 sort: "publishedAt:desc"
5 pagination: { pageSize: 10 }
6 locale: "en"
7 ) {
8 documentId
9 title
10 slug
11 author { name }
12 cover { url }
13 }
14}curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"query GetArticles { articles(filters: { publishedAt: { notNull: true } }, sort: \"publishedAt:desc\", pagination: { pageSize: 10 }, locale: \"en\") { documentId title slug author { name } cover { url } } }"}'Same data, two ways to ask for it. The REST GET is cacheable at the edge by URL. The GraphQL query reads more cleanly in an editor with GraphQL syntax highlighting, and the same selection can be reused as a fragment across components.
POST /upload endpoint for all file uploads and use the returned info to link to it in content types." The schema still exposes updateUploadFile and deleteUploadFile mutations, just no upload-creation mutation.id, not documentId. From the mutations on media files section: "Currently, mutations on media fields use Strapi v4 id, not Strapi 5 documentId, as unique identifiers for media files." Queries return documentId, but for a media mutation you have to look up the numeric id separately (admin UI or a REST call).$eq, $containsi, $notNull (REST filter operators). GraphQL operators do not: eq, containsi, notNull (GraphQL filter operators). The set is the same, the syntax differs.populate versus nested selection. REST is explicit: you ask for populate[author] (REST populate docs). GraphQL is implicit: nesting author { ... } in the selection auto-resolves the relation. The two paths can hit the database differently, so a request that is fast on one API might not be on the other.articles { author { name } } reads both Article and Author. If the public role lacks find on Author, the relation comes back null while the articles query still succeeds.Authorization header through curl, Postman, or any HTTP client. The Apollo Sandbox UI has its own header field for it.What we measured. Four kinds of API call against the same Strapi v5 app on Postgres, hitting the same rows through both REST and GraphQL: a simple list with no relations, a list with relations and a component populated, a deep list with relations plus components plus dynamic zones, and a single create. We ran each one five times and report the median. Strapi and Postgres were fully restarted between every run.
Results.
| Scenario | API | p50 (ms) | p95 (ms) | p99 (ms) | rate (req/s) |
|---|---|---|---|---|---|
| Simple list (25 entries, no relations) | REST | 7 | 10.1 | 12.1 | 50 |
| Simple list (25 entries, no relations) | GraphQL | 5 | 7 | 8.9 | 50 |
| Populated list (relations + component) | REST | 12.1 | 13.9 | 16.9 | 50 |
| Populated list (relations + component) | GraphQL | 27.9 | 47.9 | 58.6 | 50 |
| Deep populate with dynamic zones | REST | 15 | 22 | 24.8 | 50 |
| Deep populate with dynamic zones | GraphQL | 37.7 | 66 | 76 | 50 |
| Create one entry | REST | 5 | 7.9 | 10.9 | 47 |
| Create one entry | GraphQL | 5 | 8.9 | 10.1 | 50 |
What it means.
id, documentId, createdAt, updatedAt, publishedAt, plus a meta.pagination block on top of the data you asked for. On a single create both APIs were 5 ms median. They both go through the same Document Service, and most of that time is the database insert itself.Bottom line for Strapi. Simple reads and writes are a wash. Where REST is faster is the case Strapi is most often used for: pages that pull articles plus their author plus their category plus a couple of components. The more of those you ask for in one request, the wider the gap gets. We did not test REST behind a CDN or GraphQL with persisted queries (APQ); both would change the numbers in their respective API's favor.
REST.
GraphQL is the better fit for a few specific cases:
noteStats query that totals notes by tag, for example).npx strapi ts:generate-typesThis produces a types/generated/ folder with interfaces for every content type, every component, and the Strapi schema as a whole. It can be set to autogenerate on server restart via config/typescript.ts:
1export default { autogenerate: true };These types are designed for backend code (Document Service calls, custom controllers, custom services). They are not intended to be consumed by a frontend client directly. The Strapi TypeScript development docs note this, and an official Strapi v5 path is still in development.
For a frontend on Strapi v5 today, three community-supported routes work:
strapi-typed-client plugin (Strapi v5.0.0 and above, Node 18+) generates TypeScript interfaces from your schema and ships a fully typed fetch client with find, findOne, create, update, delete, populate-aware return types, and DynamicZone support.strapi-next-monorepo-starter shows the monorepo pattern for the same problem. Strapi v5 plus Next.js 16 in a Turborepo, with a dedicated packages/strapi-types workspace package that mirrors the Strapi-generated content-type definitions and exposes them to the Next.js app as a shared dependency. Read the repo's README for the full setup; it is the reference if you want full type-sharing across the boundary without writing the plumbing yourself.openapi-fetch flow in section 4.2 below.Strapi v5 has two paths to an OpenAPI spec:
npx strapi openapi generate -o ./openapi.json produces a JSON spec describing every auto-generated endpoint. It is marked experimental, but it ships with Strapi core.@strapi/plugin-documentation, the historical approach. It generates a Swagger UI at /documentation and emits a full_documentation.json OpenAPI 3.0.1 file. The Documentation plugin page flags it directly: "The Documentation plugin is not actively maintained and may not work with Strapi 5." The newer built-in CLI is the safer bet going forward.The three pieces from Strapi v5 plus the OpenAPI tooling fit together like this:
# 1. Have Strapi describe its own API as an OpenAPI 3 document.
npx strapi openapi generate -o ./openapi.json
# 2. Turn that document into a single TypeScript file.
# The output exports a `paths` type whose keys are URL templates
# ("/api/articles", "/api/articles/{documentId}", "/api/upload", ...)
# and whose values describe each method, params, and response shape.
npx openapi-typescript ./openapi.json -o ./src/api-types.tsStep 1 produces openapi.json, a JSON description of every auto-generated endpoint Strapi exposes for your content types. Step 2 produces src/api-types.ts, a pure-types file with no runtime cost. Run both in CI when content types change.
Then on the frontend:
1// 3. Use openapi-fetch as your HTTP client.
2import createClient from "openapi-fetch";
3import type { paths } from "./api-types";
4
5const strapi = createClient<paths>({ baseUrl: process.env.STRAPI_URL });
6
7const { data, error } = await strapi.GET("/api/articles", {
8 params: {
9 query: {
10 "fields[0]": "title",
11 "fields[1]": "slug",
12 "populate[author][fields][0]": "name",
13 },
14 },
15});What the editor and the type checker do for you here, all from paths:
strapi.GET(" and the editor lists every path Strapi exposes. Typo a path and you get a compile error before the request goes out.params.query is typed against the operators and field names Strapi accepts for /api/articles. Pass "sort" instead of "sort[0]" and the compiler complains. Pass "filters[title][$wrong]=foo" and the compiler complains.strapi.POST("/api/articles", { body: { ... } }) fails to compile if the body shape does not match.data is typed as the success response shape that Strapi documented (the { data: [...], meta: { pagination: {...} } } envelope). Reading data.data[0].title is a typed property access. data.data[0].typoed_field is a compile error.error is typed as the error response shape. data and error are mutually exclusive at the type level: you check one branch or the other, you cannot read both.At runtime openapi-fetch does the URL substitution and calls the platform fetch. There is no schema validation on the wire and no proxy. The library is roughly six kilobytes and almost all of its value sits at the type layer. Strapi's API does not change, the responses do not change, the network behavior does not change. What changes is that the editor catches mistakes before the request runs.
End-to-end TypeScript with no GraphQL stack, no Apollo runtime, no codegen pipeline beyond a single CLI step that runs in CI.
npm i -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operationsPoint codegen.ts at http://localhost:1337/graphql and your *.graphql operation files. You get typed query results.
For a Strapi app, REST plus OpenAPI plus openapi-fetch matches GraphQL Code Generator on type safety, with less infrastructure:
.graphql operation files to maintain.If your only reason for picking GraphQL was end-to-end TypeScript types, openapi-fetch covers that case in REST.
Probably not, unless there is a specific reason REST cannot solve for your project.
The cases where the answer is clearly yes:
Custom queries that aggregate across content types in one round trip. A noteStats query that totals notes by tag. A dashboard query that returns the latest article, the current user, their unread notification count, and the site's footer settings, all in one payload. With REST you write a new controller per shape, or you compose multiple calls on the client. With GraphQL each is a single resolver that lives next to the auto-generated schema in src/extensions/graphql/.
Component-level field selection (fragment colocation). Each React, Vue, or Svelte component declares only the fields it needs, and Apollo Client or Relay merges them into the parent query for you. A Strapi REST request can do per-call field selection through ?fields= and ?populate=, but every screen has to assemble one combined URL by hand from every component's needs, which gets unwieldy as components nest.
A frontend already built around Apollo Client or Relay. Normalized client cache, optimistic updates, query-aware pagination. If your team has invested in these workflows, walking away from them costs more than it saves.
Many backend teams converging on one client-facing API. Apollo Federation (Netflix, Shopify scale). Overkill below that, but the strongest argument GraphQL has when the scale is there.
If you are not in one of those buckets, REST plus OpenAPI plus openapi-fetch covers the same ground with less infrastructure, less bundle size, and HTTP caching for free.
If you want to try GraphQL or just understand it well enough to make the call, the four-part Strapi v5 + Next.js GraphQL series walks through it end to end: a fresh Strapi project, a content type with relations, custom resolvers, middlewares and policies, and a Next.js frontend that consumes the schema. Part 1 takes about an hour and gives you enough working code to decide whether GraphQL is for your next project.
REST and GraphQL are not the only options.
Modern REST (JSON:API, OData, OpenAPI 3.1, the conventions Strapi v5 uses out of the box) supports field selection, sparse fieldsets, deep includes, and rich filtering. The "REST forces over-fetching" framing assumes a REST API that no major spec or product still ships.
The costs that come with GraphQL:
The teams where federation pays for itself (Netflix, Shopify, GitHub, large media companies) have many backend teams shipping independently against a single client-facing API and dedicated platform engineers to operate it. Outside that profile, the costs above arrive without the matching benefits.
In Strapi v5 the REST API is on by default, supports field selection (fields), relation/component/media inclusion (populate), a full filter operator set, sorting, pagination, locales, and draft/published filtering. The built-in OpenAPI CLI plus openapi-typescript plus openapi-fetch gives end-to-end TypeScript types with no Apollo runtime and no codegen pipeline. The GraphQL plugin offers the same data through a typed schema and the Apollo Sandbox, with the tradeoffs in section 2.
REST as the default, GraphQL when the use case justifies the cost.
| Dimension | REST (modern, with OpenAPI) | GraphQL |
|---|---|---|
| Field selection | JSON:API sparse fieldsets, ?fields= | Native |
| Includes / relations | ?include=, ?populate= | Native nested |
| HTTP caching | Free, mature, CDN-friendly | Needs APQ plus GET plus setup |
| Type safety | openapi-typescript / openapi-fetch | GraphQL Code Generator |
| Polyglot SDK generation | OpenAPI Generator, all languages | Uneven outside JS/TS |
| Authorization | Per-route middleware | Per-field, more attack surface |
| N+1 problem | Endpoint-shaped, you control the SQL | Structural, needs DataLoader |
| Query DoS | Endpoints have fixed cost | Needs depth and cost limiting |
| File uploads | Trivial multipart | Awkward, CSRF concerns |
| Real-time | SSE / WebSocket | Subscriptions (federation gaps) |
| Error semantics | HTTP status codes | Always 200, errors in body |
| Federation across teams | BFFs, manual aggregation | Apollo Federation excels |
| Public API ecosystem | Universal standard | Niche outside Shopify and GitHub |
| Versioning | Both work; both need discipline | Both work; both need discipline |
| Strapi v5 maturity | Default, complete | Plugin, no uploads, ID quirks |
When in doubt, REST.
populate and field selection: https://docs.strapi.io/cms/api/rest/populate-select@strapi/client SDK: https://docs.strapi.io/cms/api/client