A portfolio has a content management problem that is smaller than it looks. Seven projects, updated a few times a year, authored by one person — this is exactly the scale where a headless CMS adds more surface area than it removes. What it needs is a typed source of truth and a static build.
Here is how the project pages work.
One array, many pages
Everything starts with a single file, src/content/projects.ts, exporting an array of Project objects. The home grid renders from it, the detail pages are generated from it, and so are the sitemap entries, the structured data, and the related-projects links. Adding a project is appending one object.
The type is the schema
export type Project = {
slug: string;
title: string;
tagline: string; // card description, reused as the meta description
stack: string[]; // primary tech chips
stackMore?: { label: string; count: number; title: string };
image: { src: string; alt: string; width: number; height: number };
href: string; // the live site
service: "web" | "mobile" | "ai";
};Two details earn their place. The image carries its own dimensions, because next/image needs them to reserve layout space and keep CLS at zero. And the stackMore chip keeps the full technology list in a title attribute instead of inventing a second data shape for it.
Static params, strict 404s
The dynamic route generates every page at build time, and dynamicParams = false turns any other slug into a real 404 rather than a runtime render:
export function generateStaticParams() {
return projects.map((project) => ({ slug: project.slug }));
}
export const dynamicParams = false;Seven pages, generated once, served as static HTML.
Metadata per page, from the same source
generateMetadata reads the same object the page renders, so the title, description and canonical URL cannot drift from the content:
export async function generateMetadata(props: PageProps<"/projects/[slug]">) {
const { slug } = await props.params;
const project = getProject(slug);
if (!project) return {};
return {
title: project.title,
description: project.tagline,
alternates: { canonical: `/projects/${project.slug}` },
openGraph: {
title: `${project.title} — ${site.name}`,
description: project.tagline,
images: [{ url: project.image.src, width: project.image.width, height: project.image.height }],
},
};
}Note the parameter type: PageProps<"/projects/[slug]"> is generated by Next.js from the route literal, so the slug type is checked against the actual route — a typo becomes a compile error.
Structured data without a second source
Search engines get a CreativeWork plus a BreadcrumbList, both built from the same object:
export function projectJsonLd(project: Project) {
return {
"@context": "https://schema.org",
"@type": "CreativeWork",
name: project.title,
description: project.tagline,
image: `${site.url}${project.image.src}`,
url: `${site.url}/projects/${project.slug}`,
creator: { "@type": "Person", name: site.name, url: site.url },
};
}Because it is generated rather than pasted into a template, it is impossible for the schema to describe a project the page no longer shows.
Related projects without a database
The related-projects row wraps around the array: two projects, the one before and the one after, found by index. No tags table, no join, no query — the ordering of the array is the relation graph.
When you would actually want a CMS
At fifty projects with multiple authors, previews, and scheduled publishing, this approach stops being the right one — you would want a database and an admin UI. At seven projects edited from a laptop, the typed array wins on every axis that matters: it type-checks, it diffs, it has no API keys, it cannot be down, and the build fails loudly if a required field is missing.
The general rule: a CMS is infrastructure for other people to edit content. Until there are other people, the repository is the better interface.