Skip to content

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.

  • Working filter syntax: field values, operators, and AND/OR combinations.
  • Responses trimmed to the fields you use (fields[type]) with related entries pulled in by include: 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.
  • 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 article type; substitute yours. Each builds on the quickstart’s setup:
    • Drupal API Client examples assume its configured client and import { DrupalJsonApiParams } from "drupal-jsonapi-params".
    • JavaScript examples assume DRUPAL_SITE_URL and a fresh access_token.
    • curl examples use -g (square brackets pass through literally) and assume $DRUPAL_SITE_URL and $TOKEN are set.
  1. The shorthand form matches a field exactly (filter[<field>]=<value> in the URL); anything other than exact equality is a full condition: a path (the field machine name), an operator, and a value:

    addFilter takes the path, the value, and optionally the operator:

    // exact match
    const queryString = new DrupalJsonApiParams()
    .addFilter("status", "1")
    .getQueryString();
    const published = await client.getCollection(
    "node--article",
    { queryString },
    );
    // full condition: "launch" anywhere in the title
    const params = new DrupalJsonApiParams()
    .addFilter("title", "launch", "CONTAINS");
    const matches = await client.getCollection(
    "node--article",
    { queryString: params.getQueryString() },
    );

    Besides = and CONTAINS, operators include <>, >, >=, <, <=, STARTS_WITH, ENDS_WITH, IN, NOT IN, BETWEEN, IS NULL, and IS NOT NULL; the query parameter reference documents each with an example.

  2. Multiple filters AND together. For OR, declare a group with a conjunction and attach conditions to it with memberOf. Two examples: published articles created since January 1, 2026 (1767225600 as a UNIX timestamp), then “launch” in the title or the body:

    // AND: every addFilter call adds a condition
    const recent = new DrupalJsonApiParams()
    .addFilter("status", "1")
    .addFilter("created", "1767225600", ">=");
    // OR: declare the group, then pass its name
    // as addFilter's 4th argument
    const 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() },
    );

    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) or date -j -f %Y-%m-%d 2026-01-01 +%s (macOS); details in the query parameter reference.

  3. By default every field comes back. Sparse fieldsets trim attributes to what you list; the key is the resource type from the response’s type member (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() },
    );
    {
    "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 Fields tab.

  4. Entries point at related entries (media, taxonomy terms) under relationships. The examples use an image relationship named image; that name is site-specific, so read yours off relationships on any entry in your own responses (a classic Drupal image field is field_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() },
    );

    One request. The related media entries arrive in a top-level included array, and each entry’s relationships.image.data.id tells 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 has data: null, and without it the join throws Cannot read properties of null (reading 'id') on the first real mixed collection.

    When you combine include with 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.

  5. 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() },
    );

    That returns entries 11–20. You rarely need to compute offsets yourself: whenever more entries remain, the response carries a ready-made links.next URL and the collection’s total in meta.count. With page[limit]=2&sort=-created on 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.next until it disappears; it adds the page[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 carry links.next and links.last (for example, when every entry on that page is unpublished). Don’t treat an empty page as the end of the collection: keep following links.next, and check meta.count and meta.omitted (see troubleshooting).

  6. sort takes a field machine name; prefix - for descending, and comma-separate multiple keys:

    // each addSort call adds a key; direction is
    // the optional 2nd argument
    const params = new DrupalJsonApiParams()
    .addSort("created", "DESC")
    .addSort("title");
    const newest = await client.getCollection(
    "node--article",
    {
    // sort=-created,title
    queryString: params.getQueryString(),
    },
    );

    Newest first, ties broken alphabetically by title.

  7. 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() },
    );

    Each part, and where it’s documented in full:

    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 exports
    import { 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.
    }

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.

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.

Was this page helpful?