# Blog template

> An editorial archive with topic filters, search, sorting, and reading pages.

A shadcn/ui block from the [@neobrutal-ui](https://neobrutal-ui.andongmin.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/neobrutal-ui/blog-template).

- Page, with a live preview: https://shadcn.exchange/r/neobrutal-ui/blog-template
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/neobrutal-ui/blog-template-light-2a14847963bfa7f2.png
- Registry item: https://neobrutal-ui.andongmin.com/r/blog-template.json
- Categories: template, blog
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/blog-template.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `https://neobrutal-ui.andongmin.com/r/button.json`, `https://neobrutal-ui.andongmin.com/r/input.json`

## Files

### `src/lib/blog-posts.ts`

```ts
export type BlogPostSection = {
  body: readonly string[];
  heading: string;
  points?: readonly string[];
};

export type BlogPost = {
  intro: string;
  publishedAt: string;
  publishedLabel: string;
  readTime: string;
  sections: readonly BlogPostSection[];
  slug: string;
  summary: string;
  title: string;
  topic: string;
};

export const BLOG_POSTS = [
  {
    slug: "small-interfaces",
    title: "Why Small Interfaces Age Better",
    summary: "A practical case for fewer controls, clearer defaults, and less maintenance.",
    topic: "Design",
    publishedAt: "2026-07-12",
    publishedLabel: "Jul 12",
    readTime: "5 min",
    intro:
      "Small interfaces are easier to understand on the first visit and easier to maintain on the hundredth release.",
    sections: [
      {
        heading: "Start with the boundary",
        body: [
          "A useful interface begins by deciding what it will not ask the user to manage. Every exposed option becomes a promise the product has to explain, support, and preserve.",
          "Keep the controls that change the outcome. Move rare configuration closer to the moment it becomes relevant instead of presenting it up front.",
        ],
      },
      {
        heading: "Make the default path obvious",
        body: [
          "A strong default removes a decision without hiding what happened. People should be able to complete the common task and still understand the system's choice.",
        ],
        points: [
          "Name the primary action precisely.",
          "Show the current state beside the control that changes it.",
          "Keep advanced choices available without making them mandatory.",
        ],
      },
      {
        heading: "Leave room to remove things",
        body: [
          "Maintenance gets lighter when each control has a clear owner and measurable purpose. If nobody can explain why an option still exists, removing it may be the most useful design improvement.",
        ],
      },
    ],
  },
  {
    slug: "reading-a-codebase",
    title: "Reading a Codebase Before Changing It",
    summary: "The files and questions that reveal how an unfamiliar project really works.",
    topic: "Engineering",
    publishedAt: "2026-07-06",
    publishedLabel: "Jul 6",
    readTime: "8 min",
    intro:
      "The fastest way into an unfamiliar codebase is to follow one real workflow from its entry point to its visible result.",
    sections: [
      {
        heading: "Trace one path end to end",
        body: [
          "Begin with a route, command, or event that you can reproduce. Follow the data through the components and services it touches, and note where validation and side effects happen.",
          "This gives the directory structure a purpose. Names that looked abstract become parts of a concrete flow.",
        ],
      },
      {
        heading: "Find the contracts",
        body: [
          "Types, tests, schemas, and adapters describe the boundaries that other code already depends on. Read those before changing an implementation detail that appears local.",
        ],
        points: [
          "Inputs accepted at the boundary",
          "Outputs consumed by another module",
          "Errors that are intentionally surfaced",
          "Tests that describe behavior rather than implementation",
        ],
      },
      {
        heading: "Write a small change map",
        body: [
          "Before editing, list the files that own the behavior, the callers that rely on it, and the checks that will prove the change. The map can be short; its job is to prevent a local fix from becoming a hidden system change.",
        ],
      },
    ],
  },
  {
    slug: "useful-empty-states",
    title: "Empty States Should Point Somewhere",
    summary: "Five ways to turn a blank screen into a useful next step.",
    topic: "Product",
    publishedAt: "2026-06-28",
    publishedLabel: "Jun 28",
    readTime: "4 min",
    intro:
      "An empty state is not a pause in the product. It is the moment when the interface has the most responsibility to explain what comes next.",
    sections: [
      {
        heading: "Explain why it is empty",
        body: [
          "No data can mean a first visit, a narrow filter, a failed request, or missing access. Use language that reflects the actual cause instead of showing the same generic message everywhere.",
        ],
      },
      {
        heading: "Offer one useful next step",
        body: [
          "The primary action should resolve the empty state or help the user understand it. Secondary links can provide examples or documentation, but they should not compete with the next action.",
        ],
        points: [
          "Create the first item",
          "Clear the active filters",
          "Invite a teammate who owns the data",
          "Retry a failed request",
          "Open a relevant example",
        ],
      },
      {
        heading: "Keep the state compact",
        body: [
          "A blank screen does not need a marketing page inside it. A direct heading, one sentence, and a clear action usually provide enough context without slowing down repeat users.",
        ],
      },
    ],
  },
  {
    slug: "quiet-debugging",
    title: "A Quieter Way to Debug Frontend Work",
    summary: "A short loop for isolating layout, state, and network problems without guessing.",
    topic: "Engineering",
    publishedAt: "2026-06-19",
    publishedLabel: "Jun 19",
    readTime: "7 min",
    intro:
      "Debugging gets faster when each experiment answers one question and changes one variable.",
    sections: [
      {
        heading: "Reduce the surface area",
        body: [
          "Reproduce the problem in the smallest route, viewport, and state you can. Disable unrelated animation and data refreshes so the signal does not move while you inspect it.",
        ],
      },
      {
        heading: "Use a short loop",
        body: [
          "State the current hypothesis before touching the code. Then run the smallest observation or change that could disprove it.",
        ],
        points: [
          "Observe the DOM, computed styles, state, and request timing.",
          "Change one variable.",
          "Reproduce from the same starting state.",
          "Keep or revert the change immediately.",
        ],
      },
      {
        heading: "Record the useful result",
        body: [
          "When the cause is subtle, leave a focused regression test or a short comment near the constraint. The goal is not to preserve the debugging story; it is to prevent the same ambiguity from returning.",
        ],
      },
    ],
  },
  {
    slug: "decision-notes",
    title: "Write Down the Decision, Not the Meeting",
    summary: "A tiny note format that preserves context without becoming more process.",
    topic: "Workflow",
    publishedAt: "2026-06-11",
    publishedLabel: "Jun 11",
    readTime: "6 min",
    intro:
      "A useful decision note lets someone understand what changed and why without replaying the meeting that produced it.",
    sections: [
      {
        heading: "Capture the durable parts",
        body: [
          "Attendance, discussion order, and every rejected sentence rarely matter later. The decision, the constraints behind it, and the consequences for future work usually do.",
        ],
        points: [
          "Decision: what the team will do",
          "Context: the constraint or evidence that shaped it",
          "Consequences: what becomes easier, harder, or intentionally unsupported",
        ],
      },
      {
        heading: "Keep one source of truth",
        body: [
          "Link the note from the issue, pull request, or project page where the work continues. Copies in several tools drift quickly and make a settled decision look unresolved.",
        ],
      },
      {
        heading: "Update decisions explicitly",
        body: [
          "A decision can change. Add a short superseding note and link the two records instead of silently rewriting the old context. Future readers can then see both the current direction and the reason it moved.",
        ],
      },
    ],
  },
  {
    slug: "honest-forms",
    title: "Shorter Forms Without Hidden Questions",
    summary: "Use grouping, timing, and defaults to reduce friction while staying honest.",
    topic: "Design",
    publishedAt: "2026-06-02",
    publishedLabel: "Jun 2",
    readTime: "5 min",
    intro:
      "A form feels short when every question arrives at the right moment and its purpose is easy to understand.",
    sections: [
      {
        heading: "Ask only when the answer matters",
        body: [
          "Collect information at the point where it changes the result. Questions needed only for a later workflow can wait until that workflow begins.",
        ],
      },
      {
        heading: "Use defaults without hiding choices",
        body: [
          "A default should reflect the most common safe answer and remain visible to the person submitting the form. Preselected values that are easy to miss save clicks by creating uncertainty elsewhere.",
        ],
        points: [
          "Group fields by the task they support.",
          "Explain why sensitive information is required.",
          "Reveal conditional questions as soon as the triggering choice is made.",
        ],
      },
      {
        heading: "Audit the work after submit",
        body: [
          "Reducing visible fields is not an improvement if the user must correct assumptions on the next screen. Review the entire path through confirmation and editing, not only the initial form length.",
        ],
      },
    ],
  },
] as const satisfies readonly BlogPost[];

export function getBlogPost(slug: string) {
  return BLOG_POSTS.find((post) => post.slug === slug);
}
```

### `src/blocks/templates/blog-post-template.tsx`

```tsx
import { ArrowLeft, Clock3, FileText } from "lucide-react";

import { buttonVariants } from "@/components/ui/button-variants";
import type { BlogPost } from "@/lib/blog-posts";

type BlogPostTemplateProps = {
  backHref?: string;
  post: BlogPost;
};

export default function BlogPostTemplate({ backHref = "/blog", post }: BlogPostTemplateProps) {
  return (
    <div id="top" className="flex min-h-dvh flex-col bg-background text-foreground">
      <header className="border-b-2 border-border bg-secondary-background">
        <div className="mx-auto flex h-12 w-full max-w-3xl items-center justify-between px-4 sm:px-6">
          <a
            href={backHref}
            className="group flex items-center gap-2 font-heading"
            aria-label="Worklog home"
          >
            <span className="grid size-7 place-items-center rounded-base border-2 border-border bg-main text-main-foreground shadow-shadow transition-[translate,box-shadow] duration-[140ms] ease-[ease] motion-reduce:transition-none group-hover:translate-x-pressX group-hover:translate-y-pressY group-hover:shadow-press group-focus-visible:translate-x-pressX group-focus-visible:translate-y-pressY group-focus-visible:shadow-press group-active:translate-x-boxShadowX group-active:translate-y-boxShadowY group-active:shadow-[0_0_0_0_var(--border)]">
              <FileText aria-hidden="true" className="size-3.5" />
            </span>
            <span className="text-sm">WORKLOG</span>
          </a>

          <a className="text-xs font-heading underline-offset-4 hover:underline" href={backHref}>
            All posts
          </a>
        </div>
      </header>

      <main className="mx-auto w-full max-w-3xl flex-1 px-4 py-8 sm:px-6 sm:py-12">
        <a href={backHref} className={buttonVariants({ size: "sm", variant: "neutral" })}>
          <ArrowLeft aria-hidden="true" />
          All posts
        </a>

        <article className="mt-8">
          <header>
            <p className="text-sm font-heading text-foreground/65">{post.topic}</p>
            <h1 className="mt-3 max-w-2xl text-4xl leading-tight font-heading sm:text-5xl">
              {post.title}
            </h1>
            <p className="mt-5 max-w-2xl text-lg leading-8 text-foreground/75">{post.summary}</p>
            <div className="mt-5 flex flex-wrap items-center gap-3 text-sm font-heading text-foreground/60">
              <time dateTime={post.publishedAt}>
                {post.publishedLabel}, {post.publishedAt.slice(0, 4)}
              </time>
              <span aria-hidden="true">/</span>
              <span className="flex items-center gap-1.5">
                <Clock3 aria-hidden="true" className="size-4" />
                {post.readTime} read
              </span>
            </div>
          </header>

          <div className="mt-8 border-y-2 border-border bg-secondary-background px-5 py-6 text-lg leading-8 sm:px-7">
            <p>{post.intro}</p>
          </div>

          <div className="mt-9 space-y-10">
            {post.sections.map((section, index) => {
              const headingId = `${post.slug}-section-${index + 1}`;

              return (
                <section key={section.heading} aria-labelledby={headingId}>
                  <h2 id={headingId} className="text-2xl leading-tight font-heading sm:text-3xl">
                    {section.heading}
                  </h2>
                  <div className="mt-4 space-y-4 text-base leading-8 text-foreground/80 sm:text-lg">
                    {section.body.map((paragraph) => (
                      <p key={paragraph}>{paragraph}</p>
                    ))}
                  </div>
                  {section.points ? (
                    <ul className="mt-5 list-[square] space-y-2 pl-6 leading-7 text-foreground/80">
                      {section.points.map((point) => (
                        <li key={point}>{point}</li>
                      ))}
                    </ul>
                  ) : null}
                </section>
              );
            })}
          </div>
        </article>
      </main>

      <footer className="border-t-2 border-border bg-secondary-background">
        <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-4 py-4 text-xs sm:px-6">
          <p className="font-heading">WORKLOG / 2026</p>
          <a href="#top" className="font-heading underline-offset-4 hover:underline">
            Back to top
          </a>
        </div>
      </footer>
    </div>
  );
}
```

### `src/blocks/templates/blog-template.tsx`

```tsx
"use client";

import { ArrowUpRight, Clock3, FileText, Search, X } from "lucide-react";
import { useMemo, useRef, useState } from "react";

import { Button } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button-variants";
import { Input } from "@/components/ui/input";
import { BLOG_POSTS } from "@/lib/blog-posts";

const TOPICS = ["All", ...new Set(BLOG_POSTS.map((post) => post.topic))];

type BlogTemplateProps = {
  basePath?: string;
};

function getPostHref(basePath: string, slug: string) {
  return `${basePath.replace(/\/$/, "")}/${slug}`;
}

export default function BlogTemplate({ basePath = "/blog" }: BlogTemplateProps) {
  const [query, setQuery] = useState("");
  const [topic, setTopic] = useState("All");
  const [sort, setSort] = useState("newest");
  const searchInputRef = useRef<HTMLInputElement>(null);

  const filteredPosts = useMemo(() => {
    const normalizedQuery = query.trim().toLowerCase();

    return BLOG_POSTS.filter(
      (post) =>
        (topic === "All" || post.topic === topic) &&
        [post.title, post.summary, post.topic].join(" ").toLowerCase().includes(normalizedQuery),
    ).sort((a, b) => {
      if (sort === "shortest")
        return Number.parseInt(a.readTime, 10) - Number.parseInt(b.readTime, 10);
      return sort === "oldest"
        ? a.publishedAt.localeCompare(b.publishedAt)
        : b.publishedAt.localeCompare(a.publishedAt);
    });
  }, [query, topic, sort]);

  return (
    <div id="top" className="flex min-h-dvh flex-col bg-background text-foreground">
      <header className="border-b-2 border-border bg-secondary-background">
        <div className="mx-auto flex h-12 max-w-3xl items-center justify-between px-4 sm:px-6">
          <a
            href="#top"
            className="group flex items-center gap-2 font-heading"
            aria-label="Worklog home"
          >
            <span className="grid size-7 place-items-center rounded-base border-2 border-border bg-main text-main-foreground shadow-shadow transition-[translate,box-shadow] duration-[140ms] ease-[ease] motion-reduce:transition-none group-hover:translate-x-pressX group-hover:translate-y-pressY group-hover:shadow-press group-focus-visible:translate-x-pressX group-focus-visible:translate-y-pressY group-focus-visible:shadow-press group-active:translate-x-boxShadowX group-active:translate-y-boxShadowY group-active:shadow-[0_0_0_0_var(--border)]">
              <FileText aria-hidden="true" className="size-3.5" />
            </span>
            <span className="text-sm">WORKLOG</span>
          </a>

          <nav
            className="flex items-center gap-3 text-xs font-heading"
            aria-label="Primary navigation"
          >
            <a className="underline-offset-4 hover:underline" href="#posts">
              Posts
            </a>
            <a className="underline-offset-4 hover:underline" href="#post-search">
              Search
            </a>
          </nav>
        </div>
      </header>

      <main className="mx-auto w-full max-w-3xl flex-1 px-4 py-8 sm:px-6 sm:py-10">
        <section aria-labelledby="page-title">
          <p className="mb-3 font-mono text-xs uppercase tracking-widest">
            Field notes / Design & engineering
          </p>
          <h1 id="page-title" className="text-3xl leading-tight font-heading sm:text-4xl">
            Latest posts
          </h1>

          <p className="mt-3 max-w-lg text-sm leading-6 text-foreground/75">
            Ideas from the workbench. Practical notes on building products that stay useful.
          </p>
          <search className="mt-6">
            <label className="sr-only" htmlFor="post-search">
              Search posts
            </label>
            <div className="relative">
              <Search
                aria-hidden="true"
                className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
              />
              {/* Keep one explicit clear action instead of browser-specific search controls. */}
              <Input
                ref={searchInputRef}
                id="post-search"
                type="text"
                role="searchbox"
                enterKeyHint="search"
                value={query}
                onChange={(event) => setQuery(event.target.value)}
                placeholder="Search posts..."
                className="h-11 bg-secondary-background pr-11 pl-10"
              />
              {query ? (
                <Button
                  type="button"
                  size="icon-xs"
                  variant="ghost"
                  className="absolute top-1/2 right-2 -translate-y-1/2"
                  onClick={() => {
                    setQuery("");
                    searchInputRef.current?.focus();
                  }}
                  aria-label="Clear search"
                  title="Clear search"
                >
                  <X aria-hidden="true" />
                </Button>
              ) : null}
            </div>
          </search>
          <div className="mt-4 flex flex-wrap items-end justify-between gap-4">
            <div role="group" aria-label="Filter posts by topic" className="flex flex-wrap gap-2">
              {TOPICS.map((item) => (
                <Button
                  key={item}
                  type="button"
                  size="sm"
                  variant={topic === item ? "default" : "neutral"}
                  aria-pressed={topic === item}
                  onClick={() => setTopic(item)}
                >
                  {item}
                </Button>
              ))}
            </div>
            <label className="grid gap-1 text-xs font-heading">
              Sort posts
              <select
                value={sort}
                onChange={(event) => setSort(event.target.value)}
                className="min-h-10 rounded-base border-2 border-border bg-secondary-background px-3 text-sm text-foreground"
              >
                <option value="newest">Newest first</option>
                <option value="oldest">Oldest first</option>
                <option value="shortest">Shortest read</option>
              </select>
            </label>
          </div>
          <p role="status" className="mt-4 text-xs text-foreground/70">
            {filteredPosts.length} {filteredPosts.length === 1 ? "post" : "posts"}
            {topic === "All" ? " across all topics" : ` in ${topic}`}
          </p>
        </section>

        <section id="posts" className="mt-4 scroll-mt-4" aria-label="Post archive">
          {filteredPosts.length > 0 ? (
            <ol className="overflow-hidden rounded-base border-2 border-border bg-secondary-background shadow-shadow">
              {filteredPosts.map((post, index) => (
                <li key={post.slug} className="border-b-2 border-border last:border-b-0">
                  <article className="grid grid-cols-[minmax(0,1fr)_2.25rem] gap-x-3 gap-y-2 p-4 sm:grid-cols-[6rem_minmax(0,1fr)_2.25rem] sm:items-center sm:gap-4 sm:p-5">
                    <time
                      className="col-span-2 text-xs font-heading text-foreground/60 sm:col-span-1"
                      dateTime={post.publishedAt}
                    >
                      {post.publishedLabel}
                    </time>

                    <div className="min-w-0">
                      {index === 0 && !query && topic === "All" && sort === "newest" ? (
                        <span className="mb-2 inline-block bg-main px-2 py-1 text-xs font-heading text-main-foreground">
                          Editor's pick
                        </span>
                      ) : null}
                      <h2 className="text-lg leading-snug font-heading sm:text-xl">
                        <a
                          className="underline-offset-4 hover:underline"
                          href={getPostHref(basePath, post.slug)}
                        >
                          {post.title}
                        </a>
                      </h2>
                      <p className="mt-1 text-sm leading-5 text-foreground/70">{post.summary}</p>
                      <p className="mt-2 flex items-center gap-3 text-xs font-heading text-foreground/60">
                        <span>{post.topic}</span>
                        <span className="flex items-center gap-1">
                          <Clock3 aria-hidden="true" className="size-3.5" />
                          {post.readTime}
                        </span>
                      </p>
                    </div>

                    <a
                      href={getPostHref(basePath, post.slug)}
                      aria-label={`Read ${post.title}`}
                      title={`Read ${post.title}`}
                      className={buttonVariants({
                        size: "icon-sm",
                        variant: "ghost",
                        className: "self-center",
                      })}
                    >
                      <ArrowUpRight aria-hidden="true" />
                    </a>
                  </article>
                </li>
              ))}
            </ol>
          ) : (
            <div className="grid justify-items-center gap-4 border-2 border-dashed border-border py-10">
              <p className="text-sm text-foreground/70">No posts found.</p>
              <Button
                type="button"
                variant="neutral"
                onClick={() => {
                  setTopic("All");
                  setQuery("");
                  searchInputRef.current?.focus();
                }}
              >
                Reset filters
              </Button>
            </div>
          )}
        </section>
      </main>

      <footer className="border-t-2 border-border bg-secondary-background">
        <div className="mx-auto max-w-3xl px-4 py-4 text-xs sm:px-6">
          <p className="font-heading">WORKLOG / 2026</p>
        </div>
      </footer>
    </div>
  );
}
```

### `src/blocks/templates/pages/blog/page.tsx`

```tsx
import BlogTemplate from "@/components/templates/blog-template";

export default function Page() {
  return <BlogTemplate />;
}
```

### `src/blocks/templates/pages/blog/[slug]/page.tsx`

```tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";

import BlogPostTemplate from "@/components/templates/blog-post-template";
import { BLOG_POSTS, getBlogPost } from "@/lib/blog-posts";

type BlogPostPageProps = {
  params: Promise<{ slug: string }>;
};

export function generateStaticParams() {
  return BLOG_POSTS.map(({ slug }) => ({ slug }));
}

export async function generateMetadata({ params }: BlogPostPageProps): Promise<Metadata> {
  const { slug } = await params;
  const post = getBlogPost(slug);

  if (!post) return {};

  return {
    title: post.title,
    description: post.summary,
  };
}

export default async function BlogPostPage({ params }: BlogPostPageProps) {
  const { slug } = await params;
  const post = getBlogPost(slug);

  if (!post) notFound();

  return <BlogPostTemplate post={post} />;
}
```
