Freeframe

Documentation

Profile

v1.0.2Updated Oct 17, 2026A developer portfolio with résumé, projects, writing and a ⌘K command palette.

Overview#

Profile is a personal website template for engineers and designers, shown as the site of Sam Lindqvist, a fictional senior frontend engineer in Stockholm. It has an about page with a "/now" card, a printable résumé, a filterable project gallery with GitHub-style stats, a project case study, a searchable writing archive, a blog post, a /uses page, a contact page with a validated form and a copy-email button, and a 404 page, with a Ctrl/⌘ K command palette and a light and dark theme. This is the Next.js version (1.0.0): Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site.

Live demo: freeframe.dev/templates/profile

Quick start#

Requirements: Node.js 24 and pnpm (corepack enable pnpm) or npm.

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
pnpm build Static site in out/
pnpm start Serve out/ locally (npx serve out)
pnpm lint ESLint (Next.js core-web-vitals and TypeScript rules)
pnpm typecheck Generate route types (next typegen), then type-check with tsc (TypeScript 7)
pnpm build:demo Build for /demos/nextjs/profile into demo/ (the freeframe.dev demo; you can delete it)

Pages#

Route File Notable sections and features
/ src/app/page.tsx Introduction with portrait and availability badge, "/now" card, highlights in numbers, three project cards, latest four posts, call to action
/resume src/app/resume/page.tsx Experience timeline, skill meters, education, certifications, languages, print / PDF button and print stylesheet
/projects src/app/projects/page.tsx GitHub stats and contribution graph, technology filter, six project cards
/project src/app/project/page.tsx The tinytable case study: facts, an illustrated table, write-up, code sample, results
/writing src/app/writing/page.tsx Instant search, posts grouped by year, RSS call to action
/post src/app/post/page.tsx An example blog post with code, a quote and tags
/uses src/app/uses/page.tsx Hardware and software lists, an editor settings sample
/contact src/app/contact/page.tsx Contact form with validation, copy-email button, availability, links
404 src/app/not-found.tsx "This page wandered off" with a terminal illustration

Like the HTML version, the template has one case study and one blog post: every project card links to /project and every post to /post. See Giving every post its own page to change that.

Project structure#

src/
├── app/
│   ├── layout.tsx          fonts, metadata, theme script, skip link, header, footer, command palette, back-to-top
│   ├── page.tsx            about (home)
│   ├── <route>/page.tsx    one folder per page
│   ├── not-found.tsx       404 page
│   ├── globals.css         Tailwind import, accent colours, component classes, print styles
│   └── icon.svg            favicon
├── components/
│   ├── SiteHeader.tsx      name, navigation, palette button, theme switch, mobile menu (client)
│   ├── SiteFooter.tsx      name, keyboard hint, social links, copyright
│   ├── CommandPalette.tsx  the Ctrl/⌘ K palette (client)
│   ├── ThemeToggle.tsx     light/dark switch (client)
│   ├── Kbd.tsx             the "Ctrl K" / "⌘K" hint (client)
│   ├── ProjectCard.tsx     a project card
│   ├── ProjectFilter.tsx   technology filter and project grid (client)
│   ├── ContributionGraph.tsx   the GitHub-style graph
│   ├── PostItem.tsx, PostTitle.tsx   a post row and its title
│   ├── WritingArchive.tsx  writing search and the posts by year (client)
│   ├── ContactForm.tsx     the contact form (client)
│   ├── CopyEmail.tsx       email address with a copy button (client)
│   ├── PrintButton.tsx     "Download / print PDF" and printing on /resume#print (client)
│   └── RevealObserver.tsx, BackToTop.tsx, Year.tsx
├── data/
│   ├── site.ts             name, role, email, URL, navigation, palette pages
│   ├── projects.ts         projects and the filter's technologies
│   ├── posts.ts            posts and date / search helpers
│   ├── resume.ts           experience and skills
│   └── uses.ts             the /uses lists
├── lib/
│   ├── theme.ts            THEME_KEY, theme labels, setTheme() and the boot script
│   ├── palette.ts          the palette's open state
│   ├── platform.ts         useIsMac() for keyboard hints
│   ├── metadata.ts         pageMetadata(): title, description and Open Graph per page
│   ├── asset.ts            prefixes public files with the base path
│   └── style.ts            cx() for class names
└── fonts/                  Inter and JetBrains Mono (woff2) with their licences
public/img/                 avatar.svg, favicon.svg (header logo), og-image.svg and og-image.png

