← Alle notater

How I built this portfolio

This site replaced my old portfolio, an Express app with MongoDB that also tracked visitors. For the new version I set a few clear goals:

  • No server and no database. Nothing to maintain, patch or attack.
  • Three languages (English, Norwegian, German) without duplicating pages.
  • Content separate from code, so adding a project or a note never means touching components.
  • Privacy first. No cookies and no personal data. The only analytics is GoatCounter, a small open-source counter that counts page views without tracking visitors.

The stack

PackageWhy
Next.js 16 (App Router)File-based routing, React Server Components and static export
React 19Components, with client-side JavaScript only where it is really needed
TypeScriptTypes for content, translations and route parameters
Tailwind CSS 4Utility classes on top of a small set of design tokens
@tailwindcss/typographyReadable styling for Markdown content with one prose class
react-markdownRenders Markdown as React elements, without injecting raw HTML
gray-matterReads the front matter (title, date, tags) of each note
react-iconsTechnology and brand icons as tree-shaken components

Static export

The whole site is built with output: "export". At build time Next.js renders every page to plain HTML, so the result is a folder of static files that can be served from any static host or CDN.

This also works as a safety net: if I use a feature that needs a server, the build fails immediately instead of breaking later in production.

Three languages without a server

Every page lives under a dynamic [locale] segment. The layout tells Next.js which languages exist, and rejects everything else:

export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

export const dynamicParams = false; // any other language returns 404

Next.js combines this with the routes below it. Six projects in three languages simply become 18 pre-rendered detail pages.

The texts live in one JSON dictionary per language. English is the reference, and TypeScript checks that the other languages have exactly the same keys:

const dictionaries = { en, no, de } satisfies Record<Locale, typeof en>;

If I forget a translation, the build fails instead of showing a blank button on the live site.

For content that is not translated yet, such as a project summary, a small helper falls back to English:

export function localize(text: LocalizedText, locale: Locale): string {
  return text[locale] ?? text.en;
}

Content as data, not code

  • Projects and games are typed arrays in TypeScript files. Cards, the featured section on the home page and the detail pages are all generated from the same data.
  • Longer texts (project details, the About page and these notes) are Markdown files in a content folder. They are read from the file system at build time, so the browser only ever receives finished HTML.
  • Notes are discovered automatically: generateStaticParams reads the folder, so a new .md file becomes a new page on the next build.

Publishing a note is a git push. The commit history becomes the history of the content, and editing is protected by GitHub's own login, so there is no admin panel to secure.

Server components first

Every component is a React Server Component unless it needs the browser. Only four small components are marked with "use client":

  • the CV download, which asks a small question before the download starts,
  • the page counter, which reports each page change to GoatCounter, because the standard script only counts the first page load.

Everything else, including the navbar itself, ships as plain HTML with no JavaScript.

The CV check also shows a detail that is easy to get wrong: the random numbers are created when the button is clicked, not when the component renders. Otherwise the HTML generated at build time and the first render in the browser would disagree, and React would report a hydration error.

It is a deterrent for bots and search engines, not real security. A static site cannot keep a secret, so the public CV simply leaves out my address and phone number.

Working within the limits of static hosting

  • Redirecting / to /en normally happens on the server. Here, the root URL is a tiny page in its own route group, (root), with its own minimal layout and a meta refresh tag.
  • Images use next/image with unoptimized: true, because there is no server to resize them on request.
  • Dates are formatted at build time with Intl.DateTimeFormat, so each language gets its own month names and order.

Design system

The look is defined by a handful of CSS variables (background, foreground, muted, border and one accent colour), exposed to Tailwind through @theme inline. Components use names like bg-background and text-accent instead of raw colour codes, so changing one value restyles the whole site, including the glow in the hero section.

What I would add next

  • Screenshots and short videos for every project
  • A local-only writing panel that runs with npm run dev, saves notes as Markdown and never exists on the live site
  • Translated versions of the project write-ups