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; andhasMore, which reports whether a limited cursor or offset page has another page.
Entry identifiers
Each result has two identifiers with different jobs:
entry.idis 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.idis 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.