smoores.dev

Building stuff on the Internet.

What is smoores.dev?

Nov. 30, 2024

Recently, as I decided to reinvest in the contents of my personal blog, I also decided to rebuild it. Previously, it had been built as a very simple Flask app, with posts written in Jinja-templated HTML, and relied on the PyPI package Frozen-Flask to export the contents to static assets for deployment. This setup always required a bit more effort than I liked, but I really enjoyed the freedom that came with writing my posts as essentially plain HTML. So I gave my blogging system another shot, this time relying on React Server Components and Next.js to build out a system that was hopefully more fun to work on and easier to maintain.

Requirements

Like any project, this one had some requirements, and it’s worth laying them out explicitly:

  1. The blog contents need to be static. I’m comfortable with adding some nice developer experience improvements for myself, but not at the expense of the reader. At the end of the day, I want to be serving static HTML, CSS, and JavaScript assets.

  2. The blog contents should be written with HTML, or at least HTML-like markup. Look, I’m a web developer. I like web technologies like HTML. I’m very comfortable with them, and I enjoy using them to express myself. And it’s tough to match the expressiveness of HTML for semantic text content!

  3. It should work exactly the same without JavaScript. Unless some future post adds client-side interaction for some reason (not out of the question, but not super likely), there’s no reason at all that readers should need JavaScript to get the entire experience of reading the blog.

  4. Boilerplate should be at least mostly automated. This includes things like the site header and footer, which are the same on every page, but also includes adding posts to the home page and the Atom syndication feed. Speaking of which...

  5. There must be a syndication feed (ideally Atom 1.0). I don’t know how many folks still use RSS readers to follow blogs, and I certainly don’t know if a single person has ever subscribed to my RSS/Atom feed, but I just love the philosophy of these syndication systems too much to miss an opportunity to participate in them.

Luckily, this is a pretty short list of requirements. Even more luckily, Next.js 15 had just been released, with stable support for React Server Components. Server Components allow developers to use React to author components that render to HTML entirely on the server, without any client-side logic whatsoever. This is distinct from earlier versions of Next.js (< 14), which supported Server-Side Rendering (SSR), which rendered a first pass of the component tree on the server, and then “hydrated” the entire React tree on the client with client-side JavaScript. This hydration step occured even if there are no interactive components!

The Basics

Next.js’ “App Router” implementation actually directly enables the first four of our requirements. We can use a layout component to implement the header and footer, and then implement each post as its own React component.

Here’s our layout:

app/layout.tsx
import type { Metadata } from "next";
import { Vollkorn } from "next/font/google";
import "./globals.css";
import Script from "next/script";

const vollkorn = Vollkorn({
subsets: ["latin"],
variable: "--font-vollkorn",
weight: "variable",
});

export const metadata: Metadata = {
title: "smoores.dev",
description:
"I'm Shane. I build stuff that lives on the internet, and sometimes I write about it, too. If you want to learn a little bit more about me, you can check out my résumé at https://resume.smoores.dev.",
};

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="h-full">
<body
className={`${vollkorn.variable} m-0 flex h-full flex-col justify-between text-foreground antialiased`}
>
<div className="mx-auto my-0 w-[calc(100%-2rem)] md:w-page">
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
<a href="/" className="block">
<header>
<h1 className="mb-0 mt-6 text-4xl font-bold text-primary">
smoores.dev
</h1>
<p className="mt-2 text-base text-primary">
Building stuff on the Internet.
</p>
</header>
</a>
<main className="mt-12 w-full md:w-page">{children}</main>
</div>
<footer className="mt-8 p-8 text-sm">
<a href="/about_fathom" className="hover:underline">
This website does not track you! Click here for more information
about the analytics I gather.
</a>
</footer>
<Script src="/tracking.js"></Script>
</body>
</html>
);
}

Each page on the website will be wrapped in the markup above.

Speaking of which, we’d like to generate a page, with it’s own URL, for each post. I decided to implement this with a single page component that imports an array of post metadata and components and renders a static page for each one:

app/post/[slug]/page.tsx
import { posts } from "@/posts";
import { Metadata } from "next";
import { notFound } from "next/navigation";

interface Props {
params: Promise<{
slug: string;
}>;
}

/**
* This is just a simple wrapper that renders the appropriate
* post content.
*/
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = posts.find(({ metadata }) => metadata.slug === slug);
if (!post) {
return notFound();
}

return <post.Component />;
}

/**
* For each page, use the title and description to produce
* the HTML metadata.
*/
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = posts.find(({ metadata }) => metadata.slug === slug);
if (!post) {
return notFound();
}
return {
title: `smoores.dev - ${post.metadata.title}`,
description: post.metadata.description,
openGraph: {
type: "article",
},
};
}

