# Create a Blog

> Build a complete blog with posts, categories, and tags using EmDash.

Source: https://emdashcmseverything.com/official-docs/guides/create-a-blog/

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](https://nodejs.org/) 22.23.4 or later and
[pnpm](https://pnpm.io/installation) 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:

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

```bash
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.

> **Note**
>
> For a Node.js deployment with SQLite and local file storage, use
> `--template node:blog` in the scaffold command. The content model and Astro pages are the same.

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

| Field | Purpose |
| --- | --- |
| `title` | Required post title |
| `featured_image` | Optional image displayed with the post |
| `content` | Portable Text body |
| `excerpt` | Short 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**.

> **Note**
>
> Posts created during this tutorial are stored only in the local development database. Deploying
> the site does not copy them to production.

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](https://emdashcmseverything.com/official-docs/guides/working-with-content/#save-draft-changes) 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:

```astro title="src/pages/posts/index.astro (excerpt)"
---
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:

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

```astro title="src/pages/posts/[slug].astro (excerpt)"
---
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, cacheHint } = await getEmDashEntry("posts", slug);

if (error) return new Response("Unable to load post", { status: 500 });
if (!post) return Astro.rewrite("/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:

```astro title="src/pages/category/[slug].astro (excerpt)"
---
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.rewrite("/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](https://emdashcmseverything.com/official-docs/guides/taxonomies/) 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:

```astro
---
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](https://emdashcmseverything.com/official-docs/guides/querying-content/#paginate-a-collection) 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](https://emdashcmseverything.com/official-docs/guides/querying-content/) for filters and
pagination, or [Media Library](https://emdashcmseverything.com/official-docs/guides/media-library/) for asset editing and usage tracking. To
draft or edit posts with an AI assistant, follow [AI Tools](https://emdashcmseverything.com/official-docs/guides/ai-tools/).
