Building kev.bot: a glass portfolio with a real backend

The full tour of how this site is built and run: React 19 over FastAPI, content that lives in a database and feeds the pages, the assistant and the résumé PDF, prerendered routes for crawlers, a pull-only deploy with two environments on one box, and the bugs that only showed up on a phone.

Kevin Witherow · 2026-10-12 · 28 min read

Most portfolio sites are a static page with a résumé on it. I wanted mine to be a small, real product: content I can edit from my phone without a deploy, a blog with proper rendering, an assistant that knows what is on the site, a résumé that is generated from the same data as the pages, and a deploy path that never needs me to open a terminal on the server. This is the long version of how kev.bot is put together, the decisions that made it simple to run, and the handful of bugs that were worth writing down.

It is long because the site is bigger than it looks. If you only want the shape of it, the first two sections and the diagrams carry most of the weight. The rest is for people who are building something similar and want to know where the sharp edges are.

The kev.bot home page: a floating header, the name in large type, and a terminal card booting the portfolio

The shape of it

Two services and a reverse proxy. The UI is a React 19 single-page app built by Vite; the API is FastAPI on SQLAlchemy 2, with Postgres in production and SQLite on a laptop. Caddy sits in front of both: it serves the built files, forwards anything under /api to the API over loopback, and hands a few other paths to the API too, because those are the ones that need real HTML rather than an app shell.

Architecture diagram: browser, Caddy, the static build, FastAPI, the database, the media folder and Claude, with the API side doubled for two environments

That is the whole topology. There is no CDN, no serverless layer, no build service, no queue. One box runs two copies of the stack (a development copy and the production copy, more on that below), and the parts talk over loopback. Nothing on the box listens to the internet except Caddy.

