Skip to content
Rustam Niraula
Rustam Niraula

6 min read

Rebuilding My Portfolio: From a Static Single Page to Astro, Pagefind, and Cloudflare

How a hand-written Vite single-pager became a content engine: Astro 7 collections, build-time Pagefind search, per-post social cards, and a deploy on Cloudflare that fires on every push.

  • Astro
  • Pagefind
  • Cloudflare
  • Frontend
On this page · 13 sections
  1. The rules first
  2. Before and after
  3. Typed frontmatter
  4. One layout owning the shell
  5. Fonts without a network
  6. The hero canvas
  7. Categories instead of tag soup
  8. Code blocks that know what they are
  9. Search
  10. Social cards, generated
  11. The contact form and its Worker
  12. Deploy
  13. One thing I removed on purpose

For two years the portfolio was about as simple as a site gets: one index.html, one 611-line style.css, one src/main.js, and Vite to bundle it. That was the entire repository. It was fast, cost nothing to run, and never broke.

It also couldn’t hold an article. Every position on that page was hand-placed markup, so “add a blog” meant hand-writing another page, and another, forever. Three things I wanted instead: write in Markdown and git push, get real search without paying for a search API, and deploy without a step in between.

This is the move, and what got built on top of it afterwards.


The rules first

I wrote the constraints down before picking anything, because the tools are the easy half.

  1. Static output. Pages ship as HTML. JavaScript where something genuinely needs to be interactive.
  2. Markdown in, site out. A post is a file. No CMS, no database, no admin panel.
  3. Search with no backend. No Algolia, no Elasticsearch, no per-query cost. $0/month has to stay $0/month.
  4. Don’t lose what worked. The hero canvas, the Open Graph tags, the Person JSON-LD, the analytics snippet. Those were the parts of the old file with accumulated value in them.
ConcernChoice
BuildAstro 7, static output
Markdown + components@astrojs/mdx
SearchPagefind, post-build
Social cardsSatori → resvg, at build time
HostingCloudflare Workers static assets, built on push

Astro renders components to HTML and only hydrates what you explicitly ask it to, which is constraint 1 for free. Pagefind is the interesting one: it doesn’t index source files or ship a client-side index like Fuse.js. It runs after the build, reads the HTML Astro just emitted, and writes a chunked index. The browser downloads only the fragments a query touches.


Before and after

.
├── index.html
├── package.json
├── src
│   └── main.js
├── style.css
└── wrangler.jsonc
.
├── astro.config.mjs
├── worker/
│   └── index.ts                  # /api/contact, and the asset server
└── src/
    ├── content.config.ts          # the schema every post is checked against
    ├── content/blog/              # posts, as Markdown
    ├── data/                      # site, categories, contact rules
    ├── lib/                       # post helpers, OG card renderer
    ├── components/
    ├── layouts/BaseLayout.astro   # <head>, SEO, schema
    ├── pages/
    │   ├── index.astro            # home
    │   ├── 404.astro
    │   ├── rss.xml.ts
    │   ├── sitemap.xml.ts
    │   ├── og/[slug].png.ts       # one social card per post
    │   └── blog/
    │       ├── index.astro
    │       ├── [slug].astro
    │       └── category/[category]/index.astro
    └── styles/global.css          # tokens in, components below

No top-level style.css and no main.js. The stylesheet moved in, and main.js came apart into whatever component owned each behaviour.


Typed frontmatter

Content collections are what make a folder of Markdown behave like a database.

const blog = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    category: z.enum(categorySlugs),
    tags: z.array(z.string()).optional(),
    featured: z.boolean().optional(),
    summary: z.string().optional(),
  }),
});

A typo in frontmatter is a build failure, not a blank spot on a deployed page. category is an enum over a hand-maintained list, so catagory: systems stops the deploy rather than producing a post filed under nothing.

One layout owning the shell

BaseLayout.astro holds what has to be true on every page: <head>, header, footer, and the metadata the old index.html had collected. That last part is the one worth being careful about — Open Graph tags, the Person JSON-LD, the analytics snippet, all sitting in a file I was about to delete, which is exactly when that sort of thing disappears without anyone noticing.

const canonical = new URL(Astro.url.pathname, Astro.site ?? Astro.url).href;

Canonical and social URLs are derived rather than pasted. isHome scopes the Person schema to one page instead of repeating it everywhere. Posts add BlogPosting and a BreadcrumbList; the breadcrumb is passed in as props, so the template doesn’t have to know where in the hierarchy it sits.

