Overview
This site. A portfolio I own end to end — design, code, pipeline, hosting — with no template underneath and nobody else’s commits in the history.
The repo opens on 18 January 2026, and the Cloudflare Pages deploy pipeline lands three days later, before there was anything worth deploying. That order was deliberate: get the domain serving and let Google start crawling while the content is still being written. v1.0.0 shipped on 26 March 2026. Since then: 932 commits, 28 tagged releases, and a second app on a subdomain that wasn’t part of the original plan.
What it does
It is a business card, and it behaves like one — you land, you find out what I build, you get a way to reach me. Everything is statically prerendered, indexable, and readable with JavaScript off. Dark theme by default, light on request, no flash on either.
- Portfolio — hero, about, stack, projects, contact, plus case studies like this one at
/projects/<slug>. - Docs hub on
docs.fkrzski.dev— its own Astro + MDX layout, with documentation for my open-source packages pulled in from their own repositories rather than duplicated here.

The hero, and the LCP element. Every animation above this fold is pure CSS — see below for what that cost to learn.
Why Astro
I considered Laravel with Blade first, since it is the stack I am fastest in, and I could have had an admin panel for editing content. For a static portfolio that is a combine harvester in a window box — a database, a runtime and a VPS to maintain so I can change a paragraph. React with Gatsby, and Angular with SSR, were the other candidates.
Astro won on two properties. It prerenders by default, with no configuration spent on convincing it to — everything else on the list treats static output as a mode you opt into and tune. And Islands mean choosing it now doesn’t foreclose anything later: if one component genuinely needs React, it gets React, and the other 95% of the page stays HTML.
From single app to monorepo
The trigger was Starlight. I wanted documentation on a subdomain of the same site, and Starlight turns out to be built on the assumption that it is the site — it wants its own Astro config, its own layout pipeline, its own everything. Bolting it into the portfolio app was not a fight worth having.
Two separate repositories was the obvious alternative, and I rejected it. Fonts, color tokens, spacing scale, ESLint and Prettier config, the Lighthouse thresholds — all of that would have to be maintained in duplicate, kept in sync by hand, and diverge the first week I got lazy. With more projects likely to appear later, that cost only grows.
So: npm workspaces. apps/web and apps/docs for the two sites; packages/theme for design tokens
and self-hosted fonts, plus docs-schema, og and lighthouse-config for the machinery both apps
need to agree on.
The migration landed on 7 July 2026 and it was the longest single stretch of work on this project — two months without a release (v1.1.1 on 17 May, v1.1.2 on 20 July) and 236 commits in July, the busiest month in the repo’s history. What kept it from turning into a rewrite was planning it as a sequence of small, individually shippable steps: convert to workspaces first, extract the shared tokens second, add the docs app last. At no point was the site broken for more than one commit.
One repository, but two deployments. Each app is its own Cloudflare Pages project — fkrzski.dev
and docs.fkrzski.dev — pointed at the same repository with its own build and its own root. GitHub
Actions never uploads a dist/; it POSTs to a per-app deploy hook and Cloudflare builds from there.
Shipping is tag-driven rather than push-driven. A push to master runs the full pipeline and
deploys nothing. A v*.*.* tag starts the release workflow, which refuses tags that aren’t on
master, bumps the version across the workspaces, fires both deploy hooks in parallel, and cuts the
GitHub release. Merging is cheap; releasing is a decision. The docs deploy carries a second trigger
on top of that — a repository_dispatch event, so a sibling project can refresh the docs hub from
its own repository when its documentation changes, without a commit landing here.

