# Query by type, field, and relationship

**Goal:** compose a [JSON:API](/start-here/glossary/#jsonapi) query that returns exactly the entries and [fields](/start-here/glossary/#field) 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](/start-here/choose-your-backend/#headless-on-cloud-platform)). Swap the base path (the `apiPrefix` option on the [Drupal API Client](/start-here/glossary/#drupal-api-client)) and every example holds.

## 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 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.

## Prerequisites

- A working credential from the [auth quickstart](/source-cms/authenticate/quickstart/).
- Your [content type](/start-here/glossary/#content-type-bundle)'s [machine name](/start-here/glossary/#machine-name) and one successful list request from the [query quickstart](/source-cms/content-api/quickstart/).
- Examples use the `article` type; substitute yours. Each builds on the quickstart's setup:
  - [Drupal API Client](/start-here/glossary/#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.

## Steps

<Steps>

1. ### 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: a `path` (the field machine name), an `operator`, and a `value`:

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       `addFilter` takes the path, the value, and optionally the operator:

       ```js
       // 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() },
       );
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       // exact match
       const 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}`,
           },
         }
       );
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       # exact match
       curl -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:

       ```bash
       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"
       ```
     </TabItem>
   </Tabs>

   Besides `=` and `CONTAINS`, operators include `<>`, `>`, `>=`, `<`, `<=`, `STARTS_WITH`, `ENDS_WITH`, `IN`, `NOT IN`, `BETWEEN`, `IS NULL`, and `IS NOT NULL`; the [query parameter reference](/source-cms/reference/query-parameters/) documents each with an example.

2. ### Combine conditions

   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:

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       ```js
       // 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() },
       );
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       // 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 memberOf
       const 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}`,
           },
         }
       );
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       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`.)

       ```bash
       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"
       ```
     </TabItem>
   </Tabs>

   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](/source-cms/reference/query-parameters/#operators).

3. ### Request only the fields you need

   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:

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       ```js
       const params = new DrupalJsonApiParams()
         .addFields("node--article", ["title", "created"]);
       const trimmed = await client.getCollection(
         "node--article",
         { queryString: params.getQueryString() },
       );
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       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}`,
           },
         }
       );
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       curl -g -s "$DRUPAL_SITE_URL/api/node/article\
       ?fields[node--article]=title,created" \
         -H "Accept: application/vnd.api+json" \
         -H "Authorization: Bearer $TOKEN"
       ```
     </TabItem>
   </Tabs>

   ```json
   {
     "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](#when-something-goes-wrong)). Field machine names are listed on the Query Builder's `Fields` tab.

4. ### Fetch related entries, without N+1

   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:

   ```js
   // 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:

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       ```js
       const params = new DrupalJsonApiParams()
         .addFilter("status", "1")
         .addInclude(["image"]);
       const withRelated = await client.getCollection(
         "node--article",
         { queryString: params.getQueryString() },
       );
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       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}`,
           },
         }
       );
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       curl -g -s "$DRUPAL_SITE_URL/api/node/article\
       ?filter[status]=1&include=image" \
         -H "Accept: application/vnd.api+json" \
         -H "Authorization: Bearer $TOKEN"
       ```
     </TabItem>
   </Tabs>

   One request. The related [media](/start-here/glossary/#media-type) entries arrive in a top-level `included` array, and each entry's `relationships.image.data.id` tells you which one belongs to it:

   ```json
   {
     "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:

   ```js
   // 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. ### Paginate

   `page[limit]` caps how many entries return; `page[offset]` skips that many from the start:

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       ```js
       const params = new DrupalJsonApiParams()
         .addPageLimit(10)
         .addPageOffset(10);
       const page2 = await client.getCollection(
         "node--article",
         { queryString: params.getQueryString() },
       );
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       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}`,
           },
         }
       );
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       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"
       ```
     </TabItem>
   </Tabs>

   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:

   ```json
   {
     "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](#when-something-goes-wrong)).

6. ### Sort

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

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       ```js
       // 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(),
         },
       );
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       const newest = await fetch(
         `${DRUPAL_SITE_URL}/api/node/article` +
           `?sort=-created,title`,
         {
           headers: {
             Accept: "application/vnd.api+json",
             Authorization: `Bearer ${access_token}`,
           },
         }
       );
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       curl -g -s "$DRUPAL_SITE_URL/api/node/article\
       ?sort=-created,title" \
         -H "Accept: application/vnd.api+json" \
         -H "Authorization: Bearer $TOKEN"
       ```
     </TabItem>
   </Tabs>

   Newest first, ties broken alphabetically by title.

7. ### 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:

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       ```js
       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() },
       );
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       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();
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       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"
       ```
     </TabItem>
   </Tabs>

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

   - `filter[status]=1`: published entries only ([filter reference](/source-cms/reference/query-parameters/))
   - `fields[node--article]=title,created,image`: trim articles to three fields, keeping the image pointer ([sparse fieldsets reference](/source-cms/reference/query-parameters/))
   - `fields[media--image]=name`: trim the included media too ([sparse fieldsets reference](/source-cms/reference/query-parameters/))
   - `include=image`: related media in the same response ([include reference](/source-cms/reference/query-parameters/))
   - `sort=-created`: newest first ([sort reference](/source-cms/reference/query-parameters/))
   - `page[limit]=10`: ten entries, `links.next` for the rest ([pagination reference](/source-cms/reference/query-parameters/))

   The query string is canonical; the same parameters work from any HTTP client. In a frontend, the [Drupal API Client](/start-here/glossary/#drupal-api-client) lives in one shared module (as in [First fetch](/source-cms/content-api/first-fetch/)), and only the idiom around the call differs:

   <Tabs syncKey="framework">
     <TabItem label="Next.js">
       ```tsx
       // 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.
       }
       ```
     </TabItem>
     <TabItem label="Astro">
       ```astro
       ---
       // src/pages/blog/index.astro: runs at build
       // (or on the server in SSR)
       import { client } from "../../lib/acquia";
       // the configured JsonApiClient
       import {
         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 Map
            pattern from step 4 -->
       ```
     </TabItem>
     <TabItem label="Nuxt">
       ```vue
       <script setup>
       // app/pages/blog/index.vue: runs on the server first
       import { client } from "~/lib/acquia";
       // the configured JsonApiClient
       import {
         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>
       ```
     </TabItem>
     <TabItem label="TanStack Start">
       The query and the fetch live in the server-only
       module ([First fetch](/source-cms/content-api/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[]` on `included` does not pass, so the
       join happens here rather than in the route.

       ```ts
       // 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 exports
       import { 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;
       }
       ```
     </TabItem>
   </Tabs>

</Steps>

## 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](/start-here/glossary/#site)'s [JSON:API Query Builder](/start-here/glossary/#jsonapi-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

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](/source-cms/content-api/writing-content/).

**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:

```json
{
  "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](/source-cms/reference/query-parameters/).

**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

- [Query parameter reference](/source-cms/reference/query-parameters/): every operator and parameter, with syntax and defaults.
- [Multilingual content](/source-cms/content-api/multilingual/): request a language with `langCode`, detect fallback deliberately.
- [Writing content via the API](/source-cms/content-api/writing-content/): POST, PATCH, and DELETE with `content:administer`.
- [Fetch content directly](/source-cms/content-api/fetch-content/): where queries like the worked example live in a real app, and how long their results are cached.
