Query by type, field, and relationship
Goal: compose a JSON:API query that returns exactly the entries and fields your page needs: filtered, trimmed, related, paginated, and sorted, in one request.
The query syntax is the JSON:API specification, so everything here applies to any Acquia-hosted backend: a Source CMS site (the examples’ default, serving /api) or self-managed Drupal on Cloud Platform (stock base path /jsonapi, see headless on Cloud Platform). Swap the base path (the apiPrefix option on the Drupal API Client) and every example holds.
What you’ll have when you’re done
Section titled “What you’ll have when you’re done”- Working filter syntax: field values, operators, and AND/OR combinations.
- Responses trimmed to the fields you use (
fields[type]) with related entries pulled in byinclude: no N+1 request loops. - One composed query (filter + fields + include + sort + pagination), ready to drop into a listing page, with the fetch idiom for your framework.
Prerequisites
Section titled “Prerequisites”- A working credential from the auth quickstart.
- Your content type’s machine name and one successful list request from the query quickstart.
- Examples use the
articletype; substitute yours. Each builds on the quickstart’s setup:- Drupal API Client examples assume its configured
clientandimport { DrupalJsonApiParams } from "drupal-jsonapi-params". - JavaScript examples assume
DRUPAL_SITE_URLand a freshaccess_token. - curl examples use
-g(square brackets pass through literally) and assume$DRUPAL_SITE_URLand$TOKENare set.
- Drupal API Client examples assume its configured
-
Filter by a field value
Section titled “Filter by a field value”The shorthand form matches a field exactly (
filter[<field>]=<value>in the URL); anything other than exact equality is a full condition: apath(the field machine name), anoperator, and avalue:addFiltertakes the path, the value, and optionally the operator:// exact matchconst queryString = new DrupalJsonApiParams().addFilter("status", "1").getQueryString();const published = await client.getCollection("node--article",{ queryString },);// full condition: "launch" anywhere in the titleconst params = new DrupalJsonApiParams().addFilter("title", "launch", "CONTAINS");const matches = await client.getCollection("node--article",{ queryString: params.getQueryString() },);// exact matchconst published = await fetch(`${DRUPAL_SITE_URL}/api/node/article` +`?filter[status]=1`,{headers: {Accept: "application/vnd.api+json",Authorization: `Bearer ${access_token}`,},});// full condition: "launch" anywhere in the title.// The label (here `in-title`) is yours to choose;// it groups the condition's parts.const matches = await fetch(`${DRUPAL_SITE_URL}/api/node/article` +`?filter[in-title][condition][path]=title` +`&filter[in-title][condition][operator]=CONTAINS` +`&filter[in-title][condition][value]=launch`,{headers: {Accept: "application/vnd.api+json",Authorization: `Bearer ${access_token}`,},});Terminal window # exact matchcurl -g -s "$DRUPAL_SITE_URL/api/node/article\?filter[status]=1" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"The label in the full condition form (here
in-title) is yours to choose; it groups the condition’s parts:Terminal window curl -g -s "$DRUPAL_SITE_URL/api/node/article\?filter[in-title][condition][path]=title\&filter[in-title][condition][operator]=CONTAINS\&filter[in-title][condition][value]=launch" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"Besides
=andCONTAINS, operators include<>,>,>=,<,<=,STARTS_WITH,ENDS_WITH,IN,NOT IN,BETWEEN,IS NULL, andIS NOT NULL; the query parameter reference documents each with an example. -
Combine conditions
Section titled “Combine conditions”Multiple filters AND together. For OR, declare a group with a
conjunctionand attach conditions to it withmemberOf. Two examples: published articles created since January 1, 2026 (1767225600as a UNIX timestamp), then “launch” in the title or the body:// AND: every addFilter call adds a conditionconst recent = new DrupalJsonApiParams().addFilter("status", "1").addFilter("created", "1767225600", ">=");// OR: declare the group, then pass its name// as addFilter's 4th argumentconst either = new DrupalJsonApiParams();either.addGroup("either", "OR");either.addFilter("title", "launch", "CONTAINS", "either",);either.addFilter("body.value", "launch", "CONTAINS", "either",);const results = await client.getCollection("node--article",{ queryString: either.getQueryString() },);// AND: conditions accumulate ("recent" is a// label of your choosing)const recent = await fetch(`${DRUPAL_SITE_URL}/api/node/article` +`?filter[status]=1` +`&filter[recent][condition][path]=created` +`&filter[recent][condition][operator]=%3E%3D` +`&filter[recent][condition][value]=1767225600`,{headers: {Accept: "application/vnd.api+json",Authorization: `Bearer ${access_token}`,},});// OR: a group with a conjunction, conditions// attached via memberOfconst either = await fetch(`${DRUPAL_SITE_URL}/api/node/article` +`?filter[either][group][conjunction]=OR` +`&filter[in-title][condition][path]=title` +`&filter[in-title][condition][operator]=CONTAINS` +`&filter[in-title][condition][value]=launch` +`&filter[in-title][condition][memberOf]=either` +`&filter[in-body][condition][path]=body.value` +`&filter[in-body][condition][operator]=CONTAINS` +`&filter[in-body][condition][value]=launch` +`&filter[in-body][condition][memberOf]=either`,{headers: {Accept: "application/vnd.api+json",Authorization: `Bearer ${access_token}`,},});Terminal window curl -g -s "$DRUPAL_SITE_URL/api/node/article\?filter[status]=1\&filter[recent][condition][path]=created\&filter[recent][condition][operator]=%3E%3D\&filter[recent][condition][value]=1767225600" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"(
>=is URL-encoded as%3E%3D.)Terminal window curl -g -s "$DRUPAL_SITE_URL/api/node/article\?filter[either][group][conjunction]=OR\&filter[in-title][condition][path]=title\&filter[in-title][condition][operator]=CONTAINS\&filter[in-title][condition][value]=launch\&filter[in-title][condition][memberOf]=either\&filter[in-body][condition][path]=body.value\&filter[in-body][condition][operator]=CONTAINS\&filter[in-body][condition][value]=launch\&filter[in-body][condition][memberOf]=either" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"Date fields compare as UNIX timestamps, not the ISO 8601 strings responses display. An ISO date here silently compares against the wrong number and mis-filters. Convert first:
date -d 2026-01-01 +%s(GNU) ordate -j -f %Y-%m-%d 2026-01-01 +%s(macOS); details in the query parameter reference. -
Request only the fields you need
Section titled “Request only the fields you need”By default every field comes back. Sparse fieldsets trim
attributesto what you list; the key is the resource type from the response’stypemember (node--article, double dash), not the URL path:const params = new DrupalJsonApiParams().addFields("node--article", ["title", "created"]);const trimmed = await client.getCollection("node--article",{ queryString: params.getQueryString() },);const trimmed = await fetch(`${DRUPAL_SITE_URL}/api/node/article` +`?fields[node--article]=title,created`,{headers: {Accept: "application/vnd.api+json",Authorization: `Bearer ${access_token}`,},});Terminal window curl -g -s "$DRUPAL_SITE_URL/api/node/article\?fields[node--article]=title,created" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"{"data": [{"type": "node--article","id": "3f9a2b1e-6c4d-4a8e-9b7f-1d2e3c4b5a69","attributes": {"title": "Spring launch recap","created": "2026-05-11T09:30:52+00:00"}}]}A misspelled field name here is silently ignored: no error, the field never appears (see troubleshooting). Field machine names are listed on the Query Builder’s
Fieldstab. -
Fetch related entries, without N+1
Section titled “Fetch related entries, without N+1”Entries point at related entries (media, taxonomy terms) under
relationships. The examples use an image relationship namedimage; that name is site-specific, so read yours offrelationshipson any entry in your own responses (a classic Drupal image field isfield_image). The tempting-but-wrong way to get them is one extra request per entry:// WRONG: one request for the list…const list = await jsonapiGet('/api/node/article?filter[status]=1',);// …then one more request PER ENTRY for its image.// 20 articles = 21 requests. This is the N+1 pattern.for (const article of list.data) {const url =article.relationships.image.links.related.href;article.image = await jsonapiGet(url);}The right way is
include, which returns the related entries in the same response:const params = new DrupalJsonApiParams().addFilter("status", "1").addInclude(["image"]);const withRelated = await client.getCollection("node--article",{ queryString: params.getQueryString() },);const withRelated = await fetch(`${DRUPAL_SITE_URL}/api/node/article` +`?filter[status]=1&include=image`,{headers: {Accept: "application/vnd.api+json",Authorization: `Bearer ${access_token}`,},});Terminal window curl -g -s "$DRUPAL_SITE_URL/api/node/article\?filter[status]=1&include=image" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"One request. The related media entries arrive in a top-level
includedarray, and each entry’srelationships.image.data.idtells you which one belongs to it:{"data": [{"type": "node--article","id": "3f9a2b1e-6c4d-4a8e-9b7f-1d2e3c4b5a69","attributes": {"title": "Spring launch recap","…": "…"},"relationships": {"image": {"data": {"type": "media--image","id": "b7c1d9e2-0f3a-4c5b-8d6e-7a8b9c0d1e2f"}}}}],"included": [{"type": "media--image","id": "b7c1d9e2-0f3a-4c5b-8d6e-7a8b9c0d1e2f","attributes": {"name": "spring-launch-hero.jpg","…": "…"}}]}Joining them in code is a map lookup, not a request:
// JSON:API Client: const { data, included } = withRelated;const { data, included } = await withRelated.json();const byId = new Map(included.map((r) => [r.id, r]),);const withImages = data.map((article) => ({...article,image: byId.get(article.relationships.image.data?.id),}));The
?.matters: an entry whose image field is empty hasdata: null, and without it the join throwsCannot read properties of null (reading 'id')on the first real mixed collection.When you combine
includewith sparse fieldsets, keep the relationship field in the list (fields[node--article]=title,image), or the entry loses the pointer you need for the lookup. You can trim included entries too:fields[media--image]=name. -
Paginate
Section titled “Paginate”page[limit]caps how many entries return;page[offset]skips that many from the start:const params = new DrupalJsonApiParams().addPageLimit(10).addPageOffset(10);const page2 = await client.getCollection("node--article",{ queryString: params.getQueryString() },);const page2 = await fetch(`${DRUPAL_SITE_URL}/api/node/article` +`?page[limit]=10&page[offset]=10`,{headers: {Accept: "application/vnd.api+json",Authorization: `Bearer ${access_token}`,},});Terminal window curl -g -s "$DRUPAL_SITE_URL/api/node/article\?page[limit]=10&page[offset]=10" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"That returns entries 11–20. You rarely need to compute offsets yourself: whenever more entries remain, the response carries a ready-made
links.nextURL and the collection’s total inmeta.count. Withpage[limit]=2&sort=-createdon a five-entry type:{"data": [{ "type": "node--article", "…": "…" },{ "type": "node--article", "…": "…" }],"meta": { "count": 5 },"links": {"self": {"href": "https://your-site.example.com/api/node/article?page%5Blimit%5D=2&sort=-created"},"next": {"href": "https://your-site.example.com/api/node/article?page%5Boffset%5D=2&page%5Blimit%5D=2&sort=-created"}}}Follow
links.nextuntil it disappears; it adds thepage[offset]for you and preserves every other parameter (with the square brackets percent-encoded).One caveat: pagination links are computed before access filtering, so a page can come back as
"data": []and still carrylinks.nextandlinks.last(for example, when every entry on that page is unpublished). Don’t treat an empty page as the end of the collection: keep followinglinks.next, and checkmeta.countandmeta.omitted(see troubleshooting). -
sorttakes a field machine name; prefix-for descending, and comma-separate multiple keys:// each addSort call adds a key; direction is// the optional 2nd argumentconst params = new DrupalJsonApiParams().addSort("created", "DESC").addSort("title");const newest = await client.getCollection("node--article",{// sort=-created,titlequeryString: params.getQueryString(),},);const newest = await fetch(`${DRUPAL_SITE_URL}/api/node/article` +`?sort=-created,title`,{headers: {Accept: "application/vnd.api+json",Authorization: `Bearer ${access_token}`,},});Terminal window curl -g -s "$DRUPAL_SITE_URL/api/node/article\?sort=-created,title" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"Newest first, ties broken alphabetically by title.
-
Compose it: a listing page’s exact query
Section titled “Compose it: a listing page’s exact query”A blog index needs the 10 newest published articles, with only their title, date, and image. Every technique above, one request:
const params = new DrupalJsonApiParams().addFilter("status", "1").addFields("node--article", ["title","created","image",]).addFields("media--image", ["name"]).addInclude(["image"]).addSort("created", "DESC").addPageLimit(10);const { data, included } = await client.getCollection("node--article",{ queryString: params.getQueryString() },);const QUERY ="/api/node/article" +"?filter[status]=1" +"&fields[node--article]=title,created,image" +"&fields[media--image]=name" +"&include=image" +"&sort=-created" +"&page[limit]=10";const response = await fetch(`${DRUPAL_SITE_URL}${QUERY}`,{headers: {Accept: "application/vnd.api+json",Authorization: `Bearer ${access_token}`,},});const { data, included } = await response.json();Terminal window curl -g -s "$DRUPAL_SITE_URL/api/node/article\?filter[status]=1\&fields[node--article]=title,created,image\&fields[media--image]=name\&include=image\&sort=-created\&page[limit]=10" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"Each part, and where it’s documented in full:
filter[status]=1: published entries only (filter reference)fields[node--article]=title,created,image: trim articles to three fields, keeping the image pointer (sparse fieldsets reference)fields[media--image]=name: trim the included media too (sparse fieldsets reference)include=image: related media in the same response (include reference)sort=-created: newest first (sort reference)page[limit]=10: ten entries,links.nextfor the rest (pagination reference)
The query string is canonical; the same parameters work from any HTTP client. In a frontend, the Drupal API Client lives in one shared module (as in First fetch), and only the idiom around the call differs:
// app/blog/page.tsx: server component// client is the configured JsonApiClient; Article is the// type the shared module exportsimport { client, type Article } from "@/lib/acquia";import {DrupalJsonApiParams,} from "drupal-jsonapi-params";type ArticleList = {data: Article[];included?: unknown[];};export const revalidate = 60;const queryString = new DrupalJsonApiParams().addFilter("status", "1").addFields("node--article", ["title","created","image",]).addFields("media--image", ["name"]).addInclude(["image"]).addSort("created", "DESC").addPageLimit(10).getQueryString();export default async function BlogIndex() {const { data, included } = (await client.getCollection<ArticleList>("node--article", { queryString })) as ArticleList;// Render data, joining included via the Map// pattern from step 4.}---// src/pages/blog/index.astro: runs at build// (or on the server in SSR)import { client } from "../../lib/acquia";// the configured JsonApiClientimport {DrupalJsonApiParams,} from "drupal-jsonapi-params";const queryString = new DrupalJsonApiParams().addFilter("status", "1").addFields("node--article", ["title","created","image",]).addFields("media--image", ["name"]).addInclude(["image"]).addSort("created", "DESC").addPageLimit(10).getQueryString();const { data, included } = await client.getCollection("node--article",{ queryString },);---<!-- …render data, joining included via the Mappattern from step 4 --><script setup>// app/pages/blog/index.vue: runs on the server firstimport { client } from "~/lib/acquia";// the configured JsonApiClientimport {DrupalJsonApiParams,} from "drupal-jsonapi-params";const queryString = new DrupalJsonApiParams().addFilter("status", "1").addFields("node--article", ["title","created","image",]).addFields("media--image", ["name"]).addInclude(["image"]).addSort("created", "DESC").addPageLimit(10).getQueryString();const { data: response } = await useAsyncData("blog-index",() =>client.getCollection("node--article",{ queryString },),);// …render response.value.data, joining// response.value.included</script>The query and the fetch live in the server-only module (First fetch sets that up); the route’s loader reaches them through its server function. That is also why the function hands back entries rather than the whole document: a server function’s return value is type-checked for serializability, and the
unknown[]onincludeddoes not pass, so the join happens here rather than in the route.// src/lib/blog.server.ts: server-only, because a// route loader is isomorphic// client is the configured JsonApiClient; Article is the// type the shared module exportsimport { client, type Article } from "./acquia.server";import {DrupalJsonApiParams,} from "drupal-jsonapi-params";type ArticleList = {data: Article[];included?: unknown[];};const queryString = new DrupalJsonApiParams().addFilter("status", "1").addFields("node--article", ["title","created","image",]).addFields("media--image", ["name"]).addInclude(["image"]).addSort("created", "DESC").addPageLimit(10).getQueryString();export async function getBlogIndex() {const doc = (await client.getCollection<ArticleList>("node--article",{ queryString },)) as ArticleList;// Join doc.included into these entries via the Map// pattern from step 4.return doc.data;}
What just happened
Section titled “What just happened”Everything here is a query parameter on a GET: reproducible with any HTTP client, cacheable, and shareable as a URL. Your site’s JSON:API Query Builder (API > JSON:API Query Builder) builds these same parameters visually, and its Filters, Fields, Includes, and Sort tabs map one-to-one onto steps 1–6. It also previews the live response, which makes it the fastest way to debug a query against your real content.
When something goes wrong
Section titled “When something goes wrong”The failure modes look similar but tell you different things. An empty data with a 200 means your filter worked and matched nothing, unless the response also carries meta.omitted, which means matching entries were hidden by access. A wrong field name never produces an empty list: in a filter it’s a 400, in fields[…] it’s silently missing attributes.
"data": [] with 200 OK
The filter matches nothing: the syntax is valid, and no entry satisfies the conditions. Exact-match shorthand (filter[title]=…) requires the exact stored value. Fix: remove conditions one at a time until entries return (the last one removed is the culprit), then check the real stored values on the Query Builder’s Response panel. (First check for a meta.omitted block: if it’s there, the entries exist and you’re not allowed to see them; see the next entry.)
data shorter than meta.count, or empty with a meta.omitted block
Access hid entries; nothing is missing. meta.count is the collection total including entries you cannot see. When some are omitted, meta.omitted.detail reads "Some resources have been omitted because of insufficient authorization.", and each omitted entry appears as a link under meta.omitted.links whose own meta.detail names the reason. For unpublished entries: "The current user is not allowed to GET the selected resource. The 'view any unpublished content' permission is required." The typical cause is unpublished content: entries created via the API are unpublished by default, and viewing unpublished content is a permission API tokens don’t normally carry. Fix: publish the entries. See the writing content guide.
Attributes missing from entries (no error)
a misspelled field in fields[node--article]=… is silently ignored, so the field never appears; and if the type key is wrong (fields[node-article], single dash), the whole sparse fieldset is ignored and everything comes back. Fix: copy field machine names from the Query Builder’s Fields tab, and use the type value exactly as the response spells it (node--article).
400 Bad Request from a wrong field name in a filter
filtering on a field that doesn’t exist fails loudly:
{ "jsonapi": { "version": "1.1", "meta": { "links": { "self": { "href": "http://jsonapi.org/format/1.1/" } } } }, "errors": [ { "title": "Bad Request", "status": "400", "detail": "Invalid nested filtering. The field `titel`, given in the path `titel`, does not exist.", "links": { "via": { "href": "https://your-site.example.com/api/node/article?filter[titel]=launch" }, "info": { "href": "http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1" } } } ]}The detail names the offending path. Fix the field machine name (Query Builder Fields tab shows them all).
400 Bad Request from an invalid operator
a bad [condition][operator] value returns the same error shape; the detail names the rejected operator but does not list the accepted ones:
"detail": "The 'LIKE' operator is not allowed in a filter parameter."The accepted operators are documented, each with an example, in the query parameter reference.
No error, but the site is slow (N+1 symptoms)
there is no error message for the N+1 pattern. You recognize it by its shape: listing-page latency grows linearly with the number of entries shown; your server logs or browser network tab show a burst of sequential /api/... requests (one per entry) right after the list request; pages that were fine with 5 test entries time out with 50 real ones. Fix: replace the per-entry loop with a single include request (step 4).
A query that returned "data": [] before you published keeps returning it after
anonymous (public-access) responses are CDN-cached per exact URL (verified: cache-control: … s-maxage=31536000 with x-cache: HIT), and a cached empty result can outlive the publish that should have filled it. Requests with an Authorization header aren’t served from that cache. Check: rerun the query with any harmless variation (a different filter label works; an unknown parameter does not, the API rejects it with 400: The following query parameters violate the JSON:API spec: 'cb'.). If the varied URL returns your content, you were looking at a stale cached response, not missing data.
Next steps
Section titled “Next steps”- Query parameter reference: every operator and parameter, with syntax and defaults.
- Multilingual content: request a language with
langCode, detect fallback deliberately. - Writing content via the API: POST, PATCH, and DELETE with
content:administer. - Fetch content directly: where queries like the worked example live in a real app, and how long their results are cached.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)