Other files: next.config.ts (static export and base path), eslint.config.mjs, postcss.config.mjs, tsconfig.json, template.json (gallery metadata), pnpm-workspace.yaml (marks the folder as a standalone project).

Customising#

Brand and colours#

The site uses Tailwind's zinc greys with one accent colour, a teal scale defined in @theme in src/app/globals.css. Change the scale to re-colour links, badges, the timeline, skill meters, the contribution graph and focus rings:

Token Value Used for
--color-accent-50, -100 #f0fdfa, #ccfbf1 Badge and success backgrounds, selection
--color-accent-300, -400 #5eead4, #2dd4bf Accent text and links in dark mode
--color-accent-500, -600 #14b8a6, #0d9488 Underlines, dots, meters, focus rings
--color-accent-700, -800, -900 #0f766e, #115e59, #134e4a Accent text in light mode, hover colours

For example, an indigo accent:

css
@theme {
  --color-accent-50: #eef2ff;
  --color-accent-100: #e0e7ff;
  --color-accent-300: #a5b4fc;
  --color-accent-400: #818cf8;
  --color-accent-500: #6366f1;
  --color-accent-600: #4f46e5;
  --color-accent-700: #4338ca;
  --color-accent-800: #3730a3;
  --color-accent-900: #312e81;
}

The reusable classes (.btn, .card, .chip, .field, .nav-link, .eyebrow, .muted, .prose-body...) are in the @layer components block of globals.css. The call to action on the home page has its own glow (rgb(45_212_191/.25)), and each project banner has its own gradient (see Content). The theme-color values are themeColor in src/data/site.ts.

Typography#

The site uses Inter (400 to 700) for text and JetBrains Mono (400 and 500) for dates, labels and code. They're self-hosted from src/fonts/ with next/font/local in src/app/layout.tsx (the same Latin files Google Fonts serves, declared at the same weights, so text wraps exactly like the HTML version) and exposed as --font-inter and --font-jetbrains-mono, which --font-sans and --font-mono use in globals.css.

To swap one, put another woff2 in src/fonts/ and point localFont at it, or use next/font/google:

tsx
import { Geist } from "next/font/google";

const sans = Geist({ subsets: ["latin"], variable: "--font-inter", display: "swap" });

Content#

Your name, role, location, email and site address are site in src/data/site.ts; the page texts (introduction, "/now", highlights, the case study, the post, contact details) are written in the page files. Lists live in typed data files:

  • Projects: projects in src/data/projects.ts. The first three appear on the home page, all of them on /projects. Open-source projects show stars and forks; leave them out for client work.

    ts
    {
      name: "fetch-cache",
      slug: "fetch-cache",
      gradient: ["#1e3a8a", "#60a5fa"],
      description: "A tiny stale-while-revalidate cache for fetch().",
      tags: ["TypeScript", "Performance"],
      language: { name: "TypeScript", color: "#3178c6" },
      stars: "240",
      forks: "12",
    },

    slug is the label on the card's banner and gradient its two colours. The filter buttons are projectFilters; a project shows under a filter when its tags include it.

  • Posts: posts in src/data/posts.ts, newest first. Wrap code in backticks in a title ("The quiet power of the `<dialog>` element"); it's shown as <code> and searched as plain text. The home page shows the latest four; the archive groups them by year, and the writing page counts them.

    ts
    { title: "Why I still write CSS by hand", date: "2026-10-01", topic: "CSS", minutes: 5 },
  • Résumé: experience (role, company, dates, summary, highlights) and skills (name and a level from 0 to 100) in src/data/resume.ts. Education, certifications, languages and "Outside work" are in src/app/resume/page.tsx.

  • Uses: uses in src/data/uses.ts, one { title, items: [{ name, note }] } group per section.

  • Command palette: palettePages in src/data/site.ts (see Command palette).

