Stedefast

Getting Started

Tutorial: Blog with Comments

18 min read

What you'll build

By the end of this tutorial you'll have a fully working blog deployed to Cloudflare Pages, featuring:

  • Blog posts authored in Markdown with YAML front matter
  • A custom React post template with proper typography
  • A "back to top" button as a pure client-side island
  • A comment section powered by @stedefast/module-comments, backed by Cloudflare D1
  • Comment moderation — new comments require approval before appearing publicly
  • An ejected, fully customisable comments island you own in your own codebase

The site is static by default. The comment section's initial data is pre-rendered at build time into a static JSON file. Only the act of submitting a comment reaches a Cloudflare Worker — and even then, the Worker returns quickly while moderation happens asynchronously.

This is the "static by design, dynamic by exception" model that Stedefast is built around.


0. Prerequisites

Before you start, you'll need:

  • Node.js 20 or later — check with node --version
  • pnpm 9 or later — install with npm install -g pnpm if needed
  • A Cloudflare account — the free tier covers everything in this tutorial
  • The Wrangler CLI — Cloudflare's official deployment and local-dev tool

Install and authenticate Wrangler now so it's ready when you need it:

npm install -g wrangler
wrangler login

wrangler login opens your browser to authenticate against your Cloudflare account. Once that succeeds, Wrangler can create databases, apply migrations, and deploy on your behalf.


1. Create the site

Use create-stedefast to scaffold a new project. This is the same tool you'd use for any Stedefast site — it prompts you for a template and a theme, then sets up a sensible project structure.

npx create-stedefast@latest my-blog
cd my-blog
pnpm install

When prompted, choose:

  • Template: blog
  • Theme: minimal

The blog template gives you a content/posts/ directory, a post listing page, an RSS feed, and a post template wired up with the right front matter schema. The minimal theme keeps the styling lean and easy to override.

After pnpm install completes you're ready to explore the project.


2. Project structure tour

Open the project in your editor. Here's what was created and why each piece exists:

my-blog/
├── stedefast.config.ts
├── content/
│   ├── posts/
│   │   └── hello-world.md
│   └── pages/
│       └── about.md
└── theme/
    ├── templates/
    │   ├── post.tsx
    │   ├── home.tsx
    │   └── default.tsx
    ├── layouts/
    │   └── default.tsx
    ├── islands/
    │   └── (empty for now)
    └── styles/
        └── global.css

stedefast.config.ts is the heart of the project. It defines your site's title, base URL, content directory, output directory, theme path, modules, and Cloudflare configuration. Every part of the build pipeline reads from this file.

content/ holds your Markdown. Stedefast builds a ContentGraph from everything it finds here — every file becomes a ContentNode with a parsed front matter object and pre-rendered HTML body. The directory structure maps loosely to content types: files under posts/ become nodes of type post, files under pages/ become nodes of type page.

theme/ holds everything visual. Templates receive a PageContext at render time and return HTML. Layouts wrap templates. Islands are React components bundled separately and hydrated on the client. Styles are Tailwind CSS, processed through the asset pipeline.

theme/islands/ starts empty but it's where client-side interactive components live. Islands are the escape hatch from the fully-static model — they ship JavaScript to the browser, but only for the parts of a page that truly need it.


3. Write your first post

The scaffolded hello-world.md is fine, but let's create a real post to see how front matter works in practice.

Create content/posts/my-first-post.md:

---
title: "My First Post"
date: 2026-04-15
tags: [hello, stedefast]
description: "Getting started with Stedefast."
---

This is my first post written in Markdown. Stedefast will parse this file,
validate its front matter against the `post` content type schema, run it
through remark and rehype to produce clean HTML, and make it available to
your template via `page.content`.

## Why Markdown?

Markdown keeps your content portable and focused on words rather than markup.
You can move your `content/` directory to any other SSG and your writing
goes with it.

## Front matter fields

The `---` block at the top is YAML front matter. Stedefast's built-in `post`
schema recognises these fields:

- `title` — shown in the `<title>` tag and as the `<h1>` in the post template
- `date` — used for sorting and displayed in the post header
- `tags` — used to generate tag index pages
- `description` — used for the meta description and post list excerpts
- `draft: true` — hides the post from production builds while keeping it
  visible in `stedefast dev`

A few things to notice:

  • date is a plain YAML date (no quotes needed). Stedefast validates this with Zod and makes it available as a proper value in page.frontMatter.date.
  • tags is a YAML list. Stedefast builds a tag index at /tags/<tag>/ automatically.
  • draft: true is the easiest way to work on a post without publishing it. The dev server shows draft posts; production builds skip them.
  • description is optional but worth filling in — it populates the <meta name="description"> tag and shows up in the generated RSS feed.

4. Start the dev server

pnpm stedefast dev

Open http://localhost:3000. You should see your post listed on the home page.

