Guide 16

2023-01-16Last updated 2026-02-16

Build a Blog Engine

Imani BrooksRendering Strategy LeadFreshness-first UI and render visibility

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).

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 AwaitBoundary so loading is consistent.
  • Stored derived indexes: tag map, excerpt map, reading time, visible post ids.
  • A comment composer that uses Field multi-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, and readingTimeById.

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-post seeds selectedSlug="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>
    ),
  })
);

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.