Piece Choice Why
UI React 19 · Vite · Tailwind v4 · shadcn/ui on Base UI Fast dev loop, sharp defaults, no runtime CSS
Motion motion (Framer's successor) Variants and layout animation without hand-rolled timing
API FastAPI · SQLAlchemy 2 (async) · Alembic · uv Typed request models, migrations, one process to run
Database Postgres 16 in production · SQLite in development Same code path through SQLAlchemy; a laptop needs nothing installed
Edge Caddy Automatic TLS, a readable config, try_files for the SPA
Assistant Claude through the Anthropic SDK Streams over server-sent events, grounded in the content bundle
PDF fpdf2 Real text in standard sections, built on request from the live content
Marks simple-icons + lucide Brand marks as tree-shaken paths, plain icons where a brand is trademark-restricted

The JavaScript bundle is a hair over one megabyte before compression, about a third of that over the wire. The admin area is its own chunk, so a visitor never downloads it.

Content is data, not JSX

Everything you read on the site, from the bio and the stats to the timeline, the skills, the projects, the ventures and the assistant's prompts, lives in the database as ten JSON collections. The API serves them in one call, and the UI bundles the seed copy as initial data so the first paint never waits on the network:

export function useContent(): ContentBundle {
  const q = useQuery({
    queryKey: CONTENT_QUERY_KEY,
    queryFn: ({ signal }) => api<ContentBundle>("/content", { signal, auth: false }),
    initialData: SEED_CONTENT,
    initialDataUpdatedAt: 0,
    staleTime: 5 * 60_000,
  })
  return q.data ?? SEED_CONTENT
}

Content pipeline diagram: the admin editor writes through Pydantic validation into the database; one bundle call feeds the UI, the assistant, the résumé PDF and the prerender

Each collection has a Pydantic model on the API side with extra="forbid", so the admin's schema-driven editor validates on save and a bad edit never reaches a page. The editor itself is generated from a spec per collection: a list of fields with a kind (text, textarea, number, select, image, a list of strings, a nested object, a list of objects), a label and a help line. Adding a field to the site is three small edits: the Pydantic model, the TypeScript type, and the spec. The form, the validation and the API contract follow.

The seed file is only the initial copy. After the first boot the database is the truth, and a CLI command round-trips it back into the seed whenever I want to commit a snapshot. The other direction has a safety catch: a reseed only ever replaces the collections you name, so pushing a new timeline entry from the repo never clobbers a profile edit made in the admin an hour earlier.

# Push three collections from the repo's seed into an environment's database.
# Anything not named is left exactly as it is.
kevbot seed --force --only timeline,skills,projects

Tip: Treating copy as data sounds like overkill for a personal site, right up until the third time you want to fix a typo from your phone. The admin area is a form over the same JSON, and the form was generated, not written.

One consequence took me a moment to appreciate. Because the schema is strict, a running API process validates every row it reads against the models it loaded at start-up. Add a field to the schema, reseed the database with rows that carry the new field, and an API process that has not been restarted will refuse those rows with a 500 until it is. That is the right behaviour (the alternative is silently dropping data), but it means the order of operations on a deploy is: restart the API first, then reseed. The deploy script does exactly that; my laptop occasionally forgets.

The design system in one file

The look is a single stylesheet: colour tokens in OKLCH for both themes, a handful of glass utilities, and a near-zero radius so panels read as crisp rectangles rather than cards. Blue leads; cyan, violet and mint support; amber is reserved for warnings and never used for a series in a chart.

.dark {
  --background: oklch(0.13 0.026 262);
  --foreground: oklch(0.97 0.006 250);
  --brand: oklch(0.74 0.15 252);
  --glass-strong: oklch(0.185 0.03 262 / 84%);
}

@utility glass-strong {
  background-color: var(--glass-strong);
  backdrop-filter: blur(28px) saturate(160%);
  box-shadow:
    inset 0 1px 0 0 var(--glass-highlight),
    0 0 0 1px var(--glass-border),
    var(--glass-shadow);
}

OKLCH earns its keep the moment you build the second theme. Lightness and chroma are perceptual, so "the same blue, a step lighter" is a number change rather than an afternoon with a colour picker, and a light palette can be derived from the dark one by moving two channels. The terminal card on the home page and the code blocks on this page force the dark palette on themselves with one utility, so they stay a lit screen on paper when the site is in light mode.

The skills page in light mode: white tiles with brand marks, a soft blue wash and a faint blueprint grid

Light mode took three rounds, and the sequence is a small lesson in restraint. The first pass was a flat off-white that read as unfinished. The second pass over-corrected: saturated colour fields and a visible gradient that looked dramatic in a screenshot and tiring on a real page. The third pass kept the idea (an atmosphere behind the glass) and cut the intensity by about two thirds: large, heavily blurred blobs at twelve to twenty percent alpha, a grid at five percent ink, and near-white glass. The rule I landed on is that a background should be something you can only see when you look for it.

Motion that does not fight you

Sections rise in as you reach them. That is a stagger container with child variants, driven by the section entering the viewport. It looked right for a week and then a post card went missing.

The blog list filters by tag. Pick a tag, then clear it, and the card that should have come back stayed invisible. The data was fine (it was cached, so the swap was instant), the DOM was fine, and the card's computed opacity was zero. The cause is a property of gesture variants in motion that is easy to miss: a whileInView variant only reaches the children that exist when it fires. A child mounted later, under a parent that already entered the viewport, inherits the initial state and nothing ever tells it to leave.

export function Reveal({ children, ...props }: HTMLMotionProps<"div">) {
  const [shown, setShown] = useState(false)
  return (
    <motion.div
      variants={staggerContainer}
      initial="hidden"
      animate={shown ? "show" : "hidden"}     // propagates to late children
      onViewportEnter={() => setShown(true)}  // the viewport still gates it
      viewport={{ once: true, margin: "-60px 0px -60px 0px" }}
      {...props}
    >
      {children}
    </motion.div>
  )
}

Driving the same variant through animate fixes the whole class of bug, because animate is inherited by children whenever they mount. The viewport observer still decides when; it just no longer decides who. I had hit the identical mechanism a day earlier with the tag pills themselves (they mount after the posts query resolves, after the stagger has played) and fixed that one locally. The second time, the fix went into the wrapper.

Note: The site respects prefers-reduced-motion through motion's global config, so the stagger, the ticker and the terminal typing all collapse to their end state for anyone who has asked for that.

Scroll-spy without an observer

The section index pinned to the right edge of this page (on a wide screen) needs to know which heading is current. The obvious tool is IntersectionObserver, but it has an awkward edge case: a short final section near the bottom of the page may never cross the trigger line, so it can never become active. A scroll listener that asks "which heading has passed a line a third of the way down the viewport" has no such gap, and it costs one measurement per frame:

const measure = () => {
  const doc = document.documentElement
  const atBottom = window.innerHeight + window.scrollY >= doc.scrollHeight - 2
  let current = ids[0]
  if (atBottom) current = ids[ids.length - 1]
  else {
    const line = window.innerHeight * 0.3
    for (const id of ids) {
      const el = document.getElementById(id)
      if (el && el.getBoundingClientRect().top <= line) current = id
      else break
    }
  }
  setActive((prev) => (prev === current ? prev : current))
}

The heading ids come from github-slugger on the Markdown source and from rehype-slug inside the renderer. Both produce the same slugs, so the index can be built before the article has rendered. Below the widest breakpoint the same list becomes a chip strip under the header; on a phone it becomes nothing, because a phone has no spare edge.

The career map

The experience page opens with a map: one lane per organisation, a bar from first role to last, a milestones lane with education and events, and a line for today. It is an SVG-free layout (plain divs on a percentage grid) because the bars need to be focusable, hoverable and clickable, and that is easier with elements than with paths.

The career map: lanes per organisation with logos, bars on a year grid, and a hover card over one bar showing dates, duration and role count

Three rules from the data-visualisation playbook shaped it:

The same organisation record carries a logo and a site. Where a mark exists it sits on a white tile; where one does not, a two-letter monogram in the organisation's colour stands in. Wordmarks widen the tile; square marks keep it square. One mark came only as an inverted version with a white band through it, so it was baked onto its own dark rounded tile rather than fought with.

Brand marks without a logo pile

The skills page shows fifty-odd technologies, and most of them have a recognisable mark. The marks come from simple-icons as named imports, which Vite tree-shakes down to the paths actually used; a mark is a <path> filled with currentColor and coloured with the brand's hex. Two details matter:

  1. Near-black marks need a different colour. Next.js, Express and shadcn ship as black. On a dark tile they vanish. A luminance check swaps those to the foreground colour, and it has to be done through a class, not an inline style, because an inline color beats any .dark rule.
  2. Some marks are not in the set on purpose. Most AWS and Microsoft marks are trademark-restricted and simple-icons does not carry them. Those fall back to a plain lucide icon (a cloud, a database, a workflow) so the grid stays even.

The home page's stack ticker uses the same component at fourteen pixels, which is the smallest size at which the marks still read.

SEO for a single-page app

A SPA ships one index.html, which carries the wrong title and description for every page except the home page. Two fixes, one per kind of page:

  1. Static routes are prerendered at build time. A post-build script writes dist/<route>/index.html for every public page (eleven of them) with its own title, canonical URL, OpenGraph tags, JSON-LD and a semantic skeleton of the content. Caddy serves it with try_files {path} {path}/index.html /index.html.
  2. Post pages are rendered by the API. Caddy routes /blog/<slug> to the API, which returns the built shell with the post's meta tags and the Markdown rendered to HTML inside #root. The app then boots on top of it.
@router.get("/blog/{slug}", response_class=HTMLResponse)
async def post_page(slug: str, db: DB, settings: SettingsDep) -> Response:
    shell = get_shell(settings).text()  # the built index.html, re-read when it changes
    post = await blog_svc.get_by_slug(db, slug, published_only=True)
    if post is None:
        doc = render_shell(shell, title="Not found", url=url, noindex=True)
        return HTMLResponse(doc, status_code=404)
    article = f"<article><h1>{_esc(post.title)}</h1>{render_markdown(post.body)}</article>"
    return HTMLResponse(
        render_shell(shell, title=post.title, url=url, type_="article", root_html=article)
    )
@post path_regexp ^/blog/[^/]+/?$
handle @post {
    reverse_proxy localhost:{api_port} {
        @down status 500 502 503 504
        handle_response @down {
            rewrite * /index.html
            file_server
        }
    }
}

Sequence diagram: the browser asks Caddy for a post page, Caddy proxies to the API, the API reads the database and returns real HTML, then the app boots over it and fetches the post as JSON

Warning: If the API is down, the fallback above serves the static shell so the page still loads. Crawlers get the generic meta tags in that window, which is why the health check runs before a deploy is called done.

This path had a real bug in production for about a day. The prerender writes the home page's skeleton into #root of the built shell, and the API's renderer replaced the literal empty <div id="root"></div>. After the first prerendered build, that string no longer existed, the replace never fired, and every post page shipped without its article. Crawlers saw a home page with a blog title. The fix was to replace whatever the root holds, and the regression test now uses a prerendered shell as its fixture rather than an empty one.

The blog you are reading

Posts are Markdown in the database. The renderer is react-markdown with GFM, rehype-slug for heading ids and rehype-highlight for code, plus a small set of extensions that this post leans on:

The crawler-facing render uses a Python Markdown pipeline with the same conventions, so the HTML a bot sees has the same callouts and captions as the HTML a person sees, minus the copy buttons.

Writing happens in the admin's editor, which is a plain textarea with a toolbar, a split preview and one feature I use constantly: paste. Rich text from Confluence, Teams, Word or Docs is converted to Markdown on the way in (turndown with the GFM plugin, and a small rule that turns Confluence's code macros into fenced blocks with the right language). Images pasted or dropped onto the editor go through the media library and come back as a URL at the caret.