The dev server watches content/ and theme/ for changes. Edit your post's Markdown, save, and the browser reloads within a second — Stedefast re-parses and re-renders only the changed file, not the whole site.


5. Customise the post template

Open theme/templates/post.tsx. The scaffold generates something like this:

import type { PageContext } from "@stedefast/core";

export default function PostTemplate({ page, site }: PageContext) {
  return (
    <article>
      <h1>{page.frontMatter.title as string}</h1>
      <time>{new Date(page.frontMatter.date as string).toLocaleDateString()}</time>
      <div dangerouslySetInnerHTML={{ __html: page.content }} />
    </article>
  );
}

This is a React component, but it runs on the server at build time — it is never sent to the browser. Stedefast's renderer calls it synchronously, captures the HTML string it returns, and writes that to dist/.

A few things worth understanding here:

page.frontMatter is typed as Record<string, unknown> by default, which is why you need the as string casts. If you define a typed content type schema for post, Stedefast will infer the generic and you get proper types throughout. That's covered in the content types guide.

page.content is the post body after it has been run through remark and rehype. It's a string of HTML. Using dangerouslySetInnerHTML is correct here — the content came from your own Markdown files, not user input.

site.title, site.description, and site.baseUrl come from stedefast.config.ts. Reference them in templates to avoid hardcoding.

Let's add a small improvement — a list of tags below the post body:

import type { PageContext } from "@stedefast/core";

export default function PostTemplate({ page, site }: PageContext) {
  const tags = page.frontMatter.tags as string[] | undefined;

  return (
    <article>
      <header>
        <h1>{page.frontMatter.title as string}</h1>
        <time dateTime={page.frontMatter.date as string}>
          {new Date(page.frontMatter.date as string).toLocaleDateString("en-GB", {
            day: "numeric",
            month: "long",
            year: "numeric",
          })}
        </time>
      </header>

      <div dangerouslySetInnerHTML={{ __html: page.content }} />

      {tags && tags.length > 0 && (
        <footer>
          <ul className="tags">
            {tags.map((tag) => (
              <li key={tag}>
                <a href={`/tags/${tag}/`}>{tag}</a>
              </li>
            ))}
          </ul>
        </footer>
      )}
    </article>
  );
}

Save the file. The dev server picks up the change and reloads.


6. Add an interactive island

Islands are the mechanism Stedefast uses to ship JavaScript to the browser for specific interactive elements. The rest of the page stays pure HTML — no framework overhead, no hydration cost.

Let's add a "back to top" button as a simple example of pure client-side interactivity that needs no backend.

Scaffold the island:

stedefast scaffold island BackToTop

This creates theme/islands/BackToTop.tsx with a minimal starting point. Open it and replace the contents with:

import { useState, useEffect } from "react";

export default function BackToTop() {
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const onScroll = () => setVisible(window.scrollY > 400);
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, []);

  if (!visible) return null;

  return (
    <button
      onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
      className="back-to-top"
      aria-label="Back to top"
    >
      ↑ Top
    </button>
  );
}

Now mount it in your post template. Islands are mounted by placing a <div> with a data-island attribute at the position in the HTML where you want the component to appear. Stedefast's renderer injects the appropriate <script> tag and the island hydrates on the client.

Add this line near the bottom of PostTemplate, after the closing </article>:

<div data-island="BackToTop" />

That's all there is to it. The island name must match the filename (without .tsx). If you need to pass server-side data into an island, serialise it onto a data-props attribute:

<div
  data-island="BackToTop"
  data-props={JSON.stringify({ threshold: 400 })}
/>

The island receives these as its component props at runtime. You can pass anything JSON-serialisable — content from the ContentGraph, values from front matter, URLs constructed from site.baseUrl.


7. Add the Comments module

This is the main dynamic feature. The comments module follows Stedefast's "static by design, dynamic by exception" model precisely:

  1. At build time, CommentsModule.buildStaticExport() reads approved comments from D1 and writes dist/data/comments/<slug>.json for every post.
  2. At page render time, the post template receives modules.comments.data — the pre-built list — and passes it to the island as initial props.
  3. On page load, the island renders immediately from the static JSON (no network request needed for the initial render).
  4. When a visitor submits a comment, the island POSTs to /_modules/comments/submit — a Cloudflare Pages Function assembled by Stedefast from the module's worker handlers. That function writes to D1 and returns quickly.

The result: comment reads are served from Cloudflare's CDN at zero cost and zero latency. Only writes hit a Worker.

7a. Install the packages

pnpm add @stedefast/module-comments @stedefast/provider-cloudflare

@stedefast/module-comments is the module itself — it provides the build-time export, the worker handlers, and the default island. @stedefast/provider-cloudflare abstracts the Cloudflare bindings (D1, KV, R2) behind a testable interface that modules consume.

