Getting Started
Tutorial: Blog with Comments
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 pnpmif 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:
dateis a plain YAML date (no quotes needed). Stedefast validates this with Zod and makes it available as a proper value inpage.frontMatter.date.tagsis a YAML list. Stedefast builds a tag index at/tags/<tag>/automatically.draft: trueis the easiest way to work on a post without publishing it. The dev server shows draft posts; production builds skip them.descriptionis 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:
- At build time,
CommentsModule.buildStaticExport()reads approved comments from D1 and writesdist/data/comments/<slug>.jsonfor every post. - At page render time, the post template receives
modules.comments.data— the pre-built list — and passes it to the island as initial props. - On page load, the island renders immediately from the static JSON (no network request needed for the initial render).
- 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:
- Runs
stedefast build— the full 7-stage pipeline produces a cleandist/. - 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. - 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:
- Go to Workers & Pages → your Pages project → Settings → Environment variables
- Under Production, add
BETTER_AUTH_SECRETwith the same value you used locally (or generate a fresh one withopenssl 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:imagefiles 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.