Media pipeline diagram: upload, sniff the bytes, cap the size, store with a database row, make a WebP preview, swap the URL into the Markdown

The media library is deliberately boring: files on disk outside the release tree (so a deploy never touches them), a row per file with its type, size and alt text, a WebP preview for the grid, and a nightly archive alongside the database backup. Uploads are validated by content, not by extension; a renamed executable is refused with a 415 before a byte is written.

An assistant that knows the site

The chat widget in the corner is a Claude model behind the API. What makes it useful rather than generic is the system prompt: it is rendered from the same content bundle as the pages, so it knows the timeline, the skills, the projects and the ventures exactly as they are today, and editing the content in the admin updates what it knows without a deploy.

Sequence diagram: the chat widget posts to the API, the API rate-limits and builds the system prompt from the content bundle, streams from Claude, and forwards tokens to the browser as server-sent events

The request path is short. The API checks a per-client rate limit first and answers with a 429 and a Retry-After before any model is involved. It then trims the conversation to the last few turns, opens a streaming request to the model, and forwards each text delta to the browser as a token event over server-sent events, followed by a done event (or an error event, which the widget renders as a quiet apology rather than a stack trace). Caddy is told not to buffer that path, which is the one line of proxy configuration that matters for streaming.

async def body():
    yield svc.sse("start", {"session_id": str(session_id), "model": settings.chat_model})
    async for frame in svc.stream_reply(client, settings, system_prompt, history, outcome):
        yield frame
    # the turn is stored after the stream completes, never in its way