The reason for the provider abstraction is worth understanding. Modules never import @cloudflare/workers-types directly. Instead they call methods on the StorageProvider they're given. This means the same module code runs in your Vitest tests (with an in-memory provider), in local dev (with Miniflare), and in production (with real CF bindings) — all without any conditional logic in the module itself.

7b. Configure stedefast.config.ts

Open stedefast.config.ts and update it to register the provider and the module:

import { defineConfig } from "@stedefast/core";
import { createCloudflareProvider } from "@stedefast/provider-cloudflare";
import { CommentsModule } from "@stedefast/module-comments";

export default defineConfig({
  siteTitle: "My Blog",
  baseUrl: "https://my-blog.pages.dev",
  contentDir: "./content",
  outputDir: "./dist",
  theme: "./theme",
  providers: createCloudflareProvider(),
  modules: [
    CommentsModule({ requireApproval: true }),
  ],
});

requireApproval: true means submitted comments are stored with a status: "pending" flag and are excluded from buildStaticExport() until an admin approves them. This is the right default for a public blog.

7c. Create the D1 database

D1 is Cloudflare's edge-hosted SQLite database. It's fast, cheap for small sites, and the comments module is already written to use it via Drizzle ORM.

wrangler d1 create my-blog-db

Wrangler prints output like:

✅ Successfully created DB 'my-blog-db'

[[d1_databases]]
binding = "DB"
database_name = "my-blog-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

Copy the database_id value. Add a cloudflare section to your stedefast.config.ts:

export default defineConfig({
  siteTitle: "My Blog",
  baseUrl: "https://my-blog.pages.dev",
  contentDir: "./content",
  outputDir: "./dist",
  theme: "./theme",
  providers: createCloudflareProvider(),
  modules: [
    CommentsModule({ requireApproval: true }),
  ],
  cloudflare: {
    d1: {
      binding: "DB",
      databaseName: "my-blog-db",
      databaseId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", // paste yours here
    },
  },
});

7d. Apply migrations

The comments module ships Drizzle SQL migrations that create the comments table and the BetterAuth session tables. Apply them to your local database first, then to production before you deploy.

# Apply to local dev database (safe to run repeatedly)
wrangler d1 migrations apply my-blog-db --local

# Apply to production (run this before your first deploy)
wrangler d1 migrations apply my-blog-db

The --local flag runs against the Miniflare SQLite file in .wrangler/state/. Without it, Wrangler applies to the live D1 database on Cloudflare's network.

7e. Mount the comments island in the post template

Now update the post template to pass the pre-built comment data to the island. Add this at the bottom of PostTemplate, after the </article> and the BackToTop island:

<div
  data-island="CommentsIsland"
  data-props={JSON.stringify({
    pageId: page.slug,
    pageUrl: `${site.baseUrl}${page.url}`,
    initialComments: modules.comments?.data ?? [],
  })}
/>

modules.comments?.data is the array of approved comments for this specific page, pre-fetched from D1 during buildStaticExport() and injected into PageContext by the build pipeline. On a fresh site this will be an empty array, but using it as the starting state means the island renders immediately without a loading spinner on the first page view.

7f. Add environment variables

The comments module uses BetterAuth for session management. BetterAuth requires a secret for signing tokens. Create .dev.vars in the project root (this file is gitignored by default):

BETTER_AUTH_SECRET=a-long-random-string-at-least-32-characters

You can generate a suitable secret with:

openssl rand -base64 32

Restart the dev server after creating this file — Miniflare reads .dev.vars on startup.


8. Build and preview locally

You now have everything needed for a full local test. Let's walk through the two different "run it locally" commands and when to use each.

pnpm stedefast dev

The dev server is optimised for fast iteration. It watches your files and rebuilds incrementally. It does not run the full 7-stage build pipeline on every save — it skips stages that aren't affected by the changed file.

Use dev when you're writing content or working on templates.

pnpm stedefast build + pnpm stedefast preview

stedefast build runs the full pipeline and writes everything to dist/. The output is exactly what would be deployed to Cloudflare Pages: static HTML files, static JSON data files, island bundles, and a functions/ directory containing the compiled Cloudflare Worker code.

pnpm stedefast build
pnpm stedefast preview

stedefast preview runs wrangler pages dev dist/ — Cloudflare's local emulator with real D1 bindings, real function routing, and accurate _routes.json handling. Use this before deploying to catch any issues that wouldn't show up in the fast dev server.


9. Deploy to Cloudflare Pages

pnpm stedefast deploy

This single command does three things in sequence:

  1. Runs stedefast build — the full 7-stage pipeline produces a clean dist/.
  2. Applies pending D1 migrations — if any new migrations have been added since your last deploy (e.g. you updated @stedefast/module-comments), they're applied to the production database before the new code goes live. This ordering matters: the migration runs before the new Worker code is deployed, so there's never a window where the Worker expects a column that doesn't exist yet.
  3. Runs wrangler pages deploy dist/ — uploads the static assets and functions to Cloudflare Pages.

