Blogging my NIH syndrome

By bob at

UPDATE 11/19/2022 This didn't age well, but that's what I get for writing a post about a 1.0 release of something. The entire section about Twind no longer applies after Fresh 1.1, and the official Deno blog now has a post about How to Build a Blog with Fresh, minus all of the stumbling around.

As all great software engineers with NIH syndrome know, the first step in starting a new blog is to write a blogging engine from scratch using the latest and trendiest technology. Otherwise, what's the point?

A Fresh start

The closest I've come to starting a personal blog was using Gatsby and Netlify CMS. There are a lot of other static site generators and hosting options out there, but I mostly went with Gatsby because I had previously used it for work for a documentation site, and I generally liked using it. The choice of Netlify CMS was mostly just an experiment - I figured that if I ever tried to build a website for someone else, they probably wouldn't want to check code in to Git just to make updates to the site. It all was working fine, no complaints, and I'll probably come back to that stack someday.

Anyway...enter Fresh. In the time between buying this domain and actually getting around to trying to code up a site for it, I saw the Fresh announcement pop up in multiple newsletters. I'd been wanting to try something with Deno anyway, so this checks multiple boxes. Win.

So far, I would describe Fresh as a "minimalist Next.js for Deno". It uses filesystem routing like Next.js, and generates HTML server-side on demand, using a service hosted in Deno. The preferred hosting platform is Deno Deploy, which conveniently has a free tier, so hosting is sorted too.

The downside (if you want to call it that) is that it's a brand new 1.0 release, with not a lot of "how-to" information, plugins, etc. available for it. So you're pretty much on your own once you get it up and running with "hello world". At least, I haven't found a lot of information for it outside of reading the official documentation and browsing GitHub issues. If there's another community hub somewhere, I haven't found it, and there's no obvious references on the Fresh website.

Step 1: Code a blog

Fast forward: install Deno, run the Fresh init script, push the example project to GitHub, connect it to Deno Deploy...

Once I made a few changes to fix the styling autocomplete, made some basic layout components, and played around with some "hello world" pages to get the feel of the was time to make the blog part of the blog.

Fresh leaves all data fetching up to you. There's no built-in framework for it like in Gatsby. There's also no nice examples of "drop some markdown files in a directory to auto-generate a blog" like Gatsby or other static-side generators. Ok, so I'm on my own here.

...Or am I? I mean, I read about the Fresh announcement on a blog, and the Fresh website itself is written in Fresh...

The main logic of the documentation site is in the docs/[...slug].tsx route.

It imports an object containing the documentation data:

import {
} from "../../data/docs.ts";

...then just reads the file matching the slug from the current path:

const url = new URL(`../../../${entry.file}`, import.meta.url);
const fileContent = await Deno.readTextFile(url);

Ok, so where are those slugs in docs.ts coming from? Is it being generated from a directory of markdown files?

import RAW_TOC from "../../docs/toc.json" assert { type: "json" };

for (const parent in (RAW_TOC as unknown as RawTableOfContents)) {
  const rawEntry = (RAW_TOC as unknown as RawTableOfContents)[parent];
  const href = `/docs/${parent}`;
  const file = `docs/${parent}/`;
  const entry = {
    slug: parent,
    title: rawEntry.title,
  TABLE_OF_CONTENTS[parent] = entry;

Not exactly. Looks like it's building it from a toc.json file in the documentation directory, which appears to just be a manually created structure mapping out the documentation paths. No automatic generation here, and not very convenient for writing a blog. Dead end.

Oh wait, the main Deno website is written in Fresh, and it has a blog! How does their blog work?

import { Handlers, RouteConfig } from "$fresh/server.ts";

export const handler: Handlers = {
  GET(_, { params }) {
    return Response.redirect(`${params.path}`, 307);

export const config: RouteConfig = { routeOverride: "/posts/:path*" };

Ah, right. It's on a different site now, and Deno is also now a company. I guess the new company site isn't open-source.

Ok...what about Ryan Dahl's blog? Right there at the bottom..."Powered by Deno Blog". Wait, what, Deno Blog? There's already a blog package for Deno, and I'm trying to write one in Fresh? What am I even doing?

Step 1 (redux): Steal a blog

Deno Blog is "minimal boilerplate blogging". There are a few customization options, but you basically just import the package and call blog() and you have a blog. There doesn't seem to be much documentation, but the init script creates an example posts/ file, which magically gets indexed and routed. Ok, so I want this, but in Fresh.

Unfortunately, it's basically just one monolithic package that duplicates a lot of things that Fresh is doing - routing, reloading, etc. The code for just reading and displaying blog posts isn't exported. It looks like an http handler for the blog is exported, so you could probably export that handler inside a Fresh route for /blog and have your blog embedded inside Fresh. Maybe that's what does?

I'd rather have full control over the rendering template and have it better integrated with Fresh, so...copy-paste time. It turns out that all this effort to find example code for generating a blog from a directory of markdown files just gets me...a loop that reads all markdown files in a directory.

async function loadContent(blogDirectory: string, isDev: boolean) {
  // Read posts from the current directory and store them in memory.
  const postsDirectory = join(blogDirectory, "posts");

  // TODO(@satyarohith): not efficient for large number of posts.
  for await (
    const entry of walk(postsDirectory)
  ) {
    if (entry.isFile && entry.path.endsWith(".md")) {
      await loadPost(postsDirectory, entry.path);

Not efficient, indeed. That's somewhat anti-climactic. Well, if it's good enough for Ryan Dahl, it's good enough for me. I'll just copy/paste loadContent and loadPost into a utility file, export the post data from it, and have it load on import:

export const POSTS = new Map<string, Post>();

await loadContent();

Now I can import { POSTS } from "../../utils/posts.ts"; and start displaying them.

While I'm at it, I might as well steal the components that display the blog index (and previews) and the blog page itself. That should get me some reasonable styling out of the box, since I haven't bothered to learn Tailwind yet. (Oops, should have mentioned earlier - Fresh will optionally set you up with Tailwind as your styling system, which Deno Blog is also using. That will be relevant soon.)

Since I already have my own base components and navigation/header/footer, I can just cut out the important-looking bits of Index, PostCard, and PostPage. And...there we go! A basic functional blog.

Step 2: De-uglify it

...but it's really ugly. The pages look completely un-styled and lacking even the basic styling and layout that I should have copied over from Deno Blog. Everything is running together, un-aligned, etc. What happened?

So when I said above that both Fresh and Deno Blog used Tailwind...that was a lie. Fresh is actually using Twind, which is Tailwind-in-js. To use any Tailwind styles, you have to use the tw macro to get them inserted. So, easy enough fix, just change all class references like class="mt-1" to class={tw`mt-1`}. Now my autocomplete is complaining about a few of the utility classes. It turns out that Deno Blog doesn't use Tailwind either, it uses UnoCSS (via the htm package). While it's clearly inspired by Tailwind, it's not a 100% match, so a few things had to be changed. Too bad I still haven't learned Tailwind, so I just deleted the red-underlined classes and moved on.

Now everything is looking better. The blog index, preview cards, and page headers are all looking basically the same as Deno Blog, which is as good as I can hope for to get started with. Now to write a post (this one!)...

...and that's ugly too. Paragraphs run together, headings look like text, links aren't even underlined. I'm starting to remember why at this point I always kicked styling problems over to someone who actually knew what they were doing (Hi, Ryan! (no, not Dahl)).

It looks like this is not a bug, but a feature from Twind called Preflight. They really, really want to make sure you use Tailwind utility classes for absolutely everything and not rely on browser defaults for <p>, <h1>, etc. Ok, fine, I'll override some basic styles with Twind to make basic elements look reasonable.

const markdown = css({
  h1: apply`text-4xl`,
  h2: apply`text-3xl`,
  h3: apply`text-2xl`,
  h4: apply`text-xl`,
  p: apply`my-5`,
  a: {
    textDecoration: "underline",
  "a.anchor": {
    display: "inline-block",

Much better, now it looks like rendered Markdown instead of just plain text all smushed together. Still, it's not as nice looking as Deno Blog, and I don't have syntax highlighting on all of these code blocks. I'm clearly missing another styling piece. Time to check out the gfm package doing the rendering.

Ah, hah. It exports its own CSS as a text blob that you need to add to your styles. The Deno website uses a separate route for this, so I can just copy their gfm.css.ts file as-is. It even includes a few tweaks for Twind. Linking that to the blog post page makes it render correctly.

Conclusion: Don't do this

I still need to re-implement (steal) Deno Blog's RSS feed generation and a few other things before I'll even have feature parity - which I could have had in a few seconds by just using Deno Blog in the first place. Or any other static site generator that is actually meant to host blogs. All in all, this was just a lot of fumbling around due to me never actually having used Deno or Tailwind, much less any of the libraries built on them. I would be more embarrassed to even post all of this, except I know nobody will actually read it.