Every turn is stored with its session, and the admin has a transcript view. Reading those is how I found out which questions the content did not answer well, which fed back into the content. The assistant is also told, firmly, what it is not: it does not book meetings, it does not speculate about availability or rates, and it points at the contact form for anything that needs a reply from a person.

A résumé from the same data

The résumé page under About is the content bundle laid out as one scannable document, with a print stylesheet that drops the site chrome, the atmosphere and the rails. The PDF next to it is not a print of that page; it is built on request by the API with fpdf2, from the same bundle, in the plain single-column form that résumé scanners parse cleanly: standard section names, real text, no columns, no icons.

The résumé page: name and contact row, summary with stats, experience with dates in the margin, and a download button

Two things about fpdf2 are worth knowing before you reach for it. The built-in fonts draw Latin-1 and nothing else, so every string passes through a small cleaner that maps dashes, curly quotes, ellipses and bullets to ASCII cousins and drops what cannot be drawn (a trademark sign, an emoji). The bullet glyph itself is drawn as a small square. And a long document needs orphan control: a section label at the foot of a page with its content on the next one looks broken, so sections and entries check whether they fit before they start and move to a new page if not. With three bullets per role and a tighter line height it lands on two pages, and the site keeps the full bullet lists.

Important: The PDF is served with a short cache and a download disposition, and the download link on the page is a plain anchor. No JavaScript is needed to get the file, which also means a crawler can.