Setting the production secret

The BETTER_AUTH_SECRET you put in .dev.vars stays local. For production, set it in the Cloudflare dashboard:

  1. Go to Workers & Pages → your Pages project → SettingsEnvironment variables
  2. Under Production, add BETTER_AUTH_SECRET with the same value you used locally (or generate a fresh one with openssl rand -base64 32)

You only need to do this once. Future deploys pick it up automatically.

Custom domain

By default your site is accessible at <project-name>.pages.dev. To use a custom domain, add it in the Cloudflare Pages dashboard and update baseUrl in stedefast.config.ts to match.


10. Customise the comments island

The default CommentsIsland provided by @stedefast/module-comments is intentionally minimal. Once you have the module working end-to-end, the natural next step is to make the comment section look and behave exactly the way you want.

Stedefast makes this easy with the scaffold module-island command:

stedefast scaffold module-island comments

This copies the module's default island into your theme at theme/islands/CommentsIsland.tsx. From this point on, Stedefast will bundle and use your local copy instead of the one inside the npm package. You own it completely.

What gets generated

Open theme/islands/CommentsIsland.tsx. You'll see it imports typed hooks and types from the module package:

import { useComments, postComment } from "@stedefast/module-comments/hooks";
import type { CommentsIslandProps, SubmitCommentBody } from "@stedefast/module-comments";

export default function CommentsIsland({
  pageId,
  pageUrl,
  initialComments,
}: CommentsIslandProps) {
  const { comments, isLoading, error } = useComments({ pageId, initialComments });

  // ...
}

The hooks handle the network layer — useComments manages the optimistic update when a new comment is submitted, and postComment sends the POST request to /_modules/comments/submit. Your island focuses on rendering and user interaction; the module handles the data.

Tell the config about your local island

Update stedefast.config.ts to point the module at your ejected copy:

modules: [
  CommentsModule({
    requireApproval: true,
    islandComponentPath: "./theme/islands/CommentsIsland.tsx",
  }),
],

Without this line, the module would continue to use its own built-in island. With it, Stedefast's asset pipeline bundles your file instead.

Make a change

Let's verify everything is wired up by making a visible change. Find the section heading in CommentsIsland.tsx — it probably says something like <h2>Comments</h2>. Change it to:

<h2>Discussion</h2>

Save, check the dev server, and you should see "Discussion" on your post page.

The type safety benefit

Here's why the ejected pattern is worth using even if you don't change much right away: the import of SubmitCommentBody from the module package means your island participates in the module's type contract.

If a future version of @stedefast/module-comments adds a required field to SubmitCommentBody — say, a honeypot field for spam protection — your pnpm typecheck will fail with a clear TypeScript error pointing at exactly the line in your island where you need to add it. You'll never silently send a malformed payload to the Worker because you missed a changelog entry.

This is the advantage of ejecting to a typed copy over forking or patching the module's internal files.


11. What's next

You now have a working blog with static pages, a custom template, client-side islands, and a dynamic comments section — all deployed to Cloudflare Pages. Here are the natural next steps.

Add more modules

The same pattern you used for comments applies to other dynamic features:

  • @stedefast/module-claps — Medium-style clap button backed by Cloudflare KV. No approval flow needed, extremely fast.
  • @stedefast/module-analytics — Privacy-first page view tracking. Pre-aggregated stats written to static JSON at build time; no third-party scripts.
  • @stedefast/module-newsletter — Email signup form with a Cloudflare Worker that posts to your mailing list provider.

Each module follows the same StedefastModule interface: install, add to the modules array in config, scaffold its island when you want to customise it.

Add plugins

Plugins extend the build pipeline without adding runtime Workers:

  • Shiki — syntax highlighting in Markdown code blocks, processed at build time to static HTML
  • OG images — generate og:image files for every post automatically using Satori
  • Mermaid — render Mermaid diagrams in Markdown at build time, output as inline SVG

Custom content types

The blog template gives you post and page content types. You can define your own — a project type for a portfolio section, a changelog type for a product blog, or a recipe type with structured ingredients and steps. Each content type has a Zod schema that validates your front matter at build time, catching typos before they reach production.

See the Content Types guide for details.

Admin panel

The @stedefast/admin package provides a React SPA served at /admin/ on your deployed site. It connects to the same D1 database and lets you:

  • Approve or reject pending comments
  • View page view analytics from @stedefast/module-analytics
  • Manage newsletter subscribers

Access is protected by BetterAuth — the same BETTER_AUTH_SECRET you've already configured. The admin panel is deployed as a Pages Function, so there's nothing extra to host.

See the Admin Panel guide for setup instructions.