The docs hub — a separate Astro app on its own origin, drawing its fonts and color tokens from
packages/theme. Each project’s documentation is maintained next to its code and synced in.
Engineering challenges
Indexed by a backlink, not by a sitemap
Google found the site almost immediately and then did nothing with it. Pages sat in discovered — currently not indexed for weeks. Sitemap submitted, structured data in place, a link from my GitHub profile — none of it moved the needle.
What broke the deadlock was one link, from Dead by Stats to here. That domain has years of history and real traffic behind it. Two days later roughly 90% of the pages were indexed, with no other change on my side.
A sitemap gets you crawled. A link from a domain the crawler already trusts gets you indexed. Those are not the same problem, and I spent weeks solving the one I didn’t have.
One theme, two origins
fkrzski.dev and docs.fkrzski.dev are separate origins, so localStorage — where the theme
preference lives — does not cross between them. Pick light on the portfolio, click through to the
docs, get dark. Small thing, but it makes two sites out of what should read as one.
The fix is a theme cookie scoped to the apex domain, which a subdomain can see. Both sites run
the same toggle and the same inline script from packages/theme. The toggle saves every pick to
localStorage and mirrors it into the cookie. The script runs before first paint and takes the
cookie first, then the domain’s own localStorage, then dark — so there is no flash on either
origin.
The system preference plays no part. The toggle has two states, light and dark, so a visitor on a light-mode OS can’t knock the portfolio off its deliberate dark default just by opening the docs.
That inline script is hashed into each site’s CSP at build time — there is no unsafe-inline
anywhere on either origin.
The 1250 ms the hero spent invisible
The hero was animated with GSAP, like everything else on the page. Which meant the heading sat at
opacity: 0 until a ~48 KB bundle had downloaded, parsed and run — and the LCP element is charged
for every millisecond it spends transparent. That was ~1250 ms of pure element render delay on
the single metric the page is judged by.
The fix wasn’t dropping GSAP, it was drawing a line through the page. Anything needed for the site to be readable or usable is CSS or eager JavaScript: the hero entrance is now pure CSS keyframes, and the navbar background moved to a passive scroll listener — functional chrome shouldn’t wait on an animation library either.
Everything below the fold is still GSAP, and still should be. The scroll reveals, the staggered
stack grid, the project cards — animations.ts does all of it, lazy-loaded on idle or first
scroll, and the image lightbox pulls GSAP in on demand only once you actually click an image.
It is decoration, it now loads like decoration, and it can no longer hold the fold hostage.
The rule is written down in the repo so I don’t quietly undo it in six months.
Quality gates
Nothing merges without: astro check, ESLint, Prettier, Vitest across four workspaces, and 16
Playwright specs covering navigation, theming, view transitions, SEO, sitemap, CSP headers, and
axe-core accessibility.
A monorepo makes it easy to run everything on every change, which is the same as running nothing
carefully. So each run is routed by a path filter: a docs-only pull request never starts Playwright
or the web Lighthouse runners, and a web-only one never syncs and rebuilds the docs. Anything shared
— packages/**, the lockfile, the root config — counts as a change to both apps, because it is one.
On top of that, Lighthouse CI runs both apps on both form factors, two runs each. The thresholds that fail the pipeline are: performance ≥90 on mobile and ≥95 on desktop, and accessibility, best practices and SEO at exactly 100. The performance numbers have slack because mobile throttling on shared CI runners is noisy. The other three do not, deliberately — a regression there is a mistake, not weather.
Results
- Lighthouse: ≥95 performance, and 100 in accessibility, best practices and SEO, in every app/form-factor combination.
- 932 commits, 28 releases since January 2026, on a project with one contributor.
- Traffic: close to nothing, and I’m not going to dress that up. It’s a business card. Its job is to be there and be good when someone follows a link to it — not to acquire anybody.
What I’d do differently
-
Wouldn’t have made GSAP the default. It is still on the site and it still earns its keep on the scroll-triggered reveals. But reaching for it on everything is what put a 48 KB bundle in front of the LCP element, and most of what it did here — fades, slide-ups, staggers — CSS does natively and faster. The library should have been the exception I reached for, not the baseline I started from.
-
Would have started as a monorepo. The migration wasn’t difficult, it was just long, and it cost two months of releases at exactly the point where I had momentum. Workspaces on day one are nearly free; workspaces retrofitted onto a live site are not.
Status today
Actively maintained. Dependencies come in through Dependabot, small fixes ship continuously, and a release gets tagged when something is actually worth tagging. The design is where I want it, so most new work is content — this case study among it.
