CS
Chirag Singhal's blog
Engineering · 10 min read

Anatomy of blog.oriz.in — How I Built a Zero-Cost, High-Performance Blog Platform

A deep-dive analysis of the blog.oriz.in repository: architecture, component design, deployment automation, coding conventions, and lessons learned from building a production Astro blog.

Anatomy of blog.oriz.in — How I Built a Zero-Cost, High-Performance Blog Platform

Most blog platforms are either fast, cheap, or flexible. You get to pick two. I wanted all three. This is the story of how I built blog.oriz.in from scratch — a static Astro blog deployed globally on Cloudflare Pages for exactly $0/month.


1. The Problem

I needed a platform that:

  • Serves static content to users worldwide with <100ms load times
  • Supports Markdown/MDX for writing posts with embedded components
  • Costs nothing to host and scale
  • Looks professional without a design team
  • Deploys automatically on push

Every platform I tried (WordPress, Ghost, Medium, Hugo) hit at least one limitation. So I built my own.

2. Tech Stack at a Glance

LayerTechnologyWhy
FrameworkAstro 6Zero JS by default, island architecture, static output
StylingTailwind CSS 4Utility-first, dark mode native, no runtime cost
LintingBiome.jsFast Rust-based linter, replaces ESLint + Prettier
DeploymentCloudflare PagesGlobal CDN, free tier, auto SSL, instant deploys
CommentsGiscusGitHub Discussions-backed, zero infra cost
AnalyticsCloudflare Web AnalyticsZero JS impact, built into CDN layer
NewsletterButtondownFree tier, simple embed, no tracking scripts
PWAService WorkerOffline support, cached navigation

Every dependency was chosen to eliminate a server-side cost.

3. Project Structure

blog.oriz.in/
├── .github/workflows/deploy.yml   # CI/CD pipeline
├── scripts/
│   ├── setup_cloudflare.py        # One-command infra provisioning
│   └── manage_dns.py              # DNS sync automation
├── src/
│   ├── components/                # 16 .astro components
│   ├── content/blog/              # MDX posts (content layer)
│   ├── layouts/                   # BaseLayout + BlogPostLayout
│   ├── pages/                     # File-based routing
│   ├── styles/global.css          # Tailwind theme + custom CSS
│   ├── config.ts                  # Centralized site configuration
│   └── i18n/locales/              # en, hi translations
├── public/
│   ├── sw.js                      # Service worker
│   ├── offline.html               # Offline fallback
│   ├── manifest.webmanifest       # PWA manifest
│   └── favicon.svg
├── astro.config.mjs
├── biome.json
├── tsconfig.json
└── package.json

The separation is clean: src/ contains all Astro components and content, public/ holds static assets that bypass the build, and scripts/ handles infrastructure automation.

4. Astro Configuration

// astro.config.mjs
export default defineConfig({
  site: "https://blog.oriz.in",
  output: "static",          // Pure static HTML — no server
  integrations: [mdx(), sitemap()],
  vite: {
    plugins: [tailwindcss()],
  },
  markdown: {
    shikiConfig: {
      themes: {
        light: "github-light",
        dark: "github-dark",
      },
    },
  },
  i18n: {
    defaultLocale: "en",
    locales: ["en", "hi"],
    routing: { prefixDefaultLocale: false },
  },
});

Key decisions:

  • output: "static" — No SSR, no server. Everything is HTML at build time.
  • Shiki with dual themes — Code blocks look identical to GitHub’s rendering.
  • i18n routing — English as default without prefix, Hindi at /hi/.

5. The Content Layer

Posts live in src/content/blog/ as .mdx files. Astro’s content collections enforce a schema:

// src/content.config.ts
const blog = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    updatedDate: z.date().optional(),
    heroImage: z.string().optional(),
    tags: z.array(z.string()).default([]),
    category: z.string().default("General"),
    draft: z.boolean().default(false),
    author: z.string().default("Chirag Singhal"),
  }),
});

