# Querying Content

> Query, order, paginate, and render EmDash content in server-rendered Astro pages.

Source: https://emdashcmseverything.com/official-docs/guides/querying-content/

EmDash pages read content at request time with `getEmDashCollection()` and `getEmDashEntry()`.
The first function returns a list, and the second returns one entry by its slug or content ID. Both
return errors as data so the page can decide how to respond.

The bundled templates use Astro's server output. A visitor therefore receives the latest published
revision on the next render after an editor publishes it. A saved draft remains private until it is
published or requested through a valid preview URL.

## Query a collection

The following page loads the seven most recently published posts. Public collection queries default
to `status: "published"`, so the filter does not need to repeat it.

```astro title="src/pages/posts/index.astro"
---
import { getEmDashCollection } from "emdash";

const { entries: posts, error, cacheHint } = await getEmDashCollection("posts", {
  orderBy: { published_at: "desc" },
  limit: 7,
});

if (error) {
  console.error("Failed to load posts:", error);
  return new Response("Unable to load posts", { status: 500 });
}

if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

<h1>Posts</h1>
<ul>
  {posts.map((post) => (
    <li>
      <a href={`/posts/${post.id}`}>{post.data.title}</a>
    </li>
  ))}
</ul>
```

Ordering and limiting happen in the database before EmDash hydrates the entries. This avoids
loading an entire collection and sorting or slicing it in the page.

The result contains:

- `entries`, which is an empty array when there are no matches or the query failed;
- `error`, which is set for a failed query but not for an empty result;
- `cacheHint`, which carries the purge tags for Astro's cache;
- `nextCursor`, which is set when a limited cursor page has more entries; and
- `hasMore`, which reports whether a limited cursor or offset page has another page.

## Entry identifiers

Each result has two identifiers with different jobs:

- `entry.id` is the URL-facing identifier produced by the content loader. It is normally the slug.
  When internationalization prefixes a locale, the prefix is included. Use this value when you build
  a link from a collection result.
- `entry.data.id` is the stable content ID stored in the database. It does not change when an editor
  changes the slug. Use it when an API, taxonomy helper, page context, or relation expects a content
  ID.

`getEmDashEntry()` accepts either the slug or the stable content ID. A slug lookup is scoped to a
locale when internationalization is enabled; a content ID identifies the row directly.

> **Caution**
>
> Do not build links from `entry.data.slug` when `entry.id` is available. `entry.id` includes the
> locale prefix required by the active URL configuration.

## Filter a collection

Pass filters in the second argument. The following query returns published posts in the `news`
category whose `series` field is `engineering`:

```ts
const { entries } = await getEmDashCollection("posts", {
  where: {
    category: "news",
    series: "engineering",
  },
});
```

Keys that name a taxonomy match assigned term slugs. Other keys match collection fields. Multiple
keys are combined with AND logic. An array on one key matches any listed value, so
`{ category: ["news", "updates"] }` matches either category.

Use `locale` to request one language explicitly:

```ts
const { entries: frenchPosts } = await getEmDashCollection("posts", {
  locale: "fr",
  orderBy: { published_at: "desc" },
});
```

