Zach Scroggins
  • Portfolio
  • About
  • Contact
  • Blog
Download Resume
GitHubLinkedInTwitter
Zach Scroggins
  • Portfolio
  • About
  • Contact
  • Blog
LinkedInGitHub
Resume
  • Home
  • Portfolio
  • About
  • Contact
  • Blog
  • GitHub
  • LinkedIn
  • Twitter
  • Facebook
  • Instagram
© 2026 Zach Scroggins
Portfolio
About
Contact
Blog
Blog

Post · November 5, 2026

How I Built This Blog

  • Next.js
  • React
  • TypeScript
  • Sanity
  • Tailwind CSS
  • shadcn/ui
  • Vercel
  • Claude Code

My first blog post, back in 2021, was a demo. It dropped a React component into the middle of some Markdown to prove that MDX worked. It did work, and then I wrote almost nothing else. The setup was fun to build and tedious to write in.

This is the rebuild. The goal is the same: writing a post should take less effort than putting it off. Nearly everything underneath has changed, and so has the way I built it.

The stack

  • Next.js 16 with the App Router, Cache Components and the React Compiler
  • React 19 and TypeScript, with types generated from the content schema
  • Sanity for content, edited in a Studio that lives in its own repo
  • Tailwind CSS 4 and shadcn/ui, with shadcn's Typeset styles for post bodies
  • sugar-high for syntax highlighting, rendered on the server
  • Vercel for hosting, preview deployments and production

Content lives outside the code

The old blog stored MDX strings in Sanity and compiled them on the server with mdx-bundler. It worked, but the content was really code. A typo in a JSX tag broke the page, and the CMS had no way to warn me first.

Posts are now Portable Text, Sanity's JSON format for rich text. A paragraph is data. A code block is data with a language and a filename. An image is data with required alt text. The site decides how each piece renders, and the Studio checks each piece before I can publish it. I wrote up the Sanity side in Using Sanity to Manage Blog Content.

The site and the Studio are two repos in one folder. The Studio generates TypeScript types straight into the site repo, so a schema change shows up as a type error in the code that reads it.

The Studio deploys itself, too. Merging a pull request into the Studio repo runs a GitHub Actions workflow that installs the dependencies and runs the repo's own sanity deploy, so the hosted Studio always matches main:

.github/workflows/deploy-studio.yml
on:
  push:
    branches: [main]
    paths-ignore: ['**.md', 'functions/**', 'sanity.blueprint.ts']
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx sanity deploy --yes
        env:
          SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }}

Cache Components: static by default, dynamic on purpose

Next.js 16 changes the default. With cacheComponents on, data is only cached where I say so with 'use cache', and anything that reads the request (cookies, headers, search params) has to sit inside a Suspense boundary. That is stricter, and that's the point: every page is a static shell plus the parts I chose to leave dynamic.

next.config.ts
import type { NextConfig } from 'next';
import { sanity } from 'next-sanity/live/cache-life';

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: { default: sanity },
  partialPrefetching: true,
  reactCompiler: true,
  experimental: {
    turbopackRustReactCompiler: true,
  },
};

export default nextConfig;

Blog posts and tag pages are prerendered at build time from generateStaticParams. Every Sanity fetch is tagged, so a publish can invalidate only the pages that used that document instead of rebuilding the site.

The React Compiler handles memoization, so none of my own components need useMemo or useCallback.

Two lessons from the strict model

With partialPrefetching on, /blog/[slug] failed to prerender with an error about reading URL data outside of Suspense. The fix was a loading.tsx next to the page, which gives the route a boundary to stream into, and awaiting params in the page itself.

The same rule applies to client hooks. The Blog link in the header is underlined on any blog page, which means reading the pathname with usePathname. For a slug that wasn't known at build time, that hook suspends during prerendering, so the link wraps itself in Suspense and uses the plain link as the fallback:

components/nav-link.tsx
export function NavLink({ activeClassName, ...props }: NavLinkProps) {
  return (
    <Suspense fallback={<Link {...props} />}>
      <ActiveNavLink activeClassName={activeClassName} {...props} />
    </Suspense>
  );
}

function ActiveNavLink({ href, className, activeClassName, ...props }: NavLinkProps) {
  const pathname = usePathname();
  const isCurrent = pathname === href;
  const isActive = isCurrent || pathname.startsWith(`${href}/`);

  return (
    <Link
      href={href}
      aria-current={isCurrent ? 'page' : isActive ? 'true' : undefined}
      className={cn(className, isActive && activeClassName)}
      {...props}
    />
  );
}

Styling: Tailwind 4, shadcn/ui and Typeset

The site uses shadcn/ui components and theme tokens throughout, so light and dark mode come from the same set of CSS variables. For post bodies I chose shadcn's Typeset over @tailwindcss/typography. Typeset is one CSS file that lives in my repo, and a few custom properties tune it:

app/globals.css
.typeset-blog {
  --typeset-size: 1.0625rem;
  --typeset-leading: 1.8;
  --typeset-flow: 1.25em;
  color: var(--color-muted-foreground);
}

Code blocks are highlighted on the server with sugar-high, using colors from the same theme. That has its own post: Syntax Highlighting with sugar-high.

SEO without a plugin

Next.js covers all of this with file conventions:

  • app/sitemap.ts lists every post and tag from Sanity.
  • A route handler serves an RSS feed at /blog/rss.xml.
  • Each post and tag gets an Open Graph image from next/og, with my logo, the post type and the title.
  • generateMetadata builds titles, descriptions and canonical URLs from the same queries the pages use.

Every metadata query runs with stega: false. Visual Editing hides invisible characters in strings so the Studio can map text back to fields, and those characters don't belong in a page title or a search result.

Built with an AI pair programmer

I built this with Claude Code, and the way we worked mattered as much as the stack.

  1. Plan before code. We went back and forth on the content model, the tag pages and the preview setup before writing anything. Most of my input went into decisions, not syntax.
  2. Give the agent current docs. Next.js 16 ships its docs inside node_modules, and the repo's AGENTS.md tells agents to read them before writing code, because a model's training data trails the framework. Sanity's Agent Toolkit adds skills for schema design and for wiring Sanity Live into Cache Components, and Sanity's MCP server lets the agent query and edit content directly.
  3. An AGENTS.md in every folder. The parent folder, the site and the Studio each explain what they are and how they connect, so any agent picks up the context, not just Claude.
  4. Check it in a real browser. A passing build isn't the bar. Every change gets checked in the running app: light and dark mode, desktop and phone widths, hover states included.
  5. Keep notes outside the chat. The plan, decisions and a work log live in my Obsidian vault, so the next session starts where the last one stopped.

Everything lands on a feature branch, Vercel builds a preview, and I review the pull request before it merges.

Publishing without a rebuild

When I publish in the Studio, a Sanity Function receives the sync tags for the content that changed and sends them to a route on this site. The route checks a shared secret and expires just those cache entries. Once the function reports back, any open page refreshes itself. A typo fix is live on the site a few seconds after I click Publish, with no build and no deploy.

What's next

The real goal now is simply writing more posts.