01A portfolio about itself
This is the second version of my portfolio, and you are reading the case study about it on the thing it describes. Circular, I know. The first version was a static gallery: a card per project, a sentence of description, a link. It could show you that I built something. It could never tell you what building it was like.
I am still fond of that first one. It was a create-react-app project from university. I built it while most of my classmates did not have a site, and I taught myself React and the Intersection Observer API getting it out the door. But create-react-app came with baggage I kept tripping over. Builds were slow, the bundle was heavy, and every time I wanted something modern, like a real blog, it was a small fight. To build what I actually wanted, I needed a better foundation.
The push to rebuild came from a friend, Eman. He writes deep-dive posts about whatever he is digging into and sends them my way. It clicked when I returned to one of my own projects and could not remember what half the code did. A portfolio should tell the story for whoever is reading and for the version of me who comes back a year later. That became the bar: if a project is not worth writing about, maybe it does not belong here. This case study earns its place by running through the same structured writing and annotated-code system it describes. Circular, but at least it brought evidence.
02How it looks, and why
Before any of the engineering, there was a look I was after. The first face of v2 started from the Praha template in Framer, pulled toward something sparer: dark, with the navigation pinned to the sides and the content running down the middle. I picked that layout on purpose. The whole point of a portfolio is the work, so the chrome stays out of the way and lets the projects be the main event.
That dark version fronted the site for most of its life. The Ledger came next, with paper, dark ink, and one oxide-red accent. Its numbered contents column, margin notes, and hairline rules turned the site into the engineering notebook it had become. The current redesign is Negative. It keeps that editorial structure but reverses the surface again: matte graphite paper, cool silver ink, and one ice-blue mark.
03From one app to a workspace
v2 is not a single app anymore; it is a Turborepo monorepo. The Next.js site and the Sanity Studio that feeds it live side by side, with a shared package holding the content schemas. The shared schemas are the part I care about, and the trick is not some elaborate build wiring. The package is plain TypeScript source that the Studio imports directly. The website never touches it. Instead, one root command asks the Studio to extract its schema and runs Sanity TypeGen against the site's GROQ queries, generating the exact return type of every query. Change a schema field and regenerate, and the compiler points at every place the website now disagrees with the CMS. Turbo's job is orchestration: the "^build" in the config tells it to build a package's dependencies before the package itself, and one command runs everything in development.
{
"$schema": "https://turborepo.com/schema.json",
"globalEnv": ["NODE_ENV"],
"globalDependencies": [".env*"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"env": ["SANITY_STUDIO_PROJECT_ID", "SANITY_STUDIO_PROJECT_TITLE"]
},
"lint": {
"dependsOn": ["^lint"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}04Writing as structured data
Every case study, including this one, is Sanity content stored as Portable Text and rendered by a custom parser. Portable Text is just structured JSON, which means I can teach it new kinds of blocks. The one that mattered most for a developer portfolio was code. I added a snippet type, and a snippetGroup for showing a few related files together, so a real excerpt with a filename and a link back to the source can sit inline in the writing instead of being pasted in as plain text.
export const snippet = defineType({
name: 'snippet',
title: 'Code Snippet',
type: 'object',
fields: [
defineField({
name: 'filename',
title: 'Filename',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'source',
title: 'Source URL',
type: 'url',
}),
defineField({
name: 'code',
title: 'Code',
type: 'code',
options: {
language: 'typescript',
languageAlternatives,
},
validation: (Rule) => Rule.required(),
}),
// ... plus an optional annotations array (the code-annotation feature).
],
});Once the writing is data, the same pass can drive more than the page body. The table of contents beside each case study is not maintained by hand. One walk of the Portable Text tree indexes every heading it finds, and every code snippet too, by filename, and both the outline rail and the parser read from that single pass instead of counting the blocks twice. The snippet you just read is listed in the outline of this very page, and nobody put it there.
export function buildOutline(content: BlockContent): {
items: OutlineItem[];
itemFor: (key: string | undefined) => OutlineItem | undefined;
} {
const items: OutlineItem[] = [];
const byKey = new Map<string, OutlineItem>();
let headingCounter = 0;
let codeBlockCounter = 0;
const push = (item: OutlineItem) => {
items.push(item);
byKey.set(item.key, item);
};
content.forEach((block) => {
if (
block._type === 'block' &&
OUTLINE_HEADING_TAGS.includes(block.style ?? '')
) {
const text = plainText(block);
if (!text) return;
// ... derive a unique slug id from text, then:
if (block.style === 'h2') {
push({
type: 'h2',
id,
text,
n: String(++headingCounter).padStart(2, '0'),
key: block._key,
});
} else {
push({ type: 'h3', id, text, key: block._key });
}
return;
}
if (block._type === 'snippet') {
const { filename } = block as { _key: string } & Snippet;
push({
type: 'code',
id: `${CODE_ID_PREFIX}${++codeBlockCounter}`,
text: filename,
key: block._key,
});
return;
}
if (block._type === 'snippetGroup') {
const group = block as { _key: string } & SnippetGroup;
push({
type: 'code',
id: `${CODE_ID_PREFIX}${++codeBlockCounter}`,
text: group.title || group.snippets[0]?.filename || 'Snippets',
key: block._key,
});
}
});
// ... returns { items, itemFor } for the Contents rail and the parser.
}05Highlighting code on the server
Syntax highlighting normally means shipping a highlighter to the browser to color the code after the page loads. I did not want the weight or the flash. So the code gets highlighted during the render with Shiki, which uses the same grammar as VS Code and hands back ready-styled HTML. The component is an async server component. It turns the snippet into highlighted markup before the page is ever sent, so the browser receives finished code and runs no highlighting JavaScript at all. The same pass decides something else too: a snippet that carries authored notes gets upgraded to an interactive listing, which is the next section's story.
const CodeParser = async ({ id, snippet, annotations }: Props) => {
const { filename, source } = snippet;
const { language, code } = snippet.code;
const text = code ?? '';
const lang = language || 'typescript';
if (annotations && annotations.length > 0) {
const { html } = await highlightAnnotated(text, lang, annotations);
return (
<div id={id} className='scroll-m-16'>
<AnnotatedListing html={html} filename={filename} source={source} annotations={annotations} />
</div>
);
}
const html = await highlight(text, lang);
return (
<figure id={id} className='my-10 scroll-m-16 min-w-0'>
{/* ... filename + view-source chrome, then the highlighted markup: */}
<div
data-nosnippet
className='border border-rule'
dangerouslySetInnerHTML={{ __html: html }}
/>
</figure>
);
};06Notes in the margin
The snippets on this page carry numbered notes. On a wide screen they sit in a rail beside the code, like marginalia. On a narrower one, tapping a marked token opens the note under it. Ice-blue notes are decisions, why the code is shaped this way and what I turned down. Grey notes are context, what a line does when that is not obvious. Hover a marked token and its note lights up. Click it and it pins.
Under the hood a note is authored data, not markup. Each one anchors to an exact substring of its snippet's code, and the same server pass that highlights the code resolves those anchors into Shiki decorations, so the interactive marks are already part of the HTML the browser receives. An anchor that no longer matches, say after I re-excerpt a file, gets skipped and reported instead of taking the page down with it. On the client, one hook owns the hover and pin state and drives the code panel and the notes rail together, even across a tabbed group of files, where switching tabs swaps the code under the same shared rail.
export type CodeAnnotation = {
id: string;
kind: AnnotationKind;
/** Exact substring of the snippet code to anchor to. */
match: string;
/** 1-based occurrence of `match`, defaults to 1. */
occurrence?: number;
body: string;
};
// ...
export function resolveAnnotations(
code: string,
annotations: CodeAnnotation[]
): ResolvedAnnotations {
const ranges: DecorationItem[] = [];
const misses: { id: string; match: string }[] = [];
annotations.forEach((a, i) => {
const start = nthIndexOf(code, a.match, a.occurrence ?? 1);
if (start === -1) {
misses.push({ id: a.id, match: a.match });
return;
}
ranges.push({
start,
end: start + a.match.length,
properties: {
class: `annot annot-${a.kind}`,
'data-annot': a.id,
'data-kind': a.kind,
'data-n': String(i + 1),
tabindex: 0,
},
});
});
return { ranges, misses };
}The notes beside that snippet are, of course, rendered by the system they describe. This page cannot help itself.
07Making things move
In v1 I hand-rolled a scroll-animation hook with the Intersection Observer API, and I was proud of it. It taught me how to drive motion straight from browser APIs. In v2 I handed that job to Framer Motion, which got me much richer animation for a lot less code, down to small touches like a box-reveal that dissolved a grid of tiles to uncover an image.
Then the Ledger took the job back. Plain CSS and the Web Animations API covered its smaller rules, masks, and reveals, so framer-motion left the dependency list. Negative kept that motion system and changed the surface around it. I came back to v1's habit of using browser APIs directly, except this time it was a choice rather than all I knew. The transition between pages was the part that fought me.
08Page transitions, and rebuilding them
That one had two rounds. The first version leaned on Framer Motion's AnimatePresence to cross-fade one page into the next, and it does not get along with the App Router out of the box. While a page is animating out, Next has already moved the router on, so the exiting page re-renders against the route it is leaving for and flickers. The fix I used then was to freeze the router context for the length of the exit, capturing it in a ref so the leaving page keeps animating against the context it started with. It worked, after it broke two more things on the way, project filtering that had stopped tracking the URL search params, and a Suspense boundary Next now wanted around the component that read them.
The Ledger redesign replaced the cross-fade with a curtain, and Negative kept the mechanism with new colors. A graphite panel slides up over the page with a thin ice-blue line on its leading edge. Once it covers the viewport, the destination name rises through a mask while a rule draws underneath in the same blue. The panel then lifts to reveal the page loaded behind it.
The rebuild also removed the router fight. The old approach animated the outgoing page while Next changed the route context underneath it. The curtain covers the viewport first, calls router.push while the panel is in place, and reveals after the route changes. That keeps the half-changed route behind the cover.
A four-phase state machine, idle, covering, waiting, and revealing, coordinates the sequence through the Web Animations API. The document-level click handler skips interception when site motion is off, so links use normal browser navigation. A roughly seven-second watchdog forces a reveal if navigation stays in the waiting phase.
Two more doors lead into the same machine. Landing on the site fresh is one. The server renders the page already covered, and the arrival reveal is a set of CSS keyframes baked into that first paint rather than a JavaScript timer, so on a slow connection the curtain still lifts on schedule whether or not the bundle has shown up. Hydration's only job is to watch that animation and take over the cleanup when it ends. Browser back and forward is the other. By the time the site hears about those, the URL has already changed, so the curtain skips the slide and snaps straight to covered, and it leaves the scroll position alone so going back still drops you where you were reading.
type Phase = 'idle' | 'covering' | 'waiting' | 'revealing';
// Server-consistent (no browser APIs): whether the curtain's very first
// render, before any JS has run, should already be in the covering state so
// a fresh page load never flashes the raw page before the arrival reveal.
const startCovered = INITIAL_MOTION_STATE === 'on';
// ...
const commit = (href: string) => {
phaseRef.current = 'waiting';
setRouteTransitionEntering(true);
router.push(href);
startPulse();
watchdog = window.setTimeout(() => {
if (phaseRef.current === 'waiting') reveal();
}, WATCHDOG_MS);
};
const cover = (href: string, dest: Destination, hash: string) => {
phaseRef.current = 'covering';
pendingHash = hash || null;
eyebrow.textContent = dest.eyebrow;
label.textContent = dest.label;
// ... slide the paper panel up over the page:
const panelCover = run(
panel,
[{ transform: 'translateY(100%)' }, { transform: 'translateY(0)' }],
COVER_MS
);
panelCover.finished.then(() => {
if (phaseRef.current !== 'covering') return;
panel.style.transform = 'translateY(0)';
// Wait for the browser to paint the fully-covered frame before committing navigation.
// Use multiple RAFs + a microtask to ensure paint has occurred.
// This is critical: the panel must be visibly covering the viewport before the new route renders.
const waitForPaint = () =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Force a style recalc/paint by reading a layout property
void panel.offsetHeight;
resolve();
});
});
});
waitForPaint().then(() => {
if (phaseRef.current === 'covering') commit(href);
});
});
};09The unglamorous parts that matter
A portfolio also has to be found. The sitemap is a route handler that queries Sanity and writes the XML, and each project emits JSON-LD structured data so it reads as a piece of work and not only a page.
Freshness used to rely on timers. Now publishing in the Studio sends a signed webhook to the site. The handler verifies the signature against the raw request body, maps the document type to the routes it can affect, and calls revalidatePath for that set. A day-long fallback remains on Sanity-backed pages in case a webhook is missed. The Sanity CDN is off for these reads so it cannot keep serving the pre-edit response after the site cache has been revalidated.
const pathsFor = ({ _type, slug }: WebhookPayload): string[] => {
switch (_type) {
case 'project': {
const paths = ['/', '/projects', '/sitemap.xml'];
if (slug) paths.unshift(`/projects/${slug}`);
return paths;
}
case 'aboutMe':
return ['/'];
case 'cv':
return ['/cv'];
// ...
default:
// tool, experience, kind, and anything unrecognized are referenced
// across many pages via references rather than a single route, so
// revalidate broadly instead of trying to trace every consumer.
return ['/', '/projects', '/cv', '/sitemap.xml', PROJECT_DYNAMIC_ROUTE];
}
};
export async function POST(request: NextRequest) {
// ...
// The signature is computed over the exact raw body bytes Sanity sent, so
// it must be read as text (and validated) before any JSON parsing.
const rawBody = await request.text();
const signature = request.headers.get(SIGNATURE_HEADER_NAME);
if (!signature || !(await isValidSignature(rawBody, signature, secret))) {
return NextResponse.json({ message: 'Invalid signature' }, { status: 401 });
}
// ... JSON.parse(rawBody), then revalidatePath() for each affected path.
}The contact story went the opposite direction. There used to be a form here, with an API route behind it and Resend turning submissions into email. The redesign replaced all of it with a mailto link in the footer. That removed the API route and Resend, though it now depends on the visitor having a mail client set up. I can report that deleting an API route is more satisfying than adding one ever was. The whole site stays TypeScript end to end, which is less a flourish than a way to catch my own mistakes before they ship. None of this is visible to a visitor, and that is sort of the point. Nobody thanks you for the plumbing, but the site is worse without it.
10The site became the proof
Eman was right that documentation earns its keep. The old portfolio told you I shipped things. This one tries to show how I think while I ship them. The case study you are reading uses the same Portable Text blocks, server-highlighted code, and authored margin notes it describes, so the page is also the demo. I would rather show the judgment behind the work than hand you a list of it.
Building it on a stack I already knew changed the question from "how do I do this" to "how do I do this well." Motion was where that bit hardest. Enough to feel alive, not so much that it gets in the way. Now, if you will excuse me, I have some new bugs to create.