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.
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.pngOther 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:
@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:
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:
projectsinsrc/data/projects.ts. The first three appear on the home page, all of them on/projects. Open-source projects showstarsandforks; 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", },slugis the label on the card's banner andgradientits two colours. The filter buttons areprojectFilters; a project shows under a filter when itstagsinclude it. -
Posts:
postsinsrc/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) andskills(name and a level from 0 to 100) insrc/data/resume.ts. Education, certifications, languages and "Outside work" are insrc/app/resume/page.tsx. -
Uses:
usesinsrc/data/uses.ts, one{ title, items: [{ name, note }] }group per section. -
Command palette:
palettePagesinsrc/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#
-
Add a
slugtoPost, movesrc/app/post/page.tsxtosrc/app/post/[slug]/page.tsxand list the posts:tsxexport function generateStaticParams() { return posts.map((post) => ({ slug: post.slug })); } -
Keep each post's text somewhere the page can look it up (a
contentfield, MDX files or a CMS), and linkPostItemto`/post/${post.slug}`. Do the same for projects withProjectCardand/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).
Navigation, header and footer#
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 befored.classList.add('dark')tot!=='light'. - Remove dark mode: delete
<ThemeToggle />fromSiteHeader.tsxand the "Toggle dark mode" item inCommandPalette.tsx, and thed.classList.add('dark')part ofthemeScript.
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).
Writing search#
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." |
| 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:
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
metadatainsrc/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)fromsrc/lib/metadata.tswith its full title ("Projects — Sam Lindqvist"); the case study and the post pass"article"as the Open Graph type. The 404 page isnoindex. metadataBaseisprocess.env.NEXT_PUBLIC_SITE_URL(falling back tosite.urlinsrc/data/site.ts). Set it when you build so the social image URL is absolute:
NEXT_PUBLIC_SITE_URL=https://www.your-name.dev pnpm buildConsider 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 directoryout. - 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
tagsmust contain the filter's name exactly as inprojectFilters(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 addsNEXT_PUBLIC_BASE_PATHto thehrefs inpalettePages, 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
themeScriptin<head>and use the same key everywhere (THEME_KEY); clear an old value withlocalStorage.removeItem("theme"). - Images break under a sub-folder. Build with
DEMO_BASE_PATH, and always wrap public paths inasset(). - 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.