Until this month, every post on this blog lived inside one JavaScript file. blogData.js held an array of posts, and each post was a list of blocks: paragraphs, headings, lists and code. A single page, article.html, loaded that file, read the slug from the URL (article.html?slug=css-box-model-basics), found the matching post and drew it on the screen.
For a person with a browser, it worked. For anything that reads HTML without running JavaScript, every post was the same empty page, titled "Article · Jared Newnam" with the description "Article by Jared Newnam."
I did the move with Claude Code, an AI coding agent, and wrote up what it got wrong and how we caught it in a separate post. This one covers the move itself.
Why the HTML matters
When you open a page, your browser downloads the HTML first. If that HTML already contains the article, you see it right away, and so does every tool that reads the page. That's a static page.
With client-side rendering, the HTML arrives mostly empty. JavaScript runs, finds the content and builds the article inside the browser. Visitors see the same result a moment later. Tools that don't run JavaScript only ever see the empty shell.
Google does run JavaScript, but rendering happens in a later pass after it crawls the page, so a JS-only page waits in line before Google sees the words. Link previews are stricter. When you paste a URL into Slack or LinkedIn, the service reads the <title> and Open Graph tags straight from the raw HTML. My posts all shared one title in that HTML, so every link I shared got the same generic card.
A quick test for your own site: open a post, view the source (not the inspector, which shows the page after JavaScript runs) and search for a sentence from the article. If it isn't there, crawlers that skip JavaScript can't see it either.
Why Eleventy
My rules for the move were short:
- Stay on Firebase Hosting.
- Keep the design exactly as it was.
- Keep every existing URL working.
- Ship no new JavaScript to visitors.
Eleventy fits those rules well. It takes Markdown and templates and writes plain HTML files. It sends no JavaScript of its own to the browser, and it only runs on my machine at build time, so it's a dev dependency and nothing more. The one plugin I added is the official RSS plugin, for the feed.
The new layout
src/ templates, Markdown posts and site data
public/ CSS, images and scripts, copied as-is
_site/ the built site that Firebase serves
Keeping public/ as a straight copy meant every asset path stayed the same. The CSS and the 3D sticker scene on the homepage didn't need a single change.
On the Firebase side, four lines in firebase.json do the work:
"public": "_site",
"predeploy": ["npx @11ty/eleventy"],
"cleanUrls": true,
"trailingSlash": true
predeploy runs the Eleventy build every time I run firebase deploy, so I can't forget to build. cleanUrls and trailingSlash give every page a URL like /blog/nested-css-2025/ with no .html on the end.
The header, footer and <head> used to be custom elements that JavaScript filled in after the page loaded. Now they're Nunjucks partials, so they're in the HTML from the start. The shared <head> gives each page its own title, description, canonical URL and share tags.
Converting the posts
A one-off script read the old array and wrote one Markdown file per post. Front matter holds everything that used to be a property in the array:
---
title: "Nested CSS: Cleaner Style Architectures in 2025"
description: "How modern tooling and native nesting unlock maintainable, future-proof style layers without the preprocessor overhead."
date: 2025-07-13
slug: nested-css-2025
readTime: "5 min read"
tags: ["CSS", "Best Practices", "Architecture"]
image: "https://images.pexels.com/photos/256502/pexels-photo-256502.jpeg"
---
Each post builds to /blog/<slug>/index.html from one shared layout. The slugs didn't change, so old links still had somewhere to land.
Writing a new post is now one Markdown file. I don't touch a JavaScript array or worry about escaping quotes inside a string.
Keeping old links alive
Every link anyone ever shared looked like /article.html?slug=nested-css-2025. Breaking those would throw away whatever search history they had, so they needed to land on the new pages.
Firebase redirects match on the path only. They can't read the query string, so there's no way to write a rule that says "send ?slug=nested-css-2025 to /blog/nested-css-2025/." They do keep the query string when they redirect, though. So the first hop is one rule:
{ "source": "/article.html", "destination": "/blog/", "type": 301 }
That turns /article.html?slug=nested-css-2025 into /blog/?slug=nested-css-2025. A few lines at the top of the blog index finish the job:
var known = ["nested-css-2025", "css-box-model-basics" /* one entry per post */];
var slug = new URLSearchParams(location.search).get("slug");
if (slug && known.indexOf(slug) !== -1) location.replace("/blog/" + slug + "/");
Eleventy fills in the known list at build time. It means a mistyped or deleted slug stays on the blog index instead of landing on a 404. location.replace keeps the extra hop out of the browser history, so the Back button still works. The same script handles an even older format, blog.html#slug, because browsers keep the # part through a redirect.
Sitemap, feed and structured data
Once every post is a real file, the extras that search engines and feed readers want become templates too.
The sitemap is a Nunjucks file that loops over every page Eleventy built:
{%- for item in collections.all %}
<url>
<loc>{{ item.url | fullUrl }}</loc>
<lastmod>{{ item.inputPath | gitLastModified | isoDate }}</lastmod>
</url>
{%- endfor %}
The real template has a few more conditions. Posts use their own date for lastmod, and pages like the 404 stay out. For everything else, a small filter asks git when the source file last changed (git log -1 --format=%cI -- <file>). So the homepage's lastmod updates when I edit the homepage, and not every time I deploy. Search engines lean on lastmod to decide what to recrawl, so a date that changes on every deploy tells them nothing.
The Atom feed comes from the RSS plugin with about ten lines of config: the collection to use, the output path, a title and an author. Feed readers get the full text of every post.
Structured data is a block of JSON in each post that describes it in a vocabulary search engines share, from schema.org. Each post gets a BlogPosting:
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Nested CSS: Cleaner Style Architectures in 2025",
"datePublished": "2025-07-13",
"dateModified": "2025-07-13",
"author": { "@type": "Person", "name": "Jared Newnam", "url": "https://jarednewnam.com" },
"image": "https://images.pexels.com/photos/256502/pexels-photo-256502.jpeg"
}
The homepage describes me as a Person, and the extensions page describes each extension as a SoftwareApplication. All of it comes from the same front matter and data files the pages use, so it can't drift out of sync with what's on screen.
The bugs that showed up along the way
Every date was one day early
The old blog index formatted dates like this:
new Intl.DateTimeFormat("en", { month: "short", day: "numeric", year: "numeric" })
.format(new Date("2025-07-13"));
new Date("2025-07-13") means midnight UTC. Format that in any US time zone and you get the evening before, July 12. The bug had been on the old index the whole time. The fix is one option:
{ month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }
If your dates are calendar days with no time attached, format them in UTC.
Two filters with the same name
I wrote a small filter called absoluteUrl to turn /blog/nested-css-2025/ into https://jarednewnam.com/blog/nested-css-2025/ for canonical links and share tags. The RSS plugin registers a filter with the same name, and its version replaced mine without a warning.
The build passed. Every canonical URL on the site came out relative, which search engines don't want. We only caught it by opening the generated HTML and reading the <head>. Renaming my filter to fullUrl fixed it.
Redirects that only fail on Windows
To test before deploying, I ran the site in the Firebase hosting emulator. None of the redirects worked, including /meet, an old one that works fine in production.
The cause sits a few layers down. Firebase's local server uses a small package called glob-slash to clean up redirect patterns, and it joins paths with the operating system's separator. On Windows, /meet becomes \meet, which never matches a URL. Production runs on Linux, so it's unaffected. With a test-only patch that swaps the backslashes back, every redirect worked locally.
If your Firebase redirects work in production but not in the emulator on Windows, this is probably why.
What changed
- Every post has its own URL, title, description and share image in the HTML.
/sitemap.xmllists every page, with post dates forlastmod./feed.xmlis an Atom feed, linked from every page's<head>. The "Subscribe via RSS soon" note on the blog finally has a link.- Each post carries
BlogPostingstructured data, so search engines know its headline, dates and author. - Lighthouse scores 100 for SEO and accessibility on the homepage, the blog index, a post and the extensions page.
- The scripts that drew the blog are gone. The JavaScript left on the blog handles the share buttons, tag filtering, the mobile menu and the theme toggle.
Should you do this?
If your content only appears after JavaScript runs, and you care about search or about how your links look when people share them, yes. The view-source test above takes ten seconds.
You don't need to switch tools to fix it. Next.js, Astro, Hugo and plain HTML files all produce pages with the content already in them. Eleventy suited this site because it's small, it's mostly words, and I wanted the output to be files I could read.
Pick one post, view its source, and search for its first sentence.