A walkthrough of how this blog is built — what stack it runs on, the custom pieces I’ve layered on top, and how it deploys.
Core Technologies
The stack is small and deliberately boring:
- Astro — most of the site is static HTML with minimal JavaScript shipped to the browser. Fast initial load, good SEO defaults, and the component model is easy to live with.
- Tailwind CSS — utility-first styling. Keeps custom CSS to a minimum and styles consistent across components.
- Markdown — all blog posts live as
.mdfiles in Astro’s content collections, which handles parsing and rendering. - TypeScript — for the components, utilities, and any client-side scripts.
The blog started from Sat Naing’s Astro Paper theme. Most of the customizations — the resume page with PDF download, the table of contents component, various UI tweaks — are layered on top of that base.
Theme and Styling
Custom theme rather than off-the-shelf, but built on top of Astro Paper’s foundation.
Light & Dark Mode
public/toggle-theme.js— vanilla JS that runs before the page renders, to avoid the flash of incorrect theme.// Simplified snippet from public/toggle-theme.js const theme = (() => { if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) { return localStorage.getItem('theme'); } if (window.matchMedia('(prefers-color-scheme: dark)').matches) { return 'dark'; } return 'light'; })(); if (theme === 'light') { document.documentElement.setAttribute('data-theme', 'light'); } else { document.documentElement.setAttribute('data-theme', 'dark'); } // ... logic to update theme-color meta tag and handle button clicks- localStorage and system preference — the script checks
localStoragefirst, then falls back toprefers-color-scheme. - Dynamic updates — the script sets
data-themeon<html>and Tailwind keys off it. Thetheme-colormeta tag is updated to match so the browser chrome follows.
Tailwind Configuration
tailwind.config.cjs— custom theme settings: colors, fonts, utility extensions.src/styles/base.css— base styles, CSS variables shared across themes, and any global rules that don’t fit utility classes.
Theme-Aware Syntax Highlighting
Code blocks track the active theme. Astro uses Shiki for syntax highlighting, and toggle-theme.js swaps Shiki’s styles when the theme changes — a custom dark color map (githubDarkColorMap) is applied so dark-mode code stays readable.
// Conceptual snippet from toggle-theme.js related to Shiki
function updateShikiTheme(theme) {
const shikiBlocks = document.querySelectorAll('pre.shiki');
if (theme === 'dark') {
// Apply dark theme styles, potentially by adding/removing classes
// or directly manipulating style properties based on githubDarkColorMap
} else {
// Apply light theme styles
}
}
Content Management & Structure
Astro’s content collections handle the posts.
src/content/blog/— all blog post Markdown files. Frontmatter for metadata, body for content.- Directory layout:
src/pages/— route components (index.astro,about.astro,resume.astro, etc.)src/layouts/— shared layout componentssrc/components/— reusable UI components (Astro / React / Svelte)
Key Layouts
Layout.astro— global wrapper. Includes<html>,<head>(meta tags, stylesheet links), and<body>, plus the header and footer.Main.astro— used for standard content pages.PostDetails.astro— renders individual blog posts: title, date, body, optional table of contents.Posts.astro— the post listing page.
Custom Features
Dynamic Open Graph Image Generation
- File:
src/utils/generateOgImages.tsx - Libraries:
satori— converts JSX-like markup into SVG@resvg/resvg-js— converts SVG to PNG
- How it works:
- Site fonts are loaded.
- React-style components (
postOgImage.tsx,siteOgImage.tsx) define the OG image templates. - For each post,
satorirenders the template into SVG. @resvg/resvg-jsconverts the SVG to PNG.- Images are generated at build time.
The result: every post gets a consistently branded social preview image without me touching anything.
// Simplified conceptual example from src/utils/generateOgImages.tsx
// (Actual implementation involves Astro's build hooks)
// import { satori } from 'satori';
// import { Resvg } from '@resvg/resvg-js';
// import { siteOgImage } from './siteOgImage'; // A React component
// async function generateImage() {
// const svg = await satori(
// siteOgImage({ title: 'My Awesome Blog Post' }),
// {
// width: 1200,
// height: 630,
// fonts: [/* font data */],
// }
// );
// const resvg = new Resvg(svg);
// const pngData = resvg.render();
// const pngBuffer = pngData.asPng();
// // fs.writeFileSync(..., pngBuffer);
// }
Interactive Resume with PDF Generation
- Data:
src/data/resume.jsonholds the resume content in structured form. - Frontend:
src/pages/resume.astro(usingsrc/layouts/ResumeLayout.astro) renders the JSON to HTML. - PDF generation:
public/resume-pdf-generator.jsusesjsPDF. When the “Download PDF” button is clicked, it reads the currentdata-themeand generates a PDF that matches the active theme — light or dark — by mapping the relevant Tailwind classes / CSS variables to jsPDF styles.
Reusable Astro Components
The src/components/ directory has the standard set:
Card.tsx— post / project preview cardsHeader.astro— site navFooter.astro— site footerTableOfContents.astro— auto-generated TOC from post headings
Helper Utilities
src/utils/ keeps the small stuff:
getSortedPosts.ts— fetches and sorts blog posts by dateslugify.ts— URL-friendly slugs- Date formatting, text helpers, etc.
SEO and Performance
SEO
- Meta tags —
src/layouts/Layout.astrosets title, description, canonical URL, Open Graph, and Twitter card tags on every page. - JSON-LD — structured data is injected from
Layout.astroso search engines get the right metadata.
Performance
- Google Fonts loaded with
font-display: swapand preconnect hints. - Critical scripts like
toggle-theme.jsare preloaded. - Astro defaults handle partial hydration, code splitting, and asset optimization.
- Vite build options in
astro.config.tscover minification, code splitting, and chunk grouping.
// Example snippet from astro.config.mjs (or .ts) for Vite options
// import { defineConfig } from 'astro/config';
// export default defineConfig({
// vite: {
// build: {
// rollupOptions: {
// output: {
// manualChunks(id) {
// if (id.includes('node_modules')) {
// // Group vendor modules into a separate chunk
// return 'vendor';
// }
// }
// }
// }
// }
// }
// });
Hosting and Deployment
The blog is hosted on Cloudflare Pages, with deploys driven by Git:
- Preview deployments — every feature branch gets its own preview URL on push. Subsequent pushes to that branch update the preview.
- Production deployments — merging to
maintriggers a production build and deploy.
What this gets you in practice:
- Easy previews — share a link, get feedback, before merging.
- Automated production — no manual steps, no risk of forgetting to redeploy.
- Rollbacks — Cloudflare keeps deployment history, so reverting is one click.
Development Experience
- ESLint & Prettier — consistent formatting and lint enforcement.
- Husky — Git hooks run lint-staged and the date-update script before commits.
- TinaCMS (optional) —
tina/config.tsenables a visual editing layer over the Markdown content, if you want it.
Step-by-Step Deployment Guide
If you want to deploy the same way, here’s the full path.
I. Prerequisites:
- GitHub account with the project repo pushed up.
- Cloudflare account (free is fine).
- Node.js / npm for local builds. Version is in
.nvmrcorpackage.json(Node 18+). - Latest code, including Astro config, on GitHub.
II. Setting Up Cloudflare Pages:
- Log in to Cloudflare and open the dashboard.
- Workers & Pages → Pages.
- Create a project and pick “Connect to Git”.
- Connect to GitHub:
- Authorize Cloudflare. You can grant access to all repos or just one.
- Pick the blog repo.
- Configure build settings:
- Project name — becomes part of your
*.pages.devsubdomain. - Production branch — usually
main. - Framework preset — Astro (usually auto-detected).
- Build command —
npm run buildorastro build. - Build output directory —
dist/. - Root directory — leave default unless your project is in a subdir.
- Environment variables — add anything your build or runtime needs (API keys, etc.). You can set different values for production and preview.
- Project name — becomes part of your
- Save and Deploy. First build takes a few minutes. Watch the logs in the dashboard.
III. Build and Deployment Process:
- On each push to the production branch (or PR against it), Cloudflare triggers a fresh build.
- The build runs
npm installagainst yourpackage-lock.json, then your build command. Output lands indist/. - Deploys are atomic — the new version is fully uploaded before it goes live.
IV. Automatic Preview and Production Deployments:
- Production — every push to the production branch deploys to your main URL.
- Preview — every PR (or push to a PR branch) gets a unique preview URL like
unique-id.your-project.pages.dev. Cloudflare comments on the PR with the link.
V. Custom Domains (Optional):
- Pages project → Custom domains.
- Set up a custom domain.
- If your domain is on Cloudflare DNS, it’s a CNAME record.
- Otherwise, Cloudflare gives you the records to add at your registrar.
- Cloudflare manages the SSL/TLS certificate automatically.
VI. Troubleshooting:
- Build failures: check the deployment logs first. Common causes:
- Missing or out-of-date
package-lock.json - Wrong build command
- Missing environment variables
- Node version mismatch — set
NODE_VERSIONin build settings (e.g.18or20)
- Missing or out-of-date
- Wrong output directory: confirm it’s set to
dist/. - 404s on assets:
- Check the
baseconfig inastro.config.tsif deploying to a subpath. - Verify asset paths in your code.
- Check the
- Custom domain issues: verify DNS records propagated, and check the SSL/TLS status in Cloudflare.
Conclusion
That’s the setup. Astro for performance, Tailwind for styling, TypeScript for safety, and a small set of custom features to cover what I needed beyond the base theme. None of it is exotic — most of the value is in keeping the moving parts small and the build / deploy loop fast.
If you’re putting together something similar, the source is on GitHub — feel free to pull it apart.