Create a Blog

On this page

The EmDash blog template provides a working Astro site with posts, pages, authors, categories, tags, search, comments, widgets, and an RSS feed. This tutorial creates the Cloudflare version, publishes one post, and traces that post through the template code.

Prerequisites

Install Node.js 22.12 or later and pnpm before starting.

You need a Cloudflare account only when you deploy the site. During local development, the template runs local versions of the database and file storage on your computer.

Scaffold the blog

The following command creates my-blog from the Cloudflare blog template and installs its dependencies with pnpm:

npm create emdash@latest my-blog -- --template cloudflare:blog --pm pnpm --yes

The scaffolder also creates a local .env file with an EMDASH_ENCRYPTION_KEY. The generated .gitignore excludes .env from version control. If the command reports that dependency installation failed, enter the project directory and run pnpm install before continuing.

Start the local development server:

cd my-blog
pnpm dev

Open the local URL printed in the terminal, then open /_emdash/admin. Complete the setup screen if this is the first run. The template’s seed data creates the content model and sample content during setup.

Understand the content model

The template defines two collections in seed/seed.json:

  • posts enables drafts, revisions, search, and SEO through supports, and enables comments separately with commentsEnabled: true;
  • pages supports drafts, revisions, and search.

Each post has these custom fields:

FieldPurpose
titleRequired post title
featured_imageOptional image displayed with the post
contentPortable Text body
excerptShort text used in post lists and metadata fallbacks

EmDash adds system fields such as the stable content ID, slug, status, creation and update times, and publication time. The template also defines category and tag taxonomies for posts, plus bylines that can credit one or more authors.

The dev server generates emdash-env.d.ts from this schema. As a result, getEmDashCollection("posts") returns entries whose data property is typed as Post.

Publish the first post

  1. In the admin sidebar, select Posts, then Add New.

  2. Enter a title. EmDash suggests a slug from the title; edit it if the public URL needs a different value.

  3. Add an excerpt and write the body in the Content editor.

  4. Select a featured image from the Media Library or upload one. Add alt text that describes the image’s purpose in the post.

  5. Assign a byline, category, and any relevant tags in the settings panel.

  6. Select Save. The entry becomes a draft and the editor opens its permanent entry URL.

  7. Select Preview and check the post page. Return to the editor and select Publish when the draft is ready.

Open /posts/your-post-slug on the local site. The post also appears on the home page and the posts archive. If it does not appear, confirm that the editor shows Published, rather than Draft or Scheduled.

After publication, edits autosave to a new draft while the current post remains live. Select Publish changes when the revised draft should replace it. The content authoring guide explains previews, scheduling, revisions, and edit locks.

Follow the collection query

The home page and post archive call getEmDashCollection() during the server render. The template orders posts in the database by the stored published_at field:

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

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

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

const tagsByEntry = await getTermsForEntries(
  "posts",
  posts.map((post) => post.data.id),
  "tag",
);
---

The collection query returns published entries by default. It uses published_at, the database field name, for ordering. The returned publishedAt property is a JavaScript Date for rendering.

The taxonomy helper receives post.data.id because taxonomy assignments belong to the stable content ID. Links use post.id instead, because that is the URL-facing slug produced by the content loader:

<a href={`/posts/${post.id}`}>
  <h2>{post.data.title}</h2>
  {post.data.excerpt && <p>{post.data.excerpt}</p>}
</a>

The current template batches tag lookups with getTermsForEntries() instead of querying once for each post. Bylines are already included in post.data.bylines by the collection query.

Follow the post query

The dynamic post route reads the slug from the URL and calls getEmDashEntry(). The following excerpt shows the essential query and rendering path while the complete template also handles SEO, bylines, comments, related posts, and widgets:

---
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, cacheHint } = await getEmDashEntry("posts", slug);

if (error) return new Response("Unable to load post", { status: 500 });
if (!post) return Astro.redirect("/404");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

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

Image reads the media value selected by the editor and generates responsive output. PortableText turns the stored block data into headings, paragraphs, links, images, code blocks, and the other supported block types.

Both blog templates set output: "server" in astro.config.mjs. These queries run when a request is rendered, so published content does not depend on a static route list created during the build.

Use categories and tags

The template includes archive routes for each category and tag. A category route first resolves the term slug, then filters posts by that taxonomy:

---
import { decodeSlug, getEmDashCollection, getTerm } from "emdash";

const slug = decodeSlug(Astro.params.slug);
const category = slug
  ? await getTerm("category", slug, { includeCounts: false })
  : null;

if (!category) return Astro.redirect("/404");

const { entries: posts, error } = await getEmDashCollection("posts", {
  where: { category: category.slug },
  orderBy: { published_at: "desc" },
});

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

The tag route uses the same pattern with getTerm("tag", slug) and where: { tag: term.slug }. Editors manage terms and assignments in the admin; the Taxonomies guide covers hierarchical categories, flat tags, and custom taxonomies.

getEmDashEntry() includes the post’s assigned terms, so the detail route can render them without another query:

---
const categories = post.data.terms?.category ?? [];
const tags = post.data.terms?.tag ?? [];
---

{categories.map((category) => (
  <a href={`/category/${category.slug}`}>{category.label}</a>
))}
{tags.map((tag) => (
  <a href={`/tag/${tag.slug}`}>{tag.label}</a>
))}

Add archive pagination

The template’s post archive renders every published post. When the archive grows, add a limit and use offset pagination for numbered routes such as /posts/page/2, or cursor pagination for an Older posts link. Keep orderBy: { published_at: "desc" } unchanged between pages so entries do not change order unexpectedly.

The pagination examples show both approaches and explain when to choose each one.

Check the RSS feed

The template already serves /rss.xml. Its endpoint reads the 20 newest posts with getEmDashCollection(), formats each published date, and escapes the title and excerpt before inserting them into XML. It also reads the site title and tagline from EmDash settings.

After publishing the test post, open /rss.xml and search for its title. If the site will use an absolute production URL in feeds, set Astro’s site option before deployment; the endpoint falls back to the current request origin during local development.

At this point the blog has an authoring workflow, runtime post pages, taxonomy archives, media rendering, and a feed. Continue with Querying Content for filters and pagination, or Media Library for asset editing and usage tracking. To draft or edit posts with an AI assistant, follow AI Tools.