Website update for 2026
Moving from Webflow to a custom build
After using Webflow for my portfolio for a couple of years, I decided to build the site myself. This wasn't because Webflow failed me. I still recommend it and still build client sites in it. But I wanted a site I understood down to the last line, and it felt like the right time to see how far a designer who codes can take a fully custom build with modern tools.
Why make the switch?
A few things pushed me toward building custom:
Cost. Between hosting, CMS features and add-ons, the monthly fees added up. For a personal site, that felt like more than it needed to be.
Ownership. Having every part of the site in a repository meant I could experiment freely, with nothing hidden behind a platform.
Learning. I wanted to work with a modern React framework end to end, from routing and server functions to deployment.
Tooling. With coding agents like Claude Code, custom builds have become far more approachable. I was curious how much of the structure and the fiddly details they could take on, and how much taste I could keep in my own hands.
The stack
TanStack Start for the framework. File-based routing, server functions for anything that touches a secret, and full server rendering out of the box. It runs on Vite, so the dev loop is fast, and it deploys to Cloudflare Workers with a single plugin.
Cloudflare Workers for hosting. The whole site is a single worker plus static assets, sitting on the free tier.
CSS Modules for styling. Plain CSS, scoped per component, with every value coming from a token file. No preprocessor, no runtime.
Base UI for the few interactive primitives I needed, mainly the buttons and the command menu dialog. Unstyled and accessible, so the look stays mine.
Geist for type, self-hosted, and Hugeicons for the handful of icons.
Markdown and JSON for content. Posts are markdown files rendered to HTML at build time with Shiki for code. Projects and the stack page are JSON. No CMS, no database, just files in the repo.
A very small design system
Before touching a component I wrote down the rules, and most of them are about saying no:
- Every color is OKLCH. Two ramps, a neutral gray and one green built from #7AFB79, and everything else is a semantic token on top of those.
- One font size, 1rem, and one weight, 500. Page intros are the only thing larger, at 1.3rem. Chips are the only thing smaller.
- rem for everything. Root is 16px. No px in the CSS except in comments.
- No borders. Surfaces are separated by tone, and the hover state is a gray fill, not an outline.
The text tokens are named after the contrast they clear rather than what they look like, which keeps the accessibility decision in the name:
:root {
color-scheme: light dark;
--text-aaa: light-dark(var(--gray-950), var(--gray-50));
--text-aa: light-dark(var(--gray-600), var(--gray-400));
--text-muted: light-dark(var(--gray-400), var(--gray-600));
--text-accent: light-dark(var(--green-800), var(--green-400));
--bg-page: light-dark(var(--gray-50), var(--gray-950));
--bg-raised: light-dark(var(--gray-200), var(--gray-800));
--row-height: 2.25rem;
--radius-md: 0.625rem;
}Light and dark are handled with light-dark() inside the token file, so no component ever has a color media query. The whole system fits in one file plus a short DESIGN.md that explains the rules the tokens encode.
One list pattern
Almost everything on the site is a list: projects on the home page, posts, the stack, the command menu. So there is one list component and it is reused everywhere. Each row is a full-width link, 36px tall, with a label in the gutter outside the content column. Years sit in that gutter on the projects list, section names on the stack.
<RowList
title="Personal projects"
rows={projects.map((project) => ({
key: project.url,
gutter: project.year,
icon: project.icon,
title: project.name,
badge: project.status,
aside: project.description,
href: project.url,
}))}
/>The gutter label is printed once per group, so a column of years reads like a timeline rather than a table. On small screens the gutter disappears and the label moves inside the row.
Server functions instead of API routes
Anything that needs a secret runs as a server function. The playground page pulls my latest Dribbble shots this way, with the token staying on the worker and the result memoised for an hour:
export const getDribbbleShots = createServerFn({ method: 'GET' }).handler(
async (): Promise<Array<DribbbleShot>> => {
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) return cache.shots
const token = process.env.DRIBBBLE_TOKEN
if (!token) return []
const res = await fetch('https://api.dribbble.com/v2/user/shots?per_page=12', {
headers: { Authorization: `Bearer ${token}` },
})
const shots = (await res.json()) as Array<DribbbleShot>
cache = { fetchedAt: Date.now(), shots }
return shots
},
)The route loader calls it, the page renders on the server, and the client never sees the token.
A stats page
The part I enjoyed most is a page of personal numbers. I run ccusage on my machine, which reads the local Claude Code and Codex logs, so I wrote a small script that pulls its daily report, aggregates tokens and cost per day and per model, and fetches the last year of my GitHub contributions through the GraphQL API. It writes everything to one JSON file that the page reads.
The page shows four numbers, a GitHub contribution calendar drawn as a plain CSS grid, a bar chart of daily tokens for the last thirty days built with Recharts, and a table of the models I use most. Hovering a bar breaks that day down by model.
Refreshing it is a Claude Code skill. I type /update-stats, it runs the script, checks the totals only went up, and commits the file.
Building with an agent
The whole site was built in a long conversation with Claude Code, one decision at a time: set the tokens, remove all the styling, add it back rule by rule, measure the result in a headless browser, adjust. I made the design calls. The agent wrote the code, ran the checks, took the screenshots, and told me when something I asked for contradicted something I had asked for earlier.
What it costs
Webflow was around $30 to $40 a month. The new site is $0: Cloudflare's free tier for hosting, a self-hosted Umami instance for analytics, and files in a repository for content.
Still recommend Webflow
I still build client sites in Webflow, and for a company without developers it remains the right choice. The visual editor is unmatched and the hosting is reliable. I moved because I wanted to own the whole thing and learn on the way, not because the platform fell short.
Moving forward
Custom development is more accessible than it has ever been. With a modern framework and a capable agent, a designer can ship a production site without being a full-time developer, and understand every line of it. That understanding is the point. It means I can keep iterating without running into someone else's limits.
If you are considering something similar, start small. Set your tokens, build one page, deploy it, then grow from there.
Thank you for reading.