Two environments, one box

The develop branch deploys to dev.kev.bot and main deploys to kev.bot. Both run on the same server as separate instances, each with its own release tree, its own API port on loopback, its own database, its own environment file and its own media folder. They share Caddy and nothing else.

Deploy pipeline diagram: push, GitHub Actions, a release branch, a timer on the server that fetches and checks out, the release script, and the health check that polls the status endpoint

Nothing inbound is needed, which is the property I care about most. CI runs the Python linter and tests, the TypeScript compiler and ESLint, builds the UI and prerenders the routes, then commits the built output plus a small RELEASE file to a release branch. On the server, a timer fetches that branch every couple of minutes with a read-only deploy key and, if the commit changed, checks it out into a fresh release directory and runs the release script:

# The pull loop, trimmed. One of these runs per environment on its own timer.
git fetch -q --depth 1 origin "$RELEASE_BRANCH"
NEW=$(git rev-parse FETCH_HEAD)
[ "$NEW" = "$(cat .deployed)" ] && exit 0
git checkout -qf FETCH_HEAD
bash deploy/remote-setup.sh "$ENV"   # sync deps, migrate, restart the API instance, reload Caddy, prune old releases
echo "$NEW" > .deployed

The release script keeps the last five releases, so rolling back is a symlink change. The workflow, meanwhile, polls the environment's status endpoint until it reports the new commit, then smoke-tests the pages, the sitemap and the feed before the check goes green. A failed deploy shows up as a red check on the pull request, not as a surprise later.

production development
Branch main → a release branch develop → its own release branch
Host kev.bot dev.kev.bot
Content reseeded on purpose, by collection reseeded after every content change
Backups nightly dump plus the media folder, fourteen kept the same, because it is the same script

Important: One quirk worth writing down: a freshly created repository did not run push-triggered workflows at all until Actions was toggled off and on once. Manual dispatch worked the whole time. If your first push sits there silently, that is the first thing to try.

Testing a site like a product

The API has a pytest suite (forty-odd tests across eleven files) that covers the content bundle and its seeding, the blog and the seed post, the media library, the assistant's streaming and rate limiting, the contact inbox, the auth flow, the SEO render and the résumé PDF. It runs against SQLite in CI and takes under ten seconds. Ruff keeps the Python honest; tsc and ESLint do the same on the other side.

The part that is less standard is visual verification. I run a local Chrome headless over the DevTools protocol and take screenshots of each page at desktop, phone and light-mode settings after every layout change, then look at them. That sounds laborious and is not: a sixty-line script captures sixteen views in about a minute, and the screenshots catch things no test would, like a tag strip stuck at opacity zero or a figure strip whose fourth cell is wider than its column.

