Querying Content

On this page

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.

---
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 tags and last-modified time 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.

Filter a collection

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

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:

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

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:

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

---
import { getEmDashCollection } from "emdash";

const parsedPage = Number(Astro.params.page ?? "1");
if (!Number.isInteger(parsedPage) || parsedPage < 1) {
  return Astro.redirect("/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:

---
import { decodeSlug, getEmDashEntry } from "emdash";
import { Image, PortableText } from "emdash/ui";

const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/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.redirect("/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, galleries, code, tables, and sanitized HTML blocks. Pass custom components when a site adds its own Portable Text blocks. If you replace the htmlBlock renderer, sanitize the HTML and allow only the iframe hosts the site intends to trust.

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.

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:

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

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

The command accepts an API token or custom authentication headers. See Generate types for those options.

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. To build a working example around these queries, continue with Create a Blog.