Fonts without a network

Three families, all self-hosted, resolved at build time from the @fontsource packages:

fonts: [
  {
    name: "Fraunces",
    cssVariable: "--font-fraunces",
    provider: fontProviders.local(),
    fallbacks: ["Georgia", "ui-serif", "serif"],
  },
],

fontProviders.local() means npm run build never reaches out to Google, and Astro emits a metric-matched fallback face so the first paint uses something the right shape rather than Times. Fraunces carries the optical-size axis, so headings pick up the high-contrast display cut as they grow without a second file.

The hero canvas

Worth a section because the first version was quietly broken.

The original drew a random walk mirrored across four quadrants, accumulating into a symmetric lattice. It ran on setInterval(fn, 10) — roughly 100fps, forever, redrawing the whole canvas every 10ms whether or not anyone was looking. A mousemove listener called the resize handler, which reset the walk position, so moving the mouse snapped the pattern. No prefers-reduced-motion, no devicePixelRatio handling.

The drawing is now a flow field instead. Around seventy particles drift through a field built from three offset sine waves, and the trail is decayed with a 1.7% white wash per frame rather than clearRect. That wash is the whole trick — it fades old segments toward the paper while new ones draw on top, which is what makes it read as long exposure instead of scribble.

const angleAt = (x, y) =>
  Math.sin(x * 0.0021 + t * 0.21) +
  Math.cos(y * 0.0027 - t * 0.16) +
  Math.sin((x + y) * 0.0013 + t * 0.11);

Seventy particles and k=3 nearest neighbours is a few thousand float operations a frame. A library would have cost more in bytes than the graph does.

The lifecycle is where the previous version was failing: one requestAnimationFrame loop, paused when the tab is hidden or the hero scrolls out of view, DPR capped at 2, and a single settled frame under prefers-reduced-motion.

Categories instead of tag soup

The first blog index derived its sidebar from whatever tags happened to be in use, with counts. Nine posts produced twenty-two categories, most with one item, which is a filter nobody can use.

It’s a hand-maintained list now, with a rule that a category waits for roughly three posts before it earns a page:

export const categories = [
  { slug: "systems", name: "Systems & Infrastructure", icon: "hub", description: "..." },
  { slug: "algorithms", name: "Algorithms", icon: "account_tree", description: "..." },
  { slug: "machine-learning", name: "Machine Learning & Statistics", icon: "psychology", description: "..." },
] as const;

as const plus z.enum means adding a category is one edit in one file and a typo is a build error. The filter is plain links to /blog/category/<slug>/ — no client-side filtering, so it works with JavaScript off and every filtered view is shareable. I had a “Load More” button too, which went: with nine posts it hides content from crawlers and from Ctrl-F, and client-side pagination is the wrong tool at this size.

Code blocks that know what they are

Nine posts, 102 fenced blocks. Two things I wanted that weren’t there.

Filenames. A fence can carry a title, and a Shiki transformer lifts it onto the <pre>, so the header bar above a block can name the file:

```go title="crawler.py"

One trap: the fence has to declare a language. Shiki reads a bare ``` title="x" as the language name, swallows the title, and data-language degrades to plaintext. Cost me one block and about ten minutes.

Copy buttons and line highlighting are a small script over the rendered <pre> — posts are Markdown, so this enhances the output rather than wrapping it in a component. The overflow fade uses a token rather than a literal, because its position has to track the header height above it and a magic number there silently desyncs the moment the padding changes.

Two markers do the work:

<article data-pagefind-body>
  <h1 data-pagefind-meta="title">{post.data.title}</h1>
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 only that element’s subtree. The home page and the feed drop out automatically; searching returns articles rather than fragments of my own navigation. The table of contents carries data-pagefind-ignore because the heading text is already inside the body and indexing it twice just skews relevance.

The UI is Pagefind’s Component UI — custom elements, so mounting is markup and a module script:

<pagefind-input instance="blog" autofocus="false"></pagefind-input>
<pagefind-summary instance="blog"></pagefind-summary>
<pagefind-results instance="blog"></pagefind-results>

