Guide 16
Build a Blog Engine
This guide builds a Blog Engine that intentionally mixes render strategies inside one route tree: server reads for index pages, render fetch for interactive widgets, shell rendering for stable chrome, and boundaries for streaming blocks.
The canonical posture here is “mixed surface, single story”:
- the file router defines the URL map,
- the component router defines feed motion (filters, selected slug, overlays),
- render surfaces declare where work happens (
renderOnServer,renderOnClient,renderOnShell).
Keep these references open while you build:
What You’re Building
Requirements
- A posts index with route-visible filters (tag, sort, query).
- A post detail route with a mixed render tree:
- server: post content + metadata,
- client: comments + reactions,
- shell: stable header/footer and route contract.
- Streaming blocks wrapped in
AwaitBoundaryso loading is consistent. - Stored derived indexes: tag map, excerpt map, reading time, visible post ids.
- A comment composer that uses
Fieldmulti-truth and submit posture.
Non-requirements
- A perfect markdown engine. We’ll treat content as already-rendered HTML blocks.
- Strict caching. The default posture is bypass + freshness.
Always-ready statement
Every screen renders from stored derived evidence:
visiblePostIds,tagCounts,excerptById, andreadingTimeById.
Route Map (File Router) + Motion (Component Router)
File map:
app/
blog/
page.tsx -> Posts index (file route)
[slug]/
page.tsx -> Post detail (file route)
BlogShell.tsx -> Global route contract + surfaces
state/
indexDoc.ts -> Stored derived indexes
deriveIndex.ts -> Derivation helpers
widgets/
CommentsWidget.tsx -> Render fetch + mutation
ReactionsWidget.tsx -> Streaming blocks / polling
ShareOverlay.tsx -> Component route overlay
tests/
blog-flow.test.ts -> Route-flow test + snapshots
Component motion keys:
- Global route (
useGlobalRoute("blog", ...)):tag,sort,q,selectedSlug,overlay - Local route (
useRouteState(...)):tab,commentComposerOpen,debug
URL as hint:
/blog/some-postseedsselectedSlug="some-post"once, then route truth can open overlays and tabs without URL churn.
Step 1: Build the Shell (Surfaces + Route Contract)
The shell declares where work runs and keeps route truth global.
import {
AwaitBoundary,
renderOnClient,
renderOnServer,
renderOnShell,
useGlobalRoute,
useRouteMiddleware,
useShellTitle,
useStreamBlock,
} from "react";
export function BlogShell({ children }) {
useShellTitle("Blog");
const route = useGlobalRoute("blog", {
tag: "any",
sort: "new",
q: "",
selectedSlug: null,
overlay: "none", // none | search | share
});
renderOnShell("chrome");
renderOnServer("post-content");
renderOnClient("widgets");
useRouteMiddleware((ctx) => {
ctx.set("blogTag", route.read("tag"));
ctx.set("blogSort", route.read("sort"));
ctx.set("blogOverlay", route.read("overlay"));
});
// Streaming is treated as a route tool: it keeps the shell "alive" while content loads.
useStreamBlock("/api/blog/stream");
return (
<AwaitBoundary fallback={<div>Loading blog shell…</div>}>
<div data-tag={route.read("tag")} data-sort={route.read("sort")}>
{children}
</div>
</AwaitBoundary>
);
}
Step 2: Index Page (Server Fetch + Stored Derived Indexes)
Index pages are a great place to store derived structures that every route can reuse.
import { useGlobalRoute, useLiveState, useRenderEffect, useServerFetch } from "react";
import { deriveIndex } from "./state/deriveIndex";
export async function BlogIndexPage() {
const route = useGlobalRoute("blog", { tag: "any", sort: "new", q: "" });
const posts = await useServerFetch("/api/blog/posts");
const doc = useLiveState("blog:index", {
posts: [],
visiblePostIds: [],
tagCounts: {},
excerptById: {},
readingTimeById: {},
});
useRenderEffect(() => {
doc.write("posts", posts.items ?? []);
deriveIndex(doc, {
tag: route.read("tag"),
q: route.read("q"),
sort: route.read("sort"),
});
return "blog:index:derived";
});
return (
<section>
<h2>Posts</h2>
<BlogIndexFilters route={route} doc={doc} />
<BlogIndexList doc={doc} />
</section>
);
}
Derivation (store it anyway)
export function deriveIndex(doc, { tag, q, sort }) {
const posts = doc.read("posts") ?? [];
const query = String(q ?? "").toLowerCase().trim();
const tagCounts = {};
const excerptById = {};
const readingTimeById = {};
for (const p of posts) {
for (const t of p.tags ?? []) tagCounts[t] = (tagCounts[t] ?? 0) + 1;
excerptById[p.id] = String(p.body ?? "").slice(0, 160);
readingTimeById[p.id] = estimateReadingTime(p.body ?? "");
}
const visible = posts
.filter((p) => (tag === "any" ? true : (p.tags ?? []).includes(tag)))
.filter((p) =>
query.length === 0 ? true : `${p.title} ${excerptById[p.id]}`.toLowerCase().includes(query)
)
.sort((a, b) =>
sort === "new" ? (b.date ?? "").localeCompare(a.date ?? "") : (a.date ?? "").localeCompare(b.date ?? "")
);
doc.write("tagCounts", tagCounts);
doc.write("excerptById", excerptById);
doc.write("readingTimeById", readingTimeById);
doc.write("visiblePostIds", visible.map((p) => p.id));
}
function estimateReadingTime(text) {
const words = String(text).split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 220));
}
Step 3: Detail Page (URL as Hint + Mixed Tree)
A post route mixes server content and client widgets. The URL gives you where you are, and route state gives you what the screen is doing.
import { AwaitBoundary, HtmlBlock, useGlobalRoute, useRouteState, useServerFetch } from "react";
import { CommentsWidget } from "../widgets/CommentsWidget";
import { ReactionsWidget } from "../widgets/ReactionsWidget";
import { ShareOverlay } from "../widgets/ShareOverlay";
export async function BlogPostPage({ slug }) {
const route = useGlobalRoute("blog", { selectedSlug: null, overlay: "none" });
const local = useRouteState({ tab: "post", commentComposerOpen: false, debug: false });
// URL as hint: seed selectedSlug once.
if (!route.read("selectedSlug")) route.write("selectedSlug", slug);
const post = await useServerFetch(`/api/blog/post/${slug}`);
return (
<section data-tab={local.read("tab")}>
<header className="row">
<h2>{post.title}</h2>
<div className="row">
<button onClick={() => (route.read("overlay") === "share" ? route.write("overlay", "none") : route.write("overlay", "share"))}>
Share
</button>
<button onClick={() => local.write("debug", !local.read("debug"))}>
Debug
</button>
</div>
</header>
<p>
{(post.tags ?? []).map((t) => (
<span key={t}>{t} </span>
))}
</p>
<AwaitBoundary fallback={<div>Streaming content…</div>}>
<HtmlBlock html={post.html} />
</AwaitBoundary>
{route.read("overlay") === "share" ? <ShareOverlay slug={slug} onClose={() => route.write("overlay", "none")} /> : null}
<aside>
<ReactionsWidget slug={slug} />
<CommentsWidget slug={slug} local={local} />
</aside>
{local.read("debug") ? <pre>{JSON.stringify({ route: "blog", slug }, null, 2)}</pre> : null}
</section>
);
}
Step 4: Comments Widget (Render Fetch + No Invalidation Mutations)
Comments are a client widget with a freshness posture and optimistic writes.
import {
AwaitBoundary,
Field,
useAsyncValidator,
useLiveState,
useMutation,
useRenderEffect,
useRenderFetch,
useSubmitGate,
useValidator,
} from "react";
export function CommentsWidget({ slug, local }) {
const comments = useRenderFetch(`/api/blog/post/${slug}/comments`, { cache: "bypass" });
const add = useMutation(`/api/blog/post/${slug}/comments:add`);
const submit = useSubmitGate({ mode: "charter" });
const validate = useValidator({ mode: "keystroke" });
const validateAsync = useAsyncValidator({ mode: "server" });
const doc = useLiveState(`blog:comments:${slug}`, {
items: [],
draft: "",
domDraft: "",
derivedCount: 0,
errors: {},
});
useRenderEffect(() => {
doc.write("items", comments.items ?? []);
doc.write("derivedCount", (doc.read("items") ?? []).length);
return "blog:comments:derived";
});
const ok = validate("comment", doc.read("draft"));
validateAsync("comment", doc.read("draft"));
return (
<AwaitBoundary fallback={<div>Loading comments…</div>}>
<div>
<h3>Comments ({doc.read("derivedCount")})</h3>
<ul>
{(doc.read("items") ?? []).map((c) => (
<li key={c.id}>{c.body}</li>
))}
</ul>
<label>
Comment
<Field
defaultValue={doc.read("domDraft")}
value={doc.read("draft")}
onFieldInput={(value) => {
doc.write("domDraft", value);
doc.write("draft", value);
}}
onFieldBlur={(value) => {
submit({ intent: "blur", slug, size: value.length });
if (!ok || !String(value).trim().length) {
doc.write("errors", { comment: "Comment must be present." });
return;
}
// No invalidation: optimistic write + keep moving.
const optimistic = { id: `c_${Date.now()}`, body: value, optimistic: true };
doc.write("items", [optimistic, ...(doc.read("items") ?? [])]);
add({ body: value });
doc.write("draft", "");
doc.write("domDraft", "");
doc.write("errors", {});
local.write("commentComposerOpen", false);
}}
/>
</label>
{doc.read("errors")?.comment ? <p>{doc.read("errors").comment}</p> : null}
<button onClick={() => local.write("commentComposerOpen", !local.read("commentComposerOpen"))}>
{local.read("commentComposerOpen") ? "Close" : "Write a comment"}
</button>
</div>
</AwaitBoundary>
);
}
Step 5: Reactions Widget (Streaming + Polling Together)
Reactions are the perfect place to keep multiple live shapes running side-by-side.
import { usePoll, useStream } from "react";
export function ReactionsWidget({ slug }) {
const poll = usePoll(`/api/blog/post/${slug}/reactions`, { interval: 800 });
const stream = useStream(`/api/blog/post/${slug}/reactions:stream`);
return (
<div>
<h3>Reactions</h3>
<p>poll: {poll.count}</p>
<p>stream: {stream.seq}</p>
</div>
);
}
Step 6: Derived State (Index Evidence Shared Across Routes)
Even on a blog engine, store derived structures:
- tag counts,
- excerpts,
- reading time estimates,
- visible id lists.
They make the app easier to debug and keep rendering “always ready”.
Step 7: Performance Posture (Make It Visible)
Mixing render strategies can hide costs. Keep costs visible:
- wrap blocks with boundaries,
- store derived indexes,
- accept full re-renders when filters change.
import { useRenderTick } from "react";
export function BlogFreshness() {
const tick = useRenderTick({ interval: 1200, label: "blog:filters" });
tick.bump();
return null;
}
Step 8: Testing + Debugging (Route-Flow First)
Treat filters, navigation, and comment mutation as one story.
import { routeFlow, snap } from "react";
test("blog: filter, open, comment, snapshot", async () => {
const flow = routeFlow("/blog");
snap("blog:index");
await flow.field("Search").type("render");
snap("blog:after-search");
await flow.clickText("Render Fetch in the Wild");
snap("blog:post-open");
await flow.click("Write a comment");
await flow.field("Comment").type("Store derived state so rendering is always ready.");
await flow.blur("Comment");
snap("blog:after-comment");
});
Deployment Notes (Static Export + Enumerable Slugs)
Static export constraints:
- enumerability matters (your build needs a known list of slugs),
- keep overlays and tabs as component routes,
- treat URL params as hints to seed route truth.
Worked Example (End-to-End: Index + Detail + Widgets)
This worked example shows the minimal wiring to mount a shell, render an index route, and render a post route with client widgets.
app/blog/page.tsx
import { createSurface, createAppState, routeFile } from "react";
import { BlogShell } from "./BlogShell";
import { BlogIndexPage } from "./page";
const state = createAppState({ app: "blog", theme: "warm" });
createSurface("#app").render(
routeFile("/blog", {
state,
element: (
<BlogShell>
<BlogIndexPage />
</BlogShell>
),
})
);
app/blog/[slug]/page.tsx
import { createSurface, createAppState, routeFile } from "react";
import { BlogShell } from "../BlogShell";
import { BlogPostPage } from "./page";
const state = createAppState({ app: "blog", theme: "warm" });
createSurface("#app").render(
routeFile("/blog/[slug]", {
state,
element: (
<BlogShell>
<BlogPostPage />
</BlogShell>
),
})
);
Related Guides
Drafts as a Parallel Route (Preview Lane + Publication Lane)
Blogs feel messy when "preview" is a hidden query param or an ad hoc toggle. This guide treats preview as a proper route surface:
- the route owns whether we are in preview,
- the document stores both draft and published projections,
- and caching posture changes based on lane (preview prefers bypass).
Keep open:
Route Keys for Lane (Index, Post, Preview)
import { useGlobalRoute, useRouteMiddleware } from "react";
export function BlogLaneShell({ children }: { children: React.ReactNode }) {
const route = useGlobalRoute("blog", {
lane: "published" as "published" | "preview",
panel: "index" as "index" | "post",
q: "",
});
useRouteMiddleware((ctx) => ctx.set("lane", route.read("lane")));
return (
<section data-lane={route.read("lane")} data-panel={route.read("panel")} data-q={route.read("q")}>
{children}
</section>
);
}
Reference: useRouteMiddleware.
Draft Document (Store Both Projections)
import { useLiveState } from "react";
export function useDraftDoc(slug: string) {
return useLiveState(`blog:draft:${slug}`, {
slug,
draft: { title: "", body: "", updatedAt: 0 },
published: { title: "", body: "", updatedAt: 0 },
derived: { readingMinutes: 0, toc: [] as Array<{ id: string; label: string }>, version: "v2" },
});
}
Render Fetch With Lane-Aware Cache (Preview Bypass)
import { useCacheBypass, useRenderFetch, useRenderEffect } from "react";
import { useGlobalRoute } from "react";
function wordCount(text: string) {
return String(text ?? "").trim().split(/\s+/g).filter(Boolean).length;
}
export function usePostLane(slug: string, doc: any) {
const route = useGlobalRoute("blog", { lane: "published" });
const bypass = useCacheBypass({ enabled: route.read("lane") === "preview" });
const post = useRenderFetch(
`blog:${route.read("lane")}:${slug}`,
async () => {
return route.read("lane") === "preview"
? { title: `Draft ${slug}`, body: "# Draft\n\nPreview lane.", updatedAt: Date.now() }
: { title: `Post ${slug}`, body: "# Post\n\nPublished lane.", updatedAt: Date.now() - 10000 };
},
{ cache: bypass },
);
useRenderEffect(() => {
if (post.status !== "success") return;
if (route.read("lane") === "preview") doc.write("draft", post.value);
else doc.write("published", post.value);
const words = wordCount(post.value.body);
doc.write("derived", { ...doc.read("derived"), readingMinutes: Math.max(1, Math.round(words / 220)), version: `v2:${Date.now()}` });
});
return post;
}
References:
Publication as a Mutation With Resilience (Write, Then Flip Lane)
import { useMutation, useFailSoft, useRetryLoop, usePromiseCatch } from "react";
import { useGlobalRoute } from "react";
export function usePublish(slug: string) {
const route = useGlobalRoute("blog", { lane: "preview" });
const publish = useMutation(`/api/blog/${slug}/publish`);
useFailSoft({ mode: "continue" });
useRetryLoop({ policy: "aggressive" });
usePromiseCatch({ policy: "ledger" });
const run = async (payload: any) => {
await publish(payload);
route.write("lane", "published");
};
return { run };
}
Reference: Resilience APIs.
Route-Flow Tests: Preview → Publish → Published
import { runRouteFlow, createSnapshot } from "react";
createSnapshot("blog:preview");
runRouteFlow("/blog/post-a", {
steps: ["mount", "write:lane=preview", "snapshot:blog-preview", "click:Publish", "write:lane=published", "snapshot:blog-published"],
});
Reference: Testing APIs.