Simon Hylander's portfolio site. A single Next.js app (App Router, statically generated) deployed on Vercel. PRODUCT.md is the content brief, DESIGN.md the design notes.
Requires Node 24 (.nvmrc) and pnpm.
pnpm install
pnpm dev # http://localhost:3000
pnpm build # production build, without drafts
SHOW_DRAFTS=1 pnpm build # production build with drafts, to check them before publishing
pnpm lint # ESLint
pnpm typecheck # route types + tsc
pnpm test # node --test on src/**/*.test.tsCopy .env.example to .env.local to set environment variables locally.
A section on the home page where a visitor asks a question and gets a 2 to 4 sentence answer, in the third person, from the site's own pages, with links to the pages it used.
- Sources.
src/lib/ask-sources.tsbuilds one source per page:/fromprofile.ts,/cvfromcareer.ts, and/work/<slug>from each case study's metadata plus its raw MDX body (metadata export and{/* */}comments stripped). The source id is the page's URL path. The corpus is a few thousand tokens, so all of it goes into the system prompt. There is no retrieval step. - Route.
POST /api/askwith{ "question": "..." }(3 to 300 characters after trimming). It checks BotID, then the rate limit, then streams plain text from Claude Sonnet 5.5 (claude-sonnet-5-5, effortlow, at most 350 output tokens) through the AI SDK. The answer ends withSources: /work/x, /cv, which the client turns into links. It drops any path that is not a real page. - Errors. JSON
{ "error": "..." }with 400 (invalid question), 403 (bot), 429 (rate limited) or 503 (ANTHROPIC_API_KEYis not set). - Prompt. The system prompt limits answers to the sources and treats the question as a question only, so instructions inside it are ignored. It is the same on every request and is marked for prompt caching.
| Name | Required | Effect |
|---|---|---|
ANTHROPIC_API_KEY |
No | Turns the feature on. Without it, the home page section and the ⌘K "Ask about my work" command are not rendered, and the route returns 503. The home page is static, so set the key before the build. |
src/lib/env.ts validates the server environment with zod.
- Rate limit rule. In the project's Firewall, add a custom rule: If
@vercel/firewallRate limit ID equalsask, then Rate Limit, fixed window, 60 seconds, 10 requests, keyed on IP, action Deny.checkRateLimit("ask")in the route reads this rule. Without the rule the check logsRate-limit ID 'ask' not configuredand lets every request through. - BotID.
withBotIdinnext.config.tsandinitBotIdinsrc/instrumentation-client.tsprotectPOST /api/ask, and the route callscheckBotId(). This is BotID Basic, which is free on all plans. Deep Analysis is a paid option in the Firewall settings and needs no code change.
Both checks run only on Vercel (process.env.VERCEL). pnpm dev and pnpm start skip them.
Each question sends about 2,000 input tokens (the system prompt) and receives at most 350 output tokens. At Sonnet 5.5 prices ($2 per million input tokens, $10 per million output, $0.20 per million for cached input) that is at most about $0.008 per question, or about $0.004 when the system prompt is cached (5 minute TTL). With the rate limit above, one IP can spend at most about $2.40 to $4.50 an hour (600 questions). Set a monthly spend limit in the Anthropic Console as the backstop.
All content lives in src/content/. Pages only render it.
profile.ts: name, role, headline and positioning line, contact prompt, socials, site URL.career.ts: jobs, newest first. The job withoutendis the current one.work/: case studies, rendered at/work/[slug].writing/: posts, rendered at/writing/[slug].collection.ts: the metadata schemas for case studies and posts.partners.ts: the systems behind the Treddy partner API, drawn by its partner graph.diagrams.ts: the architecture diagrams that case studies build up with<BuildDiagram>.
Routes, the sitemap, the ⌘K palette (src/lib/commands.ts), the Ask sources and Open Graph images all read from these modules.
Tokens live in src/app/globals.css and are documented in DESIGN.md. src/components/slots.tsx plugs the interactive pieces into the pages:
- Artifacts (
artifacts/): one small interactive piece per case study, registered by slug inartifacts/index.tsx. The home page shows them in the case study deck and its dialog. The Treddy artifact is the partner graph (partner-graph.tsx). - Activity heatmap (
activity-heatmap.tsx): GitHub contributions for the last 12 months, refreshed daily. SetGITHUB_TOKEN(no scopes needed) to use the GraphQL API; without it the public calendar page is parsed. If both fail the section is hidden and the build logs a warning.SHOW_HEATMAPinprofile.tsturns it off.
-
Create
src/content/work/<slug>.mdx. Start with a metadata export, then the sections fromPRODUCT.md(Problem, My role, Constraints, Decisions, Result):export const metadata = { title: "Treddy partner platform", summary: "One sentence.", date: "2026-10-19", // publish date, ISO pillar: "integrations", // "integrations" | "architecture" | "ai-native" tagline: "A partner platform for a fraud-prevention startup", // what was built, one short line company: "Treddy", // shown with the years under the tagline role: "Architect", period: { start: "2019", end: "2021" }, // optional, "YYYY" or "YYYY-MM" stack: ["TypeScript"], // rendered as the Stack section metric: "6 partner integrations", // optional, highlighted on the case study }; ## Problem
-
Register it in
src/content/work/index.ts. The key becomes the slug, and the registration order is the order on the home page:import * as aiCodeReview from "./ai-code-review.mdx"; export const work = defineCollection(caseStudySchema, { treddy, "ai-code-review": aiCodeReview });
Invalid metadata fails the build. Posts work the same way in src/content/writing/ with postSchema (title, summary, date, pillar, optional draft), registered newest first.
Leave a section out until it has content, and keep a {/* TODO(simon): ## Section. ... */} comment where it belongs. An empty heading renders as an empty section.
Set draft: true in a post's or case study's metadata. Drafts render in pnpm dev, and are left out of production builds: no route, no list entry, no sitemap entry. SHOW_DRAFTS=1 includes them in a production build.
Fenced code is highlighted at build time by Shiki through src/lib/rehype-code.mjs. Token colors come from the design tokens in src/app/code.css.
-
title="file.ts"after the language adds a filename caption. -
ts twoslashruns the TypeScript compiler on the block. Hovering or tabbing to an identifier shows its type, and// ^?under a token prints its type as a line. A type error in the snippet fails the build. Snippets can import any installed package, such aseffect.```ts twoslash title="retrieve.ts" import { Effect } from "effect"; const answer = Effect.succeed(42); // ^? ```
Turbopack caches compiled MDX, so after changing rehype-code.mjs, delete .next before building.
<BuildDiagram> draws an SVG diagram beside a list of steps. On wide screens with motion allowed, each step's parts appear as its text scrolls past the middle of the viewport. On small screens, with reduced motion and without JavaScript, the full diagram sits above the steps.
-
Define it in
src/content/diagrams.tswithdefineDiagram. Coordinates are SVG units on a 320-wide canvas. Every node and edge has astep, and edges must name existing node ids. -
Use it in MDX.
BuildDiagramandDiagramStepneed no import. EachDiagramStepis one step, in order, and holds one short paragraph:import { byggdagbokenDiagram } from "../diagrams"; <BuildDiagram diagram={byggdagbokenDiagram}> <DiagramStep> **The monolith.** Web traffic went through CloudFront to the legacy monolith. </DiagramStep> </BuildDiagram>