2023 Blog Refresh

November 19, 2023 (1y ago)

I updated my blog this weekend and wanted to share some thoughts along the way:

⚠️

Disclaimer: My site is my “breakable toy”. I enjoy and intentionally change technology and try new patterns here. I'd encourage you to do the same and then write about why you made those choices.

Content Management

I've moved my content from HTML to vanilla Markdown, to MDX, to a CMS, and back to MDX over the years. My content requirements as of now are:

  1. Written in Markdown¹
  2. Support for syntax highlighting, embedded tweets, and other components
  3. Managed through version control²
  4. Minimal external dependencies

My goal was to simplify without giving up too many features.

Retrieving Content

You can go surprisingly far with just Node.js and JavaScript. You'll notice a theme start to emerge in this blog post: fewer dependencies, and more copy/paste-able code.

I removed the following libraries:

  • contentlayer
  • next-contentlayer
  • rehype-autolink-headings
  • rehype-pretty-code
  • rehype-slug
  • remark-gfm
  • shiki

I was still able to maintain almost all of my content requirements with not much code. For example, here's how I'm able to retrieve all of my blog posts:

import fs from 'fs';
import path from 'path';

function getMDXFiles(dir) {
  return fs.readdirSync(dir).filter((file) => path.extname(file) === '.mdx');
}

function readMDXFile(filePath) {
  let rawContent = fs.readFileSync(filePath, 'utf-8');
  return parseFrontmatter(rawContent);
}

function getMDXData(dir) {
  let mdxFiles = getMDXFiles(dir);
  return mdxFiles.map((file) => {
    let { metadata, content } = readMDXFile(path.join(dir, file));
    let slug = path.basename(file, path.extname(file));
    return {
      metadata,
      slug,
      content,
    };
  });
}

export function getBlogPosts() {
  return getMDXData(path.join(process.cwd(), 'content'));
}

That's not too bad. So what am I missing? Well, Contentlayer gives you Fast Refresh for your content. That's nice. You can workaround this or just use @next/mdx, which I might do. Contentlayer has other features, too, but they're unnecessary for my blog.

Remark and Rehype

What about the AST modifications for auto-linking headings, adding IDs, and supporting syntax highlighting? I don't know why I didn't think of this before, but you can just… use React components.

import { highlight } from 'sugar-high'; // 1KB new dependency

// This replaces rehype-pretty-code and shiki
function Code({ children, ...props }) {
  let codeHTML = highlight(children);
  return <code dangerouslySetInnerHTML={{ __html: codeHTML }} {...props} />;
}

// This replaces rehype-slug
function slugify(str) {
  return str
    .toString()
    .toLowerCase()
    .trim() // Remove whitespace from both ends of a string
    .replace(/\s+/g, '-') // Replace spaces with -
    .replace(/&/g, '-and-') // Replace & with 'and'
    .replace(/[^\w\-]+/g, '') // Remove all non-word characters except for -
    .replace(/\-\-+/g, '-'); // Replace multiple - with single -
}

// This replaces rehype-autolink-headings
function createHeading(level) {
  return ({ children }) => {
    let slug = slugify(children);
    return React.createElement(
      `h${level}`,
      { id: slug },
      [
        React.createElement('a', {
          href: `#${slug}`,
          key: `link-${slug}`,
          className: 'anchor',
        }),
      ],
      children
    );
  };
}

Oh and that remark-gfm I was using for the GitHub style Markdown tables? Again, you can use a React component for that.

function Table({ data }) {
  let headers = data.headers.map((header, index) => (
    <th key={index}>{header}</th>
  ));
  let rows = data.rows.map((row, index) => (
    <tr key={index}>
      {row.map((cell, cellIndex) => (
        <td key={cellIndex}>{cell}</td>
      ))}
    </tr>
  ));

  return (
    <table>
      <thead>
        <tr>{headers}</tr>
      </thead>
      <tbody>{rows}</tbody>
    </table>
  );
}

Dependency Minimalism

Why go through all of this work to delete dependencies?

After having a blog for 10 years, I've gained a new appreciation for keeping things simple. Now, I'm not going full minimalist here. I still want nice things. But I'm taking more opportunities to simplify and keep more code managed in the repo.

Shoutout to next-mdx-remote and react-tweet.

Performance

Earlier this year, I moved this blog to the Next.js App Router. That came with a subtle but important change: React Server Components by default.

Server Components