Rolling dates. "Full-time roles from January 2027" on the contact page uses <MonthsAhead> (src/components/MonthsAhead.tsx) shows the month n months from now and its year: the static build shows its fallback and the browser the rolling date, so the line never goes stale. For a fixed date, write it as plain text instead.

Giving every post its own page#

  1. Add a slug to Post, move src/app/post/page.tsx to src/app/post/[slug]/page.tsx and list the posts:

    tsx
    export function generateStaticParams() {
      return posts.map((post) => ({ slug: post.slug }));
    }
  2. Keep each post's text somewhere the page can look it up (a content field, MDX files or a CMS), and link PostItem to `/post/${post.slug}`. Do the same for projects with ProjectCard and /project.

Images and artwork#

The portrait is public/img/avatar.svg (square, shown at 176–224px on the home page and 32px on the post); replace it with a square photo of at least 480×480 and keep a real alt text. Project banners are CSS gradients with a subtle grid, so there's nothing to replace; to use screenshots instead, put an <img> in the banner of ProjectCard and keep its 9rem height. The case study's table illustration is HTML in src/app/project/page.tsx. The social image is public/img/og-image.png (1200×630), exported from og-image.svg; the favicon is src/app/icon.svg (Next.js links it automatically) and the same mark is the header logo (public/img/favicon.svg).

The menu is mainNav in src/data/site.ts; SiteHeader renders it for desktop and mobile and marks the current page with aria-current="page" (on its own route and on the routes in also, so the case study marks Projects and the post marks Writing). To add a page, create src/app/<route>/page.tsx, add it to mainNav and, if you like, to palettePages. The footer (src/components/SiteFooter.tsx) has the social links in SOCIAL; replace the placeholder addresses with your profiles. The email icon links to /contact.

Dark mode#

The theme is the dark class on <html>; Tailwind's dark: variant follows it (@custom-variant dark in globals.css). A small script (themeScript in src/lib/theme.ts, inlined in <head> by the layout) applies it before the page paints, so there's no flash: a stored "dark" or "light" wins, and anything else (no choice yet, or an unexpected value) follows the system setting. It also gives the theme button its pressed state and label ("Switch to light mode" / "Switch to dark mode") as soon as the page is parsed. Until the visitor picks a theme, the site also follows the system setting when it changes. ThemeToggle and the palette's "Toggle dark mode" switch the class and save the choice.

  • Start in dark mode regardless of the system: in themeScript, change the condition before d.classList.add('dark') to t!=='light'.
  • Remove dark mode: delete <ThemeToggle /> from SiteHeader.tsx and the "Toggle dark mode" item in CommandPalette.tsx, and the d.classList.add('dark') part of themeScript.

Features#

Command palette#

Press Ctrl K (⌘ K on a Mac) anywhere, or use the search button in the header, to open CommandPalette: a native <dialog> with a filter field and a list of pages (palettePages in src/data/site.ts) and two actions, "Toggle dark mode" and "Print résumé". Typing filters by label and hint; ↑/↓ move, Enter or a click runs the item, and Esc, Ctrl/⌘ K or a click on the backdrop close it. Pages open with a full page load, like a link. "Print résumé" prints straight away on the résumé page and otherwise opens /resume#print, which prints once the page has loaded. The hints in the header, footer, post and 404 page say "⌘K" on Apple devices and "Ctrl K" elsewhere (Kbd, useIsMac()).

Add an action by adding an item with an action to ITEMS in CommandPalette.tsx. Accessibility: the field is a combobox with aria-activedescendant pointing at the selected option; the dialog keeps focus inside and returns it when it closes; opening the palette closes the mobile menu.

Project filter#

ProjectFilter (client) on /projects: "All" and one button per technology in projectFilters, as toggle buttons (aria-pressed). The grid shows the projects whose tags include the chosen technology, and the status line ("Showing 3 projects tagged CSS") is a polite live region.