If `locale` is omitted, EmDash uses the request locale and then the configured default locale. See
[Internationalization](https://emdashcmseverything.com/official-docs/guides/internationalization/#querying-translated-content) for fallback rules
and translated routes.

`status` accepts `"published"`, `"draft"`, or `"archived"`. Do not request drafts from a public
route. Use the preview flow when a visitor needs temporary access to one unpublished entry.

## Order results in the database

`orderBy` maps field names to `"asc"` or `"desc"`. Use stored field names, not the camel-cased names
returned in `entry.data`:

```ts
const { entries } = await getEmDashCollection("posts", {
  orderBy: {
    published_at: "desc",
    title: "asc",
  },
});
```

System columns use their database names, such as `created_at`, `updated_at`, and `published_at`.
Custom fields use their collection slug, such as `title` or `priority`. The returned data maps system
dates to `createdAt`, `updatedAt`, and `publishedAt`, but those camel-cased property names are not
valid `orderBy` fields.

EmDash uses the first valid `orderBy` field as the pagination key and the content ID as a stable
tie-breaker. Without `orderBy`, collections default to `created_at` descending. Mark custom scalar
fields as indexed when the site regularly sorts or filters by them; the index avoids a full table
scan as the collection grows.

## Paginate a collection

Use a cursor for a continuing feed or an offset for numbered pages. They are separate pagination
models and cannot be combined in one typed query.

### Cursor pagination

Cursor pagination continues after the last entry returned by the previous query. Keep the sort
unchanged between requests and pass `nextCursor` back without inspecting or changing it.

The following route renders an **Older posts** link when another page exists:

```astro title="src/pages/posts/index.astro"
---
import { getEmDashCollection } from "emdash";

const cursor = Astro.url.searchParams.get("cursor") ?? undefined;
const { entries: posts, nextCursor, error } = await getEmDashCollection("posts", {
  limit: 10,
  cursor,
  orderBy: { published_at: "desc" },
});

if (error) return new Response("Unable to load posts", { status: 500 });
---

<ul>
  {posts.map((post) => <li><a href={`/posts/${post.id}`}>{post.data.title}</a></li>)}
</ul>

{nextCursor && (
  <a href={`/posts?cursor=${encodeURIComponent(nextCursor)}`}>Older posts</a>
)}
```

`nextCursor` is absent on the final page. Cursor pagination does not calculate a total page count or
provide a previous-page cursor; preserve earlier URLs in the browser history if the interface needs
back navigation.

### Offset pagination

Offset pagination suits routes such as `/posts/page/3`. Convert the page number to an offset and use
`hasMore` for the next-page link:

```astro title="src/pages/posts/page/[page].astro"
---
import { getEmDashCollection } from "emdash";

const parsedPage = Number(Astro.params.page ?? "1");
if (!Number.isInteger(parsedPage) || parsedPage < 1) {
  return Astro.rewrite("/404");
}

const perPage = 10;
const { entries: posts, hasMore, error } = await getEmDashCollection("posts", {
  limit: perPage,
  offset: (parsedPage - 1) * perPage,
  orderBy: { published_at: "desc" },
});

if (error) return new Response("Unable to load posts", { status: 500 });
---

<ul>
  {posts.map((post) => <li><a href={`/posts/${post.id}`}>{post.data.title}</a></li>)}
</ul>

<nav aria-label="Post pages">
  {parsedPage > 1 && <a href={`/posts/page/${parsedPage - 1}`}>Newer posts</a>}
  {hasMore && <a href={`/posts/page/${parsedPage + 1}`}>Older posts</a>}
</nav>
```

An offset must be a non-negative integer. Page 1 uses an offset of zero, which means
"start at the first entry." Offset pagination is easy to address by page number, but entries added
between requests can shift later pages. Use cursors when that movement would be confusing.

## Query and render one entry

The following runtime route reads a post by slug, renders its featured image and Portable Text body,
and distinguishes a query failure from a missing entry:

```astro title="src/pages/posts/[slug].astro"
---
import { decodeSlug, getEmDashEntry } from "emdash";
import { Image, PortableText } from "emdash/ui";

const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.rewrite("/404");

const { entry: post, error, isPreview, cacheHint } = await getEmDashEntry("posts", slug);

if (error) {
  console.error("Failed to load post:", error);
  return new Response("Unable to load post", { status: 500 });
}

if (!post) return Astro.rewrite("/404");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

{isPreview && <p>This is an unpublished preview.</p>}

<article>
  {post.data.featured_image && <Image image={post.data.featured_image} priority />}
  <h1>{post.data.title}</h1>
  <PortableText value={post.data.content} />
</article>
```

`PortableText` supplies renderers for EmDash's standard blocks, including images, videos,
galleries, code, tables, HTML blocks and iframe blocks. Pass custom components when a site adds its
own Portable Text blocks.

An HTML block with `isolated: true` renders its `html`, `css` and `js` in a sandboxed iframe that
sizes itself to its content. Other HTML blocks render their `html` in the page, sanitized. If you
replace the `htmlBlock` renderer, pass isolated blocks to `HtmlBlock` from `emdash/ui` or render
them in an iframe whose `sandbox` omits `allow-same-origin`. Sanitize inline HTML yourself and
allow only the iframe hosts the site intends to trust.

An iframe block renders through `Iframe` from `emdash/ui` as a responsive, lazy-loading iframe with a
`sandbox` and a `strict-origin-when-cross-origin` referrer policy. Pages on your site's own host
lose same-origin access, and only https sources render.

A video block renders through `Video` from `emdash/ui`. A Media Library video plays in the browser's
own player, with controls and its caption, from your storage's public URL when one is configured.
Without a public URL, videos are served through `/_emdash/api/media/file/`. A video from a media
provider renders through that provider. An empty video block, which has no `asset`, renders nothing.

With [Astro's content security policy](https://docs.astro.build/en/reference/configuration-reference/#securitycsp)
turned on, the browser blocks the styles and scripts inside isolated HTML blocks, so they render
without their CSS, JavaScript or automatic height. Iframe blocks also lose their custom size, and
the policy's `frame-src` must allow the hosts they embed. Video blocks whose media is on another
host, such as a storage public URL or a media provider, need that host in `media-src`.

`PortableText` shows tables as read-only placeholders in edit mode. To localize their initial label, pass `tablePlaceholder={translatedLabel}`. It defaults to `"Table (edit in admin)"` and does not affect published table content.

## Read reference fields

A [`reference` field](https://emdashcmseverything.com/official-docs/reference/field-types/#reference) links an entry to entries in another collection, and [Relations](https://emdashcmseverything.com/official-docs/guides/relations/) covers defining one. Its value is not part of `data`. Pass a `references` option to `getEmDashEntry` naming the fields the page renders, keyed by field slug, and each one comes back as a page of entries:

```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashEntry } from "emdash";

const { entry: post } = await getEmDashEntry("posts", Astro.params.slug, {
  references: { author: true, related_posts: { limit: 6 } },
});

if (!post) return Astro.rewrite("/404");

const author = post.references?.author.entries[0];
---

<article>
  <h1>{post.data.title}</h1>
  {author && <p>By {author.data.name}</p>}
  <ul>
    {post.references?.related_posts.entries.map((related) => (
      <li><a href={`/posts/${related.data.slug}`}>{related.data.title}</a></li>
    ))}
  </ul>
</article>
```

`true` requests the first page at the default limit of 50 entries. Use `{ limit, cursor }` for a field that holds more, up to 100 per page.

Each referenced entry is a `ContentEntry` with the same shape as one loaded directly: an `id`, a `data` object with dates as `Date` objects and media values resolved, and an `edit` proxy scoped to the referenced entry, so clicking a card in visual editing opens the entry the card is about. Bylines and taxonomy terms are the exception — EmDash does not hydrate them onto referenced entries, so read `data.bylines` and `data.terms` from the entry itself.

Entries arrive in the order the editor arranged them when the field sits on the parent end of its relation. A field on the child end lists the entries pointing at it, which have no order of their own.

The option is opt-in in both directions. A call that passes no `references` runs no extra queries, and a field left out of the selection is not read. A call that selects fields costs one link query per field, plus one entry query per distinct target collection however many entries each field holds.

### Paginate a reference field

`getEmDashReferences` fetches the next page of a single field, using the cursor the previous page returned:

```ts
import { getEmDashReferences } from "emdash";

const { entries, nextCursor } = await getEmDashReferences("posts", post.id, "related_posts", {
	cursor,
	limit: 20,
});
```

It reads draft visibility from the same request context as `getEmDashEntry`, so a walk started in preview keeps seeing the pending selection. A page id carries its locale where i18n prefixes one (`fr/about`), and that is the id to pass back in.

On a route that caches its output, merge the `cacheHint` the page returns into the route's own, so a write to one of the entries it named expires the page.

### Reference fields in preview

A public render sees published entries and the published selection. A preview of the entry, or an editor in visual editing, sees unpublished entries and the selection staged in the entry's draft, so a preview link shows the references the page will have once it is published.

## Apply SEO and editing features

For a collection with SEO support, `getSeoMeta()` resolves the editor's SEO title, description,
image, canonical URL, and no-index choice with fallbacks from the entry. The current blog template
passes that result to its base layout:

```astro
---
import { getSeoMeta } from "emdash";

const seo = getSeoMeta(post, {
  siteTitle: "My Blog",
  siteUrl: Astro.url.origin,
  path: Astro.url.pathname,
});
---

<Base
  title={seo.title}
  pageTitle={seo.ogTitle}
  description={seo.description}
  image={seo.ogImage}
  canonical={seo.canonical}
  robots={seo.robots}
>
  <!-- Post content -->
</Base>
```

A layout that includes `<EmDashHead>` can apply the same SEO fields and plugin contributions to
server-rendered content pages. Hand-written meta tags that read only `data.title` or `data.excerpt`
do not apply the editor's canonical URL or no-index setting.

Preview URLs need no separate query. The middleware validates the `_preview` token, and
`getEmDashEntry()` returns the matching draft with `isPreview: true`. The
[Preview and visual editing guide](https://emdashcmseverything.com/official-docs/guides/preview/) explains URL generation and the `entry.edit`
annotations used for inline editing.

## Generate TypeScript types

The dev server generates `emdash-env.d.ts` from the active schema. Keep that file included in the
project's TypeScript configuration so a collection name such as `"posts"` selects the generated
`Post` data type automatically.

For a remote EmDash instance, the CLI can fetch the schema and write types to `.emdash/types.ts`:

```bash
npx emdash types --url https://cms.example.com
```

The command accepts an API token or custom authentication headers. See
[Generate types](https://emdashcmseverything.com/official-docs/reference/cli/#emdash-types) for those options.

Each collection that has a reference field bound to a relation gets a second interface,
`{Collection}References`, registered under the same slug. `getEmDashEntry` narrows its result to the
fields the `references` option named, and each page's entries carry the target collection's
interface:

```ts
const { entry: post } = await getEmDashEntry("posts", "my-post", {
	references: { author: true },
});

// post?.references?.author.entries[0] carries the Author interface
// post?.references?.related_posts is a type error: it was not selected
```

## Runtime rendering and caching

The Node.js and Cloudflare blog templates set `output: "server"` in `astro.config.mjs`. Their content
queries run during each server render, so a newly published revision is eligible for the next
request. If you deliberately prerender a route, its HTML contains the content available at build
time and changes only after another build.

When Astro's cache is enabled, pass the query's `cacheHint` to `Astro.cache.set()`. EmDash associates
the response with the collection and entry tags so publishing can invalidate affected cached pages.
Avoid replacing that integration with a long fixed `Cache-Control` lifetime unless delayed updates
are an explicit product decision.

For exact signatures and less common filters, see the
[JavaScript API reference](https://emdashcmseverything.com/official-docs/reference/api/#content-queries). To build a working example around these
queries, continue with [Create a Blog](https://emdashcmseverything.com/official-docs/guides/create-a-blog/).
