david@frosdick:~/how-we-built-this-blog
david@frosdick ~/how-we-built-this-blog $ cat how-we-built-this-blog.md

# How we rebuilt this blog: Notion → standalone Astro site

date: 26 May 2026 read: ~4 min tags: Notion, Astro, Tools, No Code

This site used to live on Feather — a clever service that turns a Notion workspace into a public website. Worked great for years. But I wanted to own the whole stack: faster pages, a real database, my own auth, total design freedom, and the ability to deploy from a single repo.

So we rebuilt it. Here’s the rough flow 👇

The starting point

  • 119 blog posts (some going back to 2007) living in a Notion database, rendered through Feather
  • 17 standalone pages — about, projects, coaching, podcast, contact, plus the legal stuff
  • 37 tags spread across the posts
  • A separate links page at links.davidfrosdick.com that I wanted to keep but redeploy myself

The new stack

  • Astro 5 (App Router-style, SSR via the Node adapter — most pages prerendered, API routes on-demand)
  • MDX for post content
  • Tailwind 4 (CSS-first config, no tailwind.config.js) for styling
  • SQLite via Drizzle ORM for the database
  • Better-Auth for email/password auth + sessions
  • Docker + Cloudflare Tunnel for the deploy — no public ports, no nginx in front, no Let’s Encrypt to manage

Everything that was a Notion page is now a .mdx file in git. That alone is worth the move.

Scraping the content

The trickiest part was getting 119 posts out cleanly. davidfrosdick.com is a Remix app under the hood — each post embeds a window.__remixContext JSON blob containing the full Notion recordMap. So the scraper:

  1. Pulls the URL list from /_feather/sitemap-posts.xml
  2. Fetches each post page
  3. Extracts the __remixContext JSON
  4. Walks recordMap.block (Notion’s block tree — note that each block is double-wrapped block[id].value.value, which took me an hour to figure out)
  5. Converts blocks → MDX (headings, lists, images, callouts, quotes, code, embeds, tweets)
  6. Downloads every image to public/all/<slug>/ with sha1-hashed filenames
  7. Writes src/content/posts/<slug>.mdx with proper YAML frontmatter

Two MDX gotchas surfaced once Astro tried to render them:

  • <<< / >>> decorative brackets from a few posts got read as JSX tag starts. Replaced with «» and added an escape pass in the scraper.
  • A registry GUID {4D36E965-…} in one Windows-themed post got read as a JSX expression. Now braces in body text get escaped to \{…\} automatically.

After the body scrape, a second pass re-walked every post to pull tags out of the Notion Tags 2 multi-select property (Feather doesn’t expose those directly, but they live in the recordMap’s page block properties).

Pages, redirects, SEO

URL parity with the old site was non-negotiable — too many backlinks to break. End result:

  • /about, /projects, /coaching etc. — same as before
  • /retention-marketing, /40-lead-magnets-for-content-upgrades etc. — also same as before (single [slug].astro handles both posts and pages because there are no slug collisions)
  • /all — the post listing (this is new but it’s a name the old site already used)
  • /tags and /tags/<tag> — new
  • /rss.xml, /sitemap-index.xml, /robots.txt — auto-generated

Every page gets full SEO: meta description (auto-derived from the first paragraph if the frontmatter doesn’t set one), canonical, Open Graph, Twitter Card, and a JSON-LD schema appropriate to the page type — WebSite + Person on the home page, BlogPosting on each post, CollectionPage on /tags, and so on.

Deploy

Each site is its own folder on the server with a Dockerfile and docker-compose.yml. The pattern is the same across all my sites:

  • Container binds to 127.0.0.1:30NN (nothing exposed to the internet directly)
  • restart: unless-stopped so it survives reboots
  • Persistent volumes for anything stateful (the SQLite DB lives in ./data/)

In front of all of that is a single Cloudflare Tunnel — cloudflared runs as a systemd service, reads /etc/cloudflared/config.yml, and routes:

links.davidfrosdick.com → http://localhost:3003
davidfrosdick.com       → http://localhost:3004
www.davidfrosdick.com   → http://localhost:3004

No open ports, no certificates to renew, no nginx to babysit. The tunnel handles TLS, DDoS protection, and the routing in one go. To add a new site I drop a new container, add one ingress rule, restart cloudflared, and add a CNAME in Cloudflare DNS.

Design

The whole thing is themed as a retro macOS terminal:

  • Window chrome with the red/yellow/green dots
  • JetBrains Mono throughout
  • david@frosdick ~ $ <command> prompt lines that change per page (whoami on the home, ls -lt posts/ on the blog list, cat <slug>.md on each post)
  • Subtle scanline overlay, blinking cursor at the bottom
  • Light/dark toggle in the top right (bright golden sun ☼ in dark mode, deep navy moon ☾ in light)
  • Theme preference persists in localStorage and follows prefers-color-scheme by default

The light theme keeps the same monospace + terminal vibe but on warm cream — feels like a vintage paper printout instead of a CRT.

What I’d do differently

  • Backfill descriptions properly. Most posts don’t have a Notion excerpt set, so the site auto-derives from the first paragraph. Fine, but a real one-line description per post would search better.
  • Hero images for list views. Only one post in Notion had a cover property set. Every post body opens with an image, but those aren’t separately tagged as the hero — so the /all listing is text-only.
  • Search. No client-side search yet. With 119 posts a tiny Pagefind or Fuse.js index would be cheap to add.

Numbers

  • 136 routes generated at build (119 posts + 17 pages)
  • 21 MB of post images
  • 0 build failures after the scrape
  • 35 MB total deployed (excluding node_modules)

The whole rebuild — scrape, design, deploy — took about 2 hours to build.

If you’re sitting on a Notion-backed site and wondering whether it’s worth moving off, the answer for me was yes. Git is a better CMS than I expected.

New posts on marketing, ecommerce, digital products & the catering industry. No spam, unsubscribe anytime.

$

david@frosdick ~/how-we-built-this-blog $