Over one weekend, I shipped a complete blogging platform from first line of code to production. It’s powered by the Notion API and includes AI-generated SEO, multiple themes, custom domains, Stripe billing, and privacy-friendly analytics, built solo with a stack I know well: Notion, Laravel, and a lot of stubbornness.

This is the story of building tiniest.blog.

The Idea

I write in Notion. Most of my notes, drafts, and half-formed ideas live there. But publishing those ideas as a blog always meant migrating content to WordPress, Ghost, or Substack, copying text, reformatting, fiddling with settings, losing the Notion workflow I’d already built.

The question I kept coming back to was simple: what if I could point at a Notion database and have a blog appear? I didn’t want a brittle export pipeline or a script I’d need to babysit. I wanted a real blog that stayed in sync automatically, shipped with proper SEO, supported a custom domain, and loaded fast enough to feel like a static site generator.

Day 1: The Foundation

The first commit landed on Saturday afternoon. A design document, a monochrome editorial design system, and a 19-task implementation plan. I knew the shape of what I was building before I wrote a line of application code.

The stack: Laravel 13 for the backend, React with TypeScript via Inertia.js for the dashboard, PostgreSQL, S3 for static file storage. No server-side rendering for blog pages, the output would be pure static HTML. Zero JavaScript on the public blog. Under 8KB of CSS.

The first real challenge was the Notion API. Notion’s block structure is deeply nested, a page contains blocks, blocks contain children, children contain more children. A table of contents block needs to scan all sibling headings. A column layout needs to know how many columns exist. A synced block references content from somewhere else entirely.

I built a NotionBlockRenderer that handles every Notion block type: paragraphs, headings, lists, code blocks, tables, toggles, callouts, equations, bookmarks, embeds, columns, and more. Each block maps to semantic HTML. The renderer walks the tree recursively, handles nested children, and produces clean, accessible markup.

By the end of day one, I had:

  • Notion OAuth for connecting workspaces
  • A full sync pipeline (fetch pages, render blocks, generate HTML)
  • Static blog output with a proper editorial design
  • An onboarding flow that goes from “nothing” to “live blog” in three steps

Day 2: The Hard Parts

On Sunday I focused on the things that separate a prototype from something that feels like a product.

AI-generated SEO. Every blog post gets automatic meta tags, Open Graph data, JSON-LD structured data, and keyword extraction, all generated by an AI agent that reads the content and produces structured output. No SEO knowledge required from the user. The blog just ranks.

Multi-blog support. The initial design was one blog per user. By Sunday morning, I’d rearchitected the entire data model: users can have multiple blogs, each connected to different Notion databases, potentially from different Notion workspaces. This meant rewriting controllers, policies, and the entire routing system.

The image problem. Notion’s image URLs expire after one hour. Every image in every blog post would break within 60 minutes of syncing. The fix: an image proxy that downloads every Notion image to our own S3 bucket during sync, rewrites the URLs in the HTML, and serves them permanently.

Themes. I started with one monochrome editorial theme. By the end of the day, I had six: Default (Fraunces serif), Warm (Playfair Display, earthy tones), Ocean (DM Sans, cool blues), Brutalist (Space Mono, raw and bold), Pastel (Nunito, soft colours), and Night (JetBrains Mono, terminal green on black). Each theme is a single CSS file under 8KB. Each supports dark mode automatically via prefers-color-scheme.

Layouts. Four index layouts (list, grid, magazine, minimal) and three post layouts (standard, wide, sidebar). Mix and match with any theme. All pure CSS, no JavaScript.

Stripe billing. Free tier gets 3 blogs with 10 posts each. Pro unlocks unlimited everything plus custom domains. Cashier handles subscriptions, checkout, and the billing portal.

Privacy analytics. Page views recorded server-side, no cookies, no JavaScript tracker, no GDPR consent banner needed. Just path, referrer, and country, with IP addresses hashed using a daily rotating salt.

Day 3: Production and the Domain Problem

By Monday I was ready to put it in production, using Laravel Cloud for hosting and Cloudflare for DNS.

The first deployment worked, and subdomain routing (kyle.tiniest.blog) resolved correctly. The blog loaded, the themes rendered as expected, and I got that brief satisfaction of something working on the first try.

After that, I moved on to custom domains.

Custom domains on a platform like this are genuinely hard. The user adds rusby.blog to their settings. We need to issue an SSL certificate for that domain, route traffic to our servers, and resolve the request to the correct blog, all automatically, for any domain, without manual intervention.

Attempt 1: DNS verification. Check if the user has set a CNAME record pointing to us. This works, but Cloudflare’s proxy strips the original hostname, and browsers throw Error 1014: CNAME Cross-User Banned when the CNAME crosses between Cloudflare accounts.

Attempt 2: Cloudflare for SaaS. This is the right solution, Cloudflare’s product for exactly this use case. Register each custom domain as a “custom hostname” via their API, and Cloudflare handles SSL provisioning automatically. The fallback origin points to custom-domains.tiniest.blog.

Attempt 3: But Laravel Cloud only accepts *.tiniest.blog. When rusby.blog arrives at Cloudflare’s edge and gets routed to our origin, the Host header is still rusby.blog. Laravel Cloud rejects it, it only allows hostnames matching the configured domain.

The fix: a Cloudflare Worker. A 20-line JavaScript function that intercepts custom domain requests, rewrites the Host header to custom-domains.tiniest.blog, and preserves the original domain in an X-Original-Host header. Laravel reads that header and resolves the correct blog.

In practice it didn’t work on the first try, and rusby.blog kept returning a 404.

The X-Original-Host header was being set by the Worker, but Laravel Cloud’s load balancer was overwriting X-Forwarded-Host with the rewritten hostname. The original domain was lost somewhere in the proxy chain.

It took about an hour to diagnose, and the eventual fix was a single line. I added X-Original-Host as a custom header that load balancers don’t touch, and checked for it first in the middleware. Once that was in place, requests started resolving to the right blog.

The Security Audit

Before calling it done, I ran a full security audit, four parallel agents examining authentication, injection vectors, authorization, and infrastructure hardening.

They found 15 issues. Three critical:

  1. Stored XSS via Notion links. The block renderer HTML-encoded URLs but didn’t validate the scheme. A javascript: URL would pass through e() untouched and execute in the browser. Fixed with a URL sanitizer that only allows http://, https://, mailto:, and tel:.
  2. Webhook authentication bypass. The Notion webhook endpoint silently accepted requests when the webhook secret wasn’t configured, a fail-open pattern. Changed to fail-closed.
  3. Header spoofing on custom domains. Any direct request to the origin could set X-Original-Host to impersonate any blog. Fixed with a shared secret between the Cloudflare Worker and Laravel.

Plus 12 more: HSTS headers, Content Security Policy, rate limiting on registration and domain creation, mass assignment hardening, destructive command protection, password policy fallbacks. All fixed in a single commit.

More Themes, More Layouts

Research across Ghost, WordPress, and Substack, plus 2026 design trend reports, showed clear demand for more variety. I added four more themes:

  • Terminal, IBM Plex Mono, matrix green on black. For developers who want their blog to feel like a terminal.
  • Serif Classic, Playfair Display and Merriweather on warm cream. The New Yorker meets Substack.
  • Swiss, Inter with a red accent. International Typographic Style, clean, grid-based, timeless.
  • Meadow, DM Serif Display with sage green tones. Warm minimalism, 2026’s biggest design trend.

Two new index layouts: Newsletter (date-focused, Substack-style) and Masonry (Pinterest-style cards). Two new post layouts: Longform (wider column with drop caps) and Split (side-by-side hero image and title).

10 themes. 6 index layouts. 5 post layouts. 300 possible combinations, all from pure CSS.

What tiniest.blog Is Now

Three days of work produced:

  • A complete multi-tenant blogging platform built on Laravel 13 and React
  • Real-time Notion sync via webhooks with a polling fallback
  • AI-generated SEO for every post, meta tags, JSON-LD, Open Graph, keywords
  • 10 CSS themes and 11 layout options, all under 8KB, zero JavaScript
  • Custom domains via Cloudflare for SaaS with automatic SSL
  • Privacy analytics, no cookies, no tracking scripts
  • Stripe billing with free and Pro tiers
  • Static HTML output served from Cloudflare R2, sub-second page loads
  • 162 commits, comprehensive test coverage, and a full security audit

The architecture is simple: you write in Notion, we sync and build static HTML, your readers get the fastest blog on the internet. No migration. No lock-in. Your content stays in Notion where it belongs.

What’s Next

At this point the product works and the infrastructure feels solid, but the next challenge is distribution.

The go-to-market is Reddit-first, r/Notion, r/SideProject, r/webdev, followed by a Product Hunt launch and outreach to Notion creators. The pitch writes itself: “Turn your Notion database into a beautiful blog in 60 seconds.”

If you write in Notion and want a blog without the overhead, tiniest.blog is live. Connect your workspace, pick a database, and your blog exists.


Built by Kyle Rusby. Powered by Notion, Laravel, and an unreasonable amount of concentration 👀

Try it free at tiniest.blog.