The same script answers one question I now ask on every route, because the answer was wrong three times before I asked it:

Diagram: two phone frames; on the left one element is wider than the 390 pixel viewport, on the right Chrome has laid the whole page out at 553 pixels and scaled it down

On a phone, a single element that cannot wrap does not merely stick out. Chrome widens the layout viewport to the content's minimum width and scales the page down to fit, so everything renders a third smaller and nothing looks broken enough to notice at a glance. innerWidth gives it away: it should equal the device width, and on three routes it did not. The culprits were a terminal log line with no explicit grid column, a truncate caption inside an intrinsically sized figure, and one unbreakable word set at figure size in a half-width cell. The fixes are one line each (grid-cols-1 with min-w-0, line-clamp instead of truncate, overflow-wrap: anywhere); finding them took a probe that hides elements one branch at a time and watches the document width.

The admin, briefly

Everything on the site is editable from the admin area, which is a separate chunk of the app behind an owner login and never downloaded by a visitor. It has six screens, and none of them took long to build because they are all forms over the same JSON:

Screen What it does
Content One generated form per collection: lists you can reorder, nested objects, image fields that open the media picker, a diff of what changed before you save
Blog The post list with drafts and published posts, the editor with paste conversion and a split preview, a cover picked from the library
Media The grid, search, alt text, copy as URL or Markdown, delete
Inbox Contact messages with a read state, newest first
Transcripts The assistant's turns grouped by session
Settings The assistant's switch and the handful of knobs it has

The pattern that made this cheap is the spec. A collection's editor is described as data (field kinds, labels, help text, which fields are optional), and one component renders any spec. Adding the period and note fields to projects, which happened the day before this post went up, was a line in the Pydantic model, a line in the TypeScript type and two lines in the spec. The form, the validation and the API contract followed without another edit.

Sessions are signed with a server-side key and the API refuses to start a sign-in flow without one. There is one owner account, no registration, and the public API is read-only except for the contact form and the assistant, both of which are rate-limited per client.

What is and is not exposed

A personal site is still a server on the internet, so a short, honest list of the surface:

One editorial choice follows from that list: nothing in this post names a port, a path, a database or a host that is not already public. Those details would not help a reader build their own version, and they would help exactly one other kind of reader.

Accessibility and performance

The two overlap more than people expect, because both reward doing less.

The boring parts: the CLI, backups and logs

Most of the operational surface is a single command with a few subcommands, which keeps the runbook short enough to remember:

kevbot seed --force --only timeline,skills   # push named collections from the repo's seed
kevbot seed-posts --force                    # overwrite seed posts by slug (this one, for instance)
kevbot export-content --out seed/content.json  # snapshot the database back into the repo
kevbot inbox                                 # read the contact inbox from a terminal
kevbot transcript                            # the assistant's recent turns

The status endpoint reports the release commit, so the same number the terminal card on the home page shows is the one CI polls for after a deploy and the one I check when something looks stale. Each environment has its own access log with rotation, and those logs are better debugging tools than they look: the invisible-card bug above was traced by reading the sequence of requests from one browser and noticing the request that never happened, because the data was already cached.

By the numbers

Routes prerendered at build 11
Content collections 10
Timeline entries · skills · projects · ventures 23 · 54 · 7 · 4
API tests 43, across 11 files, under ten seconds
JavaScript over the wire about 320 kB compressed, admin excluded
Diagrams in this post 7, all drawn by one script from a shared style
Résumé PDF 2 pages, built on request from the live content
Deploy, push to live about four minutes, hands off

Things that bit me

A short list, in the order they happened, for anyone searching for the symptom:

Still on the list

If you have built something similar and landed on different trade-offs, I would like to hear about it. The source is on GitHub, and the contact form on this site goes straight to the inbox I actually read[^1].

[^1]: Every message is stored in the database first and emailed second, so a mail outage never loses one.

All posts