Contribution graph#

ContributionGraph draws 52 weeks of days in five levels (.cg-0 to .cg-4 in globals.css), from a seeded generator, so it's the same on every build and decorative (aria-hidden). The numbers above it are written in src/app/projects/page.tsx. To show real activity, replace levels() with your data (one level from 0 to 4 per day, oldest first, weeks of seven days).

WritingArchive (client) on /writing: typing in the search field filters the posts by title and topic (case insensitive, plain text, see searchText() in src/data/posts.ts), hides years with no matches and shows "No posts match that search." when nothing matches. Clearing the field shows everything again.

Résumé printing#

The "Download / print PDF" button (PrintButton) opens the browser's print dialog; choose "Save as PDF" for a PDF. The @media print block in globals.css hides the header, footer, palette, buttons and anything with the no-print class, removes card borders and padding, and keeps entries with print-break-avoid on one page. Utilities with the print: prefix (on /resume) adjust spacing for paper. Opening /resume#print prints on load.

Contact form and copy email#

See Forms for the form. "Email me directly" (CopyEmail) shows the address with a copy button: it copies site.email and says "Copied to clipboard." in a polite live region; if the clipboard is blocked, it selects the address and asks the visitor to press Ctrl+C (⌘C on a Mac).

Mobile menu, scroll reveals and focusable code#

Below 768px the menu button shows the links under the header bar (aria-expanded, label "Open menu"/"Close menu", icons swap); Escape closes it and returns focus to the button, and it closes after following a link, when the palette opens or when the window gets wider. Elements with the reveal class fade in as they scroll into view (RevealObserver), and everything above them too; they're only hidden while JavaScript runs, and everything shows at once with reduced motion. Code samples (<pre tabIndex={0}> on the case study, the post and /uses) can be focused and scrolled with the keyboard.

Back to top#

BackToTop appears after you scroll about one screen, scrolls back to the top (instantly with reduced motion) and moves focus to <main>. Remove it by deleting <BackToTop /> in src/app/layout.tsx and the .back-to-top styles in globals.css.

Forms#

The contact form (ContactForm, on /contact) checks:

Field Rule Message
Name Not empty "Please tell me your name."
Email Not empty, looks like an address "Please enter your email address." / "That email address doesn’t look right."
Topic Chosen "Please choose a topic."
Message At least 20 characters "Please write a message." / "A little more detail, please (at least 20 characters)."

A field is checked when it loses focus (once it has a value) and again as you type while it's invalid; each has an error line (<field>-error) linked with aria-describedby, and aria-invalid once checked. Submitting checks every field and focuses the first problem. The message field counts characters. When everything is valid, the form is replaced by a thank-you panel, which gets focus; "Write another" brings back an empty form. The rules and their messages are RULES in src/components/ContactForm.tsx, and the topics TOPICS.

Nothing is sent. To receive messages with a form service such as Formspree, send the form data in onSubmit before showing the thank-you panel:

tsx
async function onSubmit(e: FormEvent<HTMLFormElement>) {
  e.preventDefault();
  // … validation as before …
  const res = await fetch("https://formspree.io/f/your-form-id", {
    method: "POST",
    body: new FormData(e.currentTarget),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return; // show an error message here
  flushSync(() => setSent(true));
  success.current?.focus();
}

Read the form element before the first await (const formEl = e.currentTarget), because React clears currentTarget after the event. Update the thank-you text, which says the form is a demo. If you remove the static export, you can post to a Route Handler (e.g. src/app/api/contact/route.ts) or call a Server Action instead.

Saved data (localStorage)#

Key Value Read by
theme (THEME_KEY) "dark" or "light" themeScript (before paint) and ThemeToggle

Only the exact values "dark" and "light" are used; anything else is ignored and the system setting applies (and is followed when it changes). The value is written when the visitor uses the theme switch or the palette's "Toggle dark mode". To reset it, run localStorage.removeItem("theme") in the browser console or clear the site's data. If storage is unavailable, the theme follows the system. If another site on the same domain also uses the key theme, give this one its own name in THEME_KEY.

SEO and sharing#

  • Defaults are in metadata in src/app/layout.tsx: the home page's title and description, author, the Open Graph image and a large Twitter card.
  • Each page exports metadata = pageMetadata(title, description) from src/lib/metadata.ts with its full title ("Projects — Sam Lindqvist"); the case study and the post pass "article" as the Open Graph type. The 404 page is noindex.
  • metadataBase is process.env.NEXT_PUBLIC_SITE_URL (falling back to site.url in src/data/site.ts). Set it when you build so the social image URL is absolute:
sh
NEXT_PUBLIC_SITE_URL=https://www.your-name.dev pnpm build

Consider adding src/app/sitemap.ts and src/app/robots.ts (both work with static export), an RSS feed for the "RSS feed" link (a Route Handler such as src/app/rss.xml/route.ts with export const dynamic = "force-static", built from posts), and Person structured data (JSON-LD) on the home page.

Accessibility#

Built in: a skip link to <main>, landmarks (banner, main and mobile navigation, main, contentinfo), one h1 per page, visible focus rings, aria-current in the navigation, a theme switch with aria-pressed and a label that says what it will do, an accessible mobile menu, a command palette built on the native <dialog>, filter toggle buttons and a live status line, skill meters with role="meter" and values, labelled lists, form errors linked with aria-describedby and focus moved to the first error or to the thank-you panel, a live region for the copy status, focusable code samples, decorative icons and illustrations hidden from screen readers, and reduced motion respected for reveals, animations and scrolling. Colours meet WCAG AA in both themes.

When editing, keep one h1 per page, label every form field, give the portrait and any photos real alt text, and check contrast in both themes when you change the accent.

Building and deploying#

pnpm build writes a static site to out/ (output: "export" in next.config.ts):

  • Vercel: import the repository; no settings needed.
  • Netlify / Cloudflare Pages: build command pnpm build, publish directory out.
  • GitHub Pages or any static host: upload the contents of out/.

To serve from a sub-folder, build with DEMO_BASE_PATH=/me pnpm build; next.config.ts passes it to basePath and NEXT_PUBLIC_BASE_PATH, which asset() and the command palette use. pnpm build:demo does this for the freeframe.dev demo (demo/, which you can delete).

To use server features (Route Handlers, Server Actions), remove output: "export" and images.unoptimized from next.config.ts and deploy to a Node-capable host such as Vercel.

TypeScript setup#

package.json installs @typescript/native (TypeScript 7, which provides tsc) and typescript as an alias of @typescript/typescript6, because typescript-eslint and parts of the Next.js toolchain still need the TypeScript 6 API. When typescript-eslint supports TypeScript 7, replace both with typescript@7. eslint.config.mjs pins settings.react.version, because eslint-plugin-react can't detect it under ESLint 10.

Troubleshooting#

  • A new project doesn't show under a filter. Its tags must contain the filter's name exactly as in projectFilters (case matters).
  • A post title shows backticks. Use one pair per code part; an odd number of backticks leaves one unpaired.
  • The palette opens the wrong address under a sub-folder. Build with DEMO_BASE_PATH; the palette adds NEXT_PUBLIC_BASE_PATH to the hrefs in palettePages, which must start with /.
  • "Print résumé" prints a page with the header. Print from /resume; the print styles hide the site chrome (header[role="banner"], footer[role="contentinfo"], .no-print), so keep those roles and classes.
  • The theme flashes or ignores the switch. Keep themeScript in <head> and use the same key everywhere (THEME_KEY); clear an old value with localStorage.removeItem("theme").
  • Images break under a sub-folder. Build with DEMO_BASE_PATH, and always wrap public paths in asset().
  • The form says it worked but nothing arrived. It's a demo until connected (see Forms).

License#

MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this template, including the portrait and icons. The fonts are licensed under the SIL Open Font License (see src/fonts/). Sam Lindqvist, the companies and the projects are fictional.

Search documentation

Search the guides and every template's documentation