Rebuilding My Portfolio: From a Static Single Page to Astro, Pagefind, and Cloudflare
How I turned a hand-written Vite single-pager into a content engine: Astro 7 content collections, build-time Pagefind search, and a deploy pipeline on Cloudflare Workers that fires on every git push.
For a couple of years my portfolio was about as simple as a website can be: one index.html, one 611-line style.css, one src/main.js, and Vite to bundle it. That was the whole repository. It was fast, it cost nothing to run, and it never broke.
It also couldn’t hold an article. Everything on the page was hand-positioned markup, so “add a blog” meant “hand-write another page, and another, forever.” I wanted three things instead: write in Markdown and git push, get real search without paying for a search API, and deploy without a manual step.
This post covers the move: from Vite to Astro, posts as a typed content collection, the built HTML indexed by Pagefind, and the whole thing shipped to Cloudflare on every push.
Constraints
I set the rules before picking tools, because the tools are the easy part:
- Static output, no runtime framework. Pages ship as HTML. JavaScript only where something genuinely needs to be interactive.
- Markdown in, site out. Adding a post is adding a file. No CMS, no database, no admin panel.
- Search with no backend. No Algolia, no Elasticsearch, no per-query cost. $0/month has to stay $0/month.
- Keep what already worked. The canvas animation in the hero, the Open Graph tags, the
PersonJSON-LD, and the analytics snippet all had to survive the migration — those were the parts of the old site with real accumulated value.
The stack that came out of it
| Concern | Choice | Version |
|---|---|---|
| Framework / build | Astro (static output) | 7.2.0 |
| Markdown + components | @astrojs/mdx |
7.0.5 |
| Search | Pagefind (post-build indexer) | 1.5.2 |
| Hosting | Cloudflare Workers static assets, built on push | — |
Astro renders components to HTML at build time and only hydrates what you explicitly ask it to, which lines up exactly with constraint 1. Pagefind is the interesting one: rather than indexing source files or shipping a full client-side index like Fuse.js or FlexSearch, it runs after the build, reads the emitted HTML, and writes a chunked index. The browser downloads only the fragments a given query touches.
Before and after
The old tree, in full:
.
├── index.html
├── package.json
├── src
│ └── main.js
├── style.css
└── wrangler.jsonc
The current tree:
.
├── astro.config.mjs
├── package.json
├── wrangler.jsonc
├── public/ # Favicons + webmanifest, served from /
└── src/
├── content.config.ts # Collection schema (typed frontmatter)
├── content/
│ └── blog/ # The posts themselves, as Markdown
├── components/
│ └── CanvasAnimation.astro # Hero canvas, preserved from the old site
├── layouts/
│ └── BaseLayout.astro # <head>, nav, footer, SEO, analytics
├── pages/
│ ├── index.astro # Portfolio home
│ └── blog/
│ ├── index.astro # Article feed, categories, search
│ └── [slug].astro # One route per Markdown file
└── styles/
└── global.css # Base styles, carried over from style.css
Note what isn’t there anymore: no top-level style.css, no main.js. The old stylesheet moved into src/styles/global.css, and main.js split into the component that owned each behaviour.
Step 1: Build tooling
Astro replaced Vite as the top-level tool (it uses Vite underneath, so nothing was lost), and the build command gained a second stage:
{
"scripts": {
"dev": "astro dev",
"build": "astro build && npx pagefind --site dist",
"preview": "astro preview"
},
"devDependencies": {
"@astrojs/mdx": "^7.0.5",
"astro": "^7.2.0",
"pagefind": "^1.5.2"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"tailwindcss": "^4.3.3"
}
}
That && npx pagefind --site dist is the whole search infrastructure. Astro writes ./dist, Pagefind reads ./dist and writes ./dist/pagefind. Both run in the same build, so the index can never drift from the content — there is no separate index to invalidate.
The Astro config stays small:
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
site: "https://rustamniraula.com.np",
output: "static",
integrations: [mdx()],
markdown: {
shikiConfig: { theme: "dark-plus" },
},
vite: {
plugins: [tailwindcss()],
},
});
site is what makes canonical URLs and absolute Open Graph URLs possible at build time — every place I used to hand-type https://rustamniraula.com.np/... is now derived from this one value.
Step 2: Typed frontmatter
Content collections are the reason a Markdown-file-as-database actually holds up. src/content.config.ts declares where posts live and what shape their frontmatter must have:
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
tags: z.array(z.string()).optional(),
heroImage: z.string().optional(),
}),
});
export const collections = { blog };
The payoff is that a typo in a post’s frontmatter is a build failure, not a blank spot on a deployed page. pubDate: z.date() also means I get a real Date object in templates, so sorting and toLocaleDateString() work without parsing strings by hand.
Step 3: One layout owning the shell
BaseLayout.astro holds everything that must be true on every page: the <head>, the sticky nav, the footer, and all the metadata the old index.html had accumulated. That last part mattered — Open Graph tags, the Person JSON-LD block, and the analytics snippet were all sitting in the file I was about to delete, and a migration is exactly when that kind of thing quietly disappears.
Canonical and social URLs are now derived rather than pasted:
const canonical = new URL(Astro.url.pathname, Astro.site ?? Astro.url).href;
const isHome = Astro.url.pathname === "/";
const isBlog = Astro.url.pathname.startsWith("/blog");
isHome scopes the Person schema to the homepage instead of repeating it on every article; isBlog is what makes the nav’s Blog link render as active on blog routes and plain elsewhere. The favicon set and site.webmanifest live in public/, which Astro copies to the site root verbatim, so the generator’s default paths (/favicon-32x32.png, /apple-touch-icon.png) work unchanged.
For page-specific <head> additions, the layout exposes a named slot:
<slot name="head" />
The blog index uses it to pull in the Pagefind stylesheet, so the other pages never request a file they don’t need.
Step 4: Keeping the canvas animation
The old hero had a canvas drawing a symmetric maze, four mirrored quadrants at a time, from a setInterval in main.js. I wanted it kept, and Astro makes that pleasant: a component can own its own <script>, written in TypeScript, and Astro bundles it.
<div class="header-canvas-wrap" aria-hidden="true">
<canvas id="headerCanvas"></canvas>
</div>
<script>
function initHeaderCanvas() {
const canvas = document.getElementById("headerCanvas") as HTMLCanvasElement | null;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// ... mirrored quadrant drawing, unchanged from the original
}
initHeaderCanvas();
</script>
The behaviour is the old code almost verbatim. What changed is ownership: the canvas markup, its sizing, and its script are one file, and the page that wants it writes <CanvasAnimation />. The wrapper is aria-hidden — it’s decoration, and screen readers shouldn’t have to care.
Step 5: The article feed
/blog reads the collection at build time and sorts it:
const posts = (await getCollection("blog")).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const [featured, ...rest] = posts;
The design called for a sidebar of categories. Rather than hardcode a list that would rot, I derive it from the tags actually in use, with counts:
const tagCounts = new Map<string, number>();
for (const post of posts) {
for (const tag of post.data.tags ?? []) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}
const tags = [...tagCounts.entries()].sort(
(a, b) => b[1] - a[1] || a[0].localeCompare(b[0])
);
Filtering by category and the “Load More” button are the one place I accepted client-side JavaScript, and it’s about forty lines: every card carries data-tags, and a single render() function decides visibility from two variables — the active tag and whether the list is expanded. No routes, no re-fetching, no framework. Astro passes the page-size constant into that script directly:
<script is:inline define:vars={{ PAGE_SIZE }}>
Tag archive routes would be better for SEO, and that’s on the list. But a filter that works on a five-post blog beats twelve generated pages that mostly say “1 result.”
Step 6: Article pages, table of contents, reading time
Each Markdown file becomes a route through getStaticPaths, and render() returns more than just the content component:
const { post } = Astro.props;
const { Content, headings } = await render(post);
// Only h2/h3 make it into the table of contents
const toc = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
const words = (post.body ?? "").split(/\s+/).filter(Boolean).length;
const readingTime = Math.max(1, Math.round(words / 200));
headings is the whole table of contents, already slugged by Astro’s Markdown pipeline, matching the id attributes on the rendered headings. The sidebar TOC is that array mapped to links — no parsing HTML, no plugin. A small IntersectionObserver highlights the entry for whatever section is on screen, and :target { scroll-margin-top: 6rem; } keeps the sticky header from covering a heading you just jumped to.
Reading time is word count over 200. It’s a rough number and everyone’s is; it’s honest about being an estimate.
Step 7: How the search actually indexes
The two markers that control Pagefind are in the article template:
<article data-pagefind-body>
<h1 data-pagefind-meta="title">{post.data.title}</h1>
data-pagefind-body is more powerful than it looks. From the build log:
[Parsing files]
Found a data-pagefind-body element on the site.
↳ Ignoring pages without this tag.
Once any page declares it, Pagefind indexes only pages that have it — and within those pages, only that element’s subtree. So my portfolio home and the blog feed are automatically excluded, and searching returns articles rather than fragments of my own navigation. The TOC sidebar gets data-pagefind-ignore for the same reason: heading text is already in the body, and indexing it twice just skews relevance.
The UI is a set of custom elements — Pagefind’s Component UI — so mounting it is markup plus a module script, with no bootstrap code of my own:
<link href="/pagefind/pagefind-component-ui.css" rel="stylesheet" />
<script src="/pagefind/pagefind-component-ui.js" type="module" is:inline></script>
<pagefind-input instance="blog"></pagefind-input>
<pagefind-summary instance="blog"></pagefind-summary>
<pagefind-results instance="blog"></pagefind-results>
<pagefind-config instance="blog"></pagefind-config>
Elements sharing an instance attribute find each other, which is what lets the input, the result count and the result list be positioned independently in the layout instead of inside one widget container.
Two details worth knowing. The script needs type="module", and in Astro it needs is:inline so the bundler leaves the path alone — /pagefind/ only exists in the build output, so there’s nothing there for Vite to resolve at compile time. And because custom elements simply don’t upgrade until their definition loads, astro dev (where the index doesn’t exist yet) renders an inert element rather than an error; a :not(:defined) rule reserves the input’s height so the results below don’t jump when it paints.
Step 8: Deployment on Cloudflare
The site is a Cloudflare Worker serving static assets, built by Workers Builds on every push. The repository is connected to the Worker, so a git push to the default branch is the deploy: Cloudflare clones the commit, runs npm run build, and publishes the result. There is no deploy step on my machine and no separate CI provider in front of it.
The entire configuration is wrangler.jsonc:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "portfolio-website",
"compatibility_date": "2026-04-22",
"observability": { "enabled": true },
"assets": { "directory": "./dist" },
"compatibility_flags": ["nodejs_compat"],
"preview_urls": false,
"workers_dev": false
}
assets.directory is the contract between the two halves: Astro’s output directory is what Cloudflare uploads and serves. workers_dev: false and preview_urls: false keep the site reachable only on its own domain, and observability turns on request logs for the asset Worker.
The full lifecycle of publishing a post:
- Write a Markdown file in
src/content/blog/with valid frontmatter, and push it. - Workers Builds runs
npm run buildon the commit — Astro validates frontmatter against the schema and renders every page to./dist, then Pagefind indexes./distand writes./dist/pagefind. - Cloudflare deploys the Worker and its assets to the edge.
Because the schema check happens in step 2, a malformed post fails the build and never reaches the edge — the previous deployment just stays up. That’s the safety net that makes “push to publish” comfortable rather than nerve-wracking.
No database, no server, no search service. Four HTML pages and an index directory.
The honest summary: the structural migration was the whole job, and it was a straightforward one. Astro gave me typed content and static output for free, Pagefind gave me search without a server, and Cloudflare gave me deploys without a pipeline to maintain. Nothing about it required a rewrite of how the site looked — just how it was built.