Every post must declare frontmatter that passes Zod validation. Drafts are filtered out at query time, so I can commit incomplete work without it appearing on the site.

6. Layout System

Two layouts handle all rendering:

BaseLayout wraps every page — head, header, footer, scroll progress, back-to-top, service worker registration, and Cloudflare analytics. It’s the single source for <head> tags, meta, and PWA links.

BlogPostLayout extends BaseLayout for posts — adds table of contents, reading time, share buttons, bookmarks, Giscus comments, recently viewed posts, and a recommendation engine based on shared tags.

// src/pages/blog/[slug].astro
const wordCount = post.body.split(/\s+/).length;
const readingTime = `${Math.ceil(wordCount / 200)} min read`;

Reading time is computed by word count. Simple, accurate enough, and zero overhead.

7. Component Architecture

All 16 components are pure Astro (.astro files). No React, no Vue, no client-side framework. This is intentional — every component renders at build time with zero JavaScript shipped to the browser.

Key Components

SearchModal — Client-side search via a pre-built JSON index (search-index.json). Loads lazily on first use. No external search service.

TableOfContents — Extracts headings from post content, renders a sticky sidebar. Uses IntersectionObserver to highlight the active section as you scroll. Zero dependencies.

BookmarkButton — Persists bookmarks to localStorage. Fires a custom BookmarksUpdated event for other components to react.

Recommendations — Scores posts by shared tag count. Top 3 results shown as “Related Posts”. All computed at build time.

PostCard — The workhorse. Renders category, date, reading time, title, description, and up to 3 tags. Images are lazy-loaded.

CommandPalette — Keyboard shortcut (Cmd+K) driven search. Follows the same pattern as VS Code or Raycast.

8. Styling System

The CSS uses Tailwind CSS 4 with a custom theme layer:

@theme {
  --color-bg-primary: #0a0a0b;
  --color-bg-secondary: #111113;
  --color-accent: #6366f1;
  --font-sans: "Inter", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", monospace;
  /* ... */
}

Custom utilities include .glass-card (glassmorphism effect), .gradient-text, .gradient-border, and button variants .btn-primary/.btn-ghost.

Animations are pure CSS — fadeIn, slideUp, slideDown. No JS animation libraries.

The prose styles target rendered Markdown content (prose-content class) with consistent spacing, code block styling, and table formatting.

9. Site Configuration

Centralized in src/config.ts with a typed interface:

export interface SiteConfig {
  title: string;
  description: string;
  url: string;
  author: string;
  social: { github: string; twitter: string; /* ... */ };
  giscus: { repo: string; repoId: string; /* ... */ };
  analytics: { cloudflareBeacon: string };
  newsletter: { provider: "buttondown"; username: string };
  i18n: { defaultLocale: string; locales: string[] };
}

This is the single source of truth for every external integration. Change it here, and the entire site updates.

10. CI/CD Pipeline

The GitHub Actions workflow is the deployment backbone:

# .github/workflows/deploy.yml
name: Deploy to Cloudflare Pages

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm run lint
      - run: pnpm run build
      - uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy dist --project-name=blog

Flow: git push → lint → build → deploy. Concurrency control prevents parallel deploys. The entire pipeline runs in under 60 seconds.

11. Infrastructure Automation

Two Python scripts automate cloud setup:

setup_cloudflare.py handles the full provisioning:

  1. Creates Cloudflare Pages project
  2. Configures custom domain (blog.oriz.in)
  3. Sets up Web Analytics
  4. Generates scoped API token
  5. Sets GitHub secrets via gh CLI
  6. Deploys via wrangler
  7. Syncs DNS records

manage_dns.py ensures the CNAME record points to the Pages deployment:

def sync_dns():
    zone_id = get_cf_zone_id(DOMAIN)
    data = {
        "type": "CNAME",
        "name": SUBDOMAIN,
        "content": PAGES_TARGET,
        "ttl": 1,
        "proxied": True
    }
    # Create or update