Server Components are fun. For example, I can embed tweets inside blog posts:

<Tweet id="1457032789883187201" />

The Tweet component handles both data fetching and creating UI, all bundled up in a handy npm package (worth an exception here). Notably, additional Server Components added to my pages aren't increasing the client-side JavaScript bundle. This “templating” remains server-only.

Another example is on my work page. I love that I can drop this component in anywhere:

async function Stars() {
  let res = await fetch('https://api.github.com/repos/vercel/next.js');
  let json = await res.json();
  let count = Math.round(json.stargazers_count / 1000);
  return `${count}k stars`;
}

Partial Prerendering

We're also working on something new in Next.js I'm now using here. It enables Next.js to prerender as much of the page as possible to static, leaving holes for dynamic components.

For example, 98% of this blog post page is work that can be prerendering during the build. However, those view counts at the top of the page should be dynamic on every request. With Partial Prerendering, the code for that looks like:

async function Views({ slug }: { slug: string }) {
  let views = await getViewsCount();
  incrementViews(slug);
  // ...
}

export default function Blog({ params }) {
  let post = getBlogPosts().find((post) => post.slug === params.slug);
  // ...

  return (
    <section>
      <Suspense fallback={<p className="h-5" />}>
        <Views slug={post.slug} />
      </Suspense>
      <article>{post.content}</article>
    </section>
  );
}

Everything up to the Suspense boundary, including the fallback, can be prerendered. Then, when the request happens, the static shell is instantly shown, followed by the dynamic content (views) streaming in after the fact.

getViewsCount signals to Next.js that it's dynamic through noStore():

export async function getViewsCount() {
  noStore();
  let data = await sql`
    SELECT slug, count
    FROM views
  `;

  return data.rows as { slug: string; count: number }[];
}

And incrementViews is a Server Action that can be called like any JavaScript function:

'use server';

import { sql } from '@vercel/postgres';

export async function increment(slug: string) {
  await sql`
    INSERT INTO views (slug, count)
    VALUES (${slug}, 1)
    ON CONFLICT (slug)
    DO UPDATE SET count = views.count + 1
  `;
}

I'm really happy with this approach. In the past, I had to include more dependencies like swr and add additional client-side JavaScript to achieve this. Partial Prerendering feels fast, without giving up the dynamic view counts that I love.

Replying to @rauchg

This solves a huge number of problems 🤯 ◆ Better DX: no thinking about runtimes ◆ Computing close the database ◆ No client data-fetching waterfalls ◆ Tiny client JS bundles ◆ Amortization of the "boot up" of dynamic parts ◆ PHP-style, secure server-side data fetching

66
Reply

Opinions

There's an assortment of other opinions I've included here now:

  • Prefer let over const: It's fewer characters to write. I'm lazy. Also, this isn't a production codebase here or anything. Nice post here on this topic.
  • Prefer copy/paste over the wrong abstraction: I removed date-fns, clsx / classnames, and gray-matter for simple copy-pastable alternatives. Sure, they don't support every single feature. But, I don't need it. And if I do later, maybe I'll bring it back.
  • Prefer loading SVGs as images: Okay, this one has been hard for me. I've gotten so used to inlining SVGs in React. But starting to change my mind. Related reading here and on svg sprites.
  • Prefer larger files versus many components: Just works for my brain better. Keep code that changes often close together. Your mileage may vary.
  • Prefer colocating most things in app/: Because, why not? Now that it's possible with the App Router I'm probably overusing this, but it feels nice.
  • Prefer colocating styles with components: Okay, I've been using Tailwind now for many years, but this is still worth mentioning. Similar to my content evolution, I went from CSS → Sass → styled-components → Chakra → Tailwind. Give it a shot. P.S. LLMs are very good at Tailwind.

Conclusion

As always, my entire site is open source. Feel free to fork it and hack around.

I've also simplified the cloning process by consolidating everything to a single Postgres database. It's now where blog views, guestbook entries, and redirects are stored. I still like MySQL, just wanted an excuse to use Vercel Postgres more.

See you next year for the 2024 blog refresh 🫡


¹: Even when I moved to a CMS, I was still writing in MDX. For my developer blog, it's by far the easiest for me to maintain. I've gone the opposite route with custom components backed by a CMS on larger projects, but has always felt unnecessary here.

²: Surprisingly, there still aren't many great git-like workflows for content that I've enjoyed using. Again, for my blog, having the complete version history tracked in git has been very helpful.