/**
* We generate one page URL for each post
*/
export async function generateStaticParams() {
return posts.map((post) => ({
slug: post.metadata.slug,
}));
}

To power this page generation, we need to define this posts array that gets imported here:

posts/index.ts

export const posts = [
{ Component: WhatIsSmooresDev, metadata: whatIsSmooresDev },
{ Component: OvercomingIoLimits, metadata: overcomingIoLimits },
{ Component: PhoneticMatching, metadata: phoneticMatching },
{
Component: BackToBasics,
metadata: backToBasics,
},
{
Component: EsniPrivateByDefault,
metadata: esniPrivateByDefault,
},
{
Component: DockerizingLegacyScoop,
metadata: dockerizingLegacyScoop,
},
{
Component: SmtpConversation,
metadata: smtpConversation,
},
];

Each post exports a Server Component with the contents of the post, and a metadata object with the metadata for that post. Here’s what that looks like for this post:

posts/WhatIsSmooresDev.tsx

export const metadata: Metadata = {
title: "What is smoores.dev?",
slug: "what_is_smoores_dev",
date: "Nov. 30, 2024",
description:
"Recently, as I decided to reinvest in the contents of my personal blog, I also decided to rebuild it. Previously, it had been built as a very simple Flask app, with posts written in Jinja-templated HTML, and relied on the PyPI package Frozen-Flask to export the contents to static assets for deployment. This setup always required a bit more effort than I liked, but I really enjoyed the freedom that came with writing my posts as essentially plain HTML. So I gave my blogging system another shot, this time relying on React Server Components and Next.js to build out a system that was hopefully more fun to work on and easier to maintain.",
};

export function WhatIsSmooresDev() {
return (
<article>
<UnderlinedHeading>{metadata.title}</UnderlinedHeading>
<DateLine date={metadata.date} />
<section id="intro">
<Paragraph>
<LeadIn>Recently</LeadIn>, as I decided to reinvest in the contents of
my personal blog, I also decided to rebuild it. Previously, it had
been built as a very simple Flask app, with posts written in
Jinja-templated HTML, and relied on the PyPI package Frozen-Flask to
export the contents to static assets for deployment. This setup always
required a bit more effort than I liked, but I really enjoyed the
freedom that came with writing my posts as essentially plain HTML. So
I gave my blogging system another shot, this time relying on React
Server Components and Next.js to build out a system that was hopefully
more fun to work on and easier to maintain.
</Paragraph>
</section>
...
</article>
);
}

This is pretty great. I can write each post in its own file, focusing just on the contents, and using JSX to mark them up. For the most part, I have standard reusable components, styled with Tailwind CSS, that most of my posts use for markup. I’ve also recently begun using Code Hike’s Bright package for a Server Component-based approach to code highlighting, which is what’s powering all of the code blocks in this post, for example.

Atom Feeds

This just leaves one requirement: a syndication feed. The standard protocol for web syndication is RSS (Really Simple Syndication), which is an XML-based protocol that allows clients to subscribe to a feed provider (like this blog) and check for updates at some interval. I ended up choosing an alternative protocol — Atom — for this, because it’s generally easier to work with and addresses some of the issues with RSS, but the same principles apply.

This means that I need to export a route that returns content other than HTML (specifically, it needs to return application/atom+xml). I’m doing this with a Next.js route handler, which is just a function that takes a Request and returns a Response.

Atom feeds can (and, in my opinion, should) contain the entire contents of each entry, when possible. In order to accomplish this with this Next.js app, we’ll need to use react-dom/server to programmatically render our post components to markup strings, and then HTML escape them to add them to our entries. Specifically, we’d like to import renderToReadableStream, which will allow us to asynchronously render each component and wait for any Suspense boundaries to resolve (this is important, because Bright’s <Code /> components use Suspense!):

app/recent.atom/route.ts
import { renderToReadableStream } from "react-dom/server";

export const dynamic = "force-static";

export async function GET() { ... }

Unfortunately, Next.js doesn’t like this. It works in development, but when we try to run a static production build, it throws an error:

app/recent.atom/route.ts
Failed to compile.

./src/app/recent.atom/route.ts
Error: x You're importing a component that imports react-dom/server. To fix it, render or return the content directly as a Server Component instead for perf and security.

I guess I understand what they’re going for here (in nearly any other context, this would be indicative that something was wrong), but it’s a little frustrating that this error isn’t configurable in any way. There is a way around it, though — we can import the module dynamically, within the handler function:

app/recent.atom/route.ts
import type {
ReactDOMServerReadableStream,
RenderToReadableStreamOptions,
} from "react-dom/server";

// This tells Next.js to place the result of this
// function in a static file at build time
export const dynamic = "force-static";

export async function GET() {
// This has to be dynamically imported to work around this:
// https://github.com/vercel/next.js/issues/43810
const renderToReadableStream: (
node: ReactNode,
options?: RenderToReadableStreamOptions,
) => Promise<ReactDOMServerReadableStream> =
// @ts-expect-error We have to import from server.browser, because
// otherwise we get the node.js file, which doesn't expose renderToReadableStream
// (even though we have ReadableStreams available, because Next.js makes them available)
(await import("react-dom/server.browser")).renderToReadableStream;
...
}

This is goofier than I’d like, but this is a small personal project, with code that ought to be touched only very infrequently, and only by me. So I decided that was good enough.

Now it’s time to actually set up these atom entries. We can import the same posts array that we use for generating the pages, and for each post, call renderToReadableStream to retrieve the serialized markup. That method actually returns an extension of ReadableStream specifically for use cases like this, where we need to statically render out the entire contents of the component for search engine crawlers or feed readers, rather than streaming the results incrementally, like we would for an interactive browser client. We can use it like this:

app/recent.atom/route.ts
const postEntries = await Promise.all(
posts.map(async (post) => {
const markupBytesStream = await renderToReadableStream(
createElement(post.Component),
);

// This is the ReactDOM extension for this use case:
// > A Promise that resolves when all rendering is complete, including both the shell and all additional content.
// https://19.react.dev/reference/react-dom/server/renderToReadableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
await markupBytesStream.allReady;

// Normally, this would be streamed to the client as bytes.
// In our case, we want to serialize it to text and save the
// output to a file, so we pipe it through a text decoder first
const markupStream = markupBytesStream.pipeThrough(
new TextDecoderStream(),
);

// Then we aggregate the string chunks into the final markup
// string
let markup = "";
for await (const chunk of markupStream as ReadableStream<string>) {
markup += chunk;
}

...
})
)

Now we can just use the escapeHTML function from the escape-html npm package (since Atom content represented as HTML must be escaped), and generate our XML feed representation. There are a few (somewhat stale) projects on npm for generating Atom feeds, but generating XML is much more straightforward than parsing it, and in the end I decided to just read the Atom 1.0 spec myself and use some simple string interpolation:

app/recent.atom/route.ts
const response = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title type="text">smoores.dev - Recent Posts</title>
<author>
<name>${author}</name>
<uri>https://resume.smoores.dev</uri>
</author>
<icon>https://smoores.dev/favicon.ico</icon>
<id>https://smoores.dev/recent.atom</id>
<updated>${latestUpdated.updated}</updated>
<link href="https://smoores.dev/" />
<link href="https://smoores.dev/recent.atom" rel="self" />
${postEntries
.map(
(post) => ` <entry>
<title type="text">${post.title}</title>
<id>https://smoores.dev${post.path}</id>
<updated>${post.updated}</updated>
<published>${post.published}</published>
<link href="https://smoores.dev${post.path}" />
<author>
<name>${post.author}</name>
<uri>https://resume.smoores.dev</uri>
</author>
<summary type="text">${post.summary}</summary>
<content type="html" xml:lang="en" xml:base="http://smoores.dev${post.path}">
${post.content}
</content>
</entry>
`,
)
.join("
")}
</feed>
`;

Deployment

That’s it for the code. It feels about as simple as it can get without hand-writing static files, which would be too error-prone for my liking. Now we just need to deploy it!

I run the server that hosts https://smoores.dev/ myself, and often when I’m deploying applications to it, I set up a CI workflow in GitLab that builds a docker container or some other artifacts, and configure my server to run that container, periodically checking for updates. That can make a lot of sense for full-stack applications like Storyteller, which have their own databases, dynamic backends, and interactive clients. But it absolutely felt like overkill for this website, especially given that I made sure it could be rendered out to static files!

Instead, I configured Next.js to export the entire site to static files during the build:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
output: "export",
distDir: "build",
trailingSlash: true,
};

export default nextConfig;

And then I removed the build/ directory from the project’s gitignore file. That means that a publish is now as simple as running deno task build on my development machine and committing the results. To update the deployment, I landed on the tried-and-true “ssh in and pull the latest”, which instantly updates the results, since I’m just serving the static files from my Caddy reverse proxy.

Conclusion

So far, I’m having a blast working on this blog. The freedom that comes from writing in JSX is a joy, and I genuinely find React development fun and energizing.

If you want to check out the rest of the code that powers this blog in a bit more depth, it’s open source and hosted on GitLab.