Elements sharing an instance attribute find each other, which is what lets the input, the count and the results sit independently in the layout. Two details that cost time: the script needs type="module", and in Astro it also needs is:inline, because /pagefind/ only exists in build output and the bundler has nothing to resolve at compile time. Custom elements don’t upgrade until their definition loads, so astro dev renders an inert element rather than an error — a :not(:defined) rule reserves the input’s height so nothing below it jumps when it paints.

/ and ⌘K focus the field. The deep link is ?q=, which is also the SearchAction target in the WebSite schema.

Social cards, generated

Every post gets a 1200×630 card rendered at build time by Satori and rasterised with resvg, from a handful of frontmatter fields.

export const GET: APIRoute<{ post?: Post }> = async ({ props }) => {
  const { post } = props;
  return pngResponse(
    await renderCard({
      kicker: `Writing / ${getCategory(post.data.category).name}`,
      title: post.data.title,
      footerLeft: site.name,
      footerRight: isoDate(post.data.pubDate),
    })
  );
};

Satori reads WOFF and TTF but not WOFF2, so the cards load the static @fontsource faces while the site itself serves the variable WOFF2 files. Same fonts, different packaging.

Previously og:image pointed at the app icon, so every link I’d ever shared rendered as a generic badge. That was the single worst bug on the site and it took two years to notice.

The contact form and its Worker

The static site is served by a Worker, and Cloudflare only invokes the script when a request doesn’t match a static asset — so almost nothing reaches it. One route is handled: POST /api/contact.

if (typeof name !== "string" ||
    typeof email !== "string" ||
    typeof message !== "string") {
  return { status: "invalid", code: 400 };
}

The rules that validate the request live in src/data/contact.ts, which the Worker imports and the form imports. One module, so the length limits and the email pattern can’t drift apart between the two — which is the failure mode of every other form I’ve written, where the browser check and the server check disagree and one of them is wrong.

It accepts JSON and form-encoded bodies. With JavaScript off the form posts normally and the Worker answers with a 303 to a static page under /contact/<status>/, so a failed submission still shows a real result. With JavaScript on, it’s a fetch and a status toast.

Deploy

The repository is connected to the Worker. git push to the default branch is the deploy: Cloudflare clones, runs npm run build, publishes. Nothing on my machine, no CI provider in front of it.

{
  "assets": { "directory": "./dist" },
  "compatibility_flags": ["nodejs_compat"],
  "observability": { "enabled": true }
}

assets.directory is the contract between the two halves — Astro’s output is what gets uploaded and served.

Publishing a post is now: add a file with valid frontmatter, push. Astro validates it against the schema and renders every route, Pagefind indexes the output, Cloudflare serves it. Because validation happens in the build, a malformed post fails before it reaches the edge and the previous deployment stays up. That’s what makes push-to-publish feel ordinary rather than nerve-wracking.

One thing I removed on purpose

The redesign had a dark mode. It looked fine in isolation — a full second set of tokens under prefers-color-scheme: dark, background at #14161a, and the hero canvas re-reading its stroke colour when the scheme flipped. I cut the whole branch.

The reason is that white here isn’t the absence of dark mode, it’s the material. Sections separate by a change of tone rather than by a card edge, and the canvas fades toward #ffffff with a rgba(255, 255, 255, 0.006) wash per frame to get the long-exposure trail — both of those decisions assume a light ground. A dark variant isn’t a reskin of that, it’s a second site. And the only people who saw mine were the ones whose OS asked for it, which meant I was reviewing my work in one material and shipping another.

So: one theme, white, regardless of preference. The exception is the contact band, which is dark on every device:

/* Single theme. There is no `prefers-color-scheme` branch: the site is
   white on a dark-preference OS, the same as it is on a light one. The
   hero canvas no longer re-reads its stroke on a theme flip either. */

.band-ink isn’t a theme either. It re-declares the token block inside its own scope, so bg-surface, text-on-surface and friends keep working inside the band and no component has to know it landed there. Cheaper to reason about than a data-theme attribute that half the components would have had to check, and it gets color-scheme: dark for free — which is what stops the browser rendering a light scrollbar and light form controls in the middle of the page.


No database, no server, no search service. Twenty HTML pages, an index directory, and a Worker that only wakes up when someone fills in the contact form.

The structural migration was the whole job and it was a straightforward one. Astro gave me typed content and static output, Pagefind gave me search without a backend, Cloudflare gave me deploys without a pipeline. Nothing about it needed a rewrite of how the site worked — and the parts that did get rewritten later taught me more than the parts that didn’t.