# 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:
- Pulls the URL list from
/_feather/sitemap-posts.xml - Fetches each post page
- Extracts the
__remixContextJSON - Walks
recordMap.block(Notion’s block tree — note that each block is double-wrappedblock[id].value.value, which took me an hour to figure out) - Converts blocks → MDX (headings, lists, images, callouts, quotes, code, embeds, tweets)
- Downloads every image to
public/all/<slug>/with sha1-hashed filenames - Writes
src/content/posts/<slug>.mdxwith 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,/coachingetc. — same as before/retention-marketing,/40-lead-magnets-for-content-upgradesetc. — also same as before (single[slug].astrohandles 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)/tagsand/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-stoppedso 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 (whoamion the home,ls -lt posts/on the blog list,cat <slug>.mdon 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
localStorageand followsprefers-color-schemeby 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
coverproperty set. Every post body opens with an image, but those aren’t separately tagged as the hero — so the/alllisting 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.