Both use raw urllib — no external Python dependencies needed.

12. PWA & Offline Support

The service worker (public/sw.js) implements a network-first caching strategy:

  • Navigation requests: try network, fall back to cache, then offline page
  • Static assets: serve from cache, update in background
  • Precaches: homepage, blog index, offline page, favicon, manifest

The offline page is a minimal HTML file with inline styles — no external dependencies.

13. SEO & Metadata

Every page generates proper <meta> tags:

  • OpenGraph (title, description, image, site name)
  • Twitter Cards (summary_large_image)
  • Canonical URLs
  • Sitemap (/sitemap-index.xml)
  • RSS feed (/rss.xml)

The RSS feed is generated from the blog collection:

// src/pages/rss.xml.ts
export async function GET(context: APIContext) {
  const posts = await getCollection("blog", (entry) => !entry.data.draft);
  return rss({
    title: SITE_CONFIG.title,
    items: posts.map((post) => ({
      title: post.data.title,
      pubDate: post.data.pubDate,
      link: `/blog/${post.id}/`,
    })),
  });
}

14. TypeScript Configuration

Strict mode is enabled across the board:

{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "noImplicitAny": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

Every Props interface in components uses explicit typing. No any escapes.

15. Coding Conventions

  • Biome.js for formatting (2-space indent, space-based) and linting (recommended rules)
  • Git hooks via VCS integration in Biome config
  • No comments in code — clean, self-documenting components
  • Component-first — every visual element is a reusable .astro component
  • Progressive enhancement — works without JS, better with JS

16. What’s Missing (And Why)

There are no tests. This is intentional. A static site with no server logic has minimal test surface — the build step itself is the test. If pnpm run build succeeds, the site is valid.

There’s no CI/CD for pull requests. The current pipeline only deploys on main push. PR previews could be added with Cloudflare Pages’ preview deployments feature.

17. Performance Profile

The result speaks for itself:

MetricValue
First Contentful Paint<0.5s
Largest Contentful Paint<1.0s
Time to Interactive<1.0s
Total Blocking Time0ms
Cumulative Layout Shift<0.01
JavaScript shipped~5KB
CSS shipped~8KB
Total page weight<50KB

Every page is static HTML with inline styles and minimal JS. The CDN does the heavy lifting.

18. Cost Breakdown

ServiceMonthly Cost
Cloudflare Pages$0
Cloudflare Web Analytics$0
Giscus (GitHub Discussions)$0
Buttondown Newsletter$0
GitHub (repo + Actions)$0
Domain (oriz.in)~$1/month
Total~$1/month

19. Lessons Learned

  1. Static is enough. 99% of blogs don’t need a server. Static output with CDN caching is faster and cheaper than any SSR setup.

  2. Astro’s island architecture wins. Ship zero JS by default, hydrate only what’s interactive. It’s the best of both worlds.

  3. Automate the infrastructure. The setup_cloudflare.py script turns a 30-minute manual setup into a single command. Worth the 300 lines of Python.

  4. TypeScript strict mode catches real bugs. noImplicitAny and strictNullChecks prevented at least a dozen runtime errors during development.

  5. Biome is faster than ESLint. Not marginally faster — 10-50x faster. The DX improvement alone justified switching.

  6. Service workers are underrated. A 80-line service worker gives offline support, faster repeat visits, and a better user experience than most SPA frameworks.

20. What’s Next

  • Hindi translations — i18n infrastructure is in place, content needs to be written
  • Image optimization — Sharp is a dependency, but hero images could be better optimized
  • WebMentions — Open protocol for cross-site interactions
  • Search improvements — Move from simple string matching to fuzzy search

The code is open source at github.com/chirag127/blog. Fork it, use it, improve it.

Building a blog shouldn’t require a team, a budget, or a server. It should just work.

Share:
Bookmark

Comments

Related Posts