Eight HTML files in a folder#
The /artifacts section of this site is eight HTML files. One folder per artifact, one index.html inside, plus an optional thumbnail.jpg. That's it.
public/artifacts/
data-lineage/index.html
gravity-sandbox/index.html
pipeline-dashboard/index.html
prompt-to-visual/index.html
regex-playground/index.html
space-invaders/index.html
sprint-tracker/index.html
voronoi-terrain/index.html
Combined, they're ~7,900 lines of HTML. Each one has its own palette, its own canvas tricks, its own opinion about how a UI should feel. The Voronoi terrain renderer doesn't know the regex playground exists. The Next.js side doesn't import a single line from any of them.
That's the design. Each artifact is its own world.
The artifact:* meta-tag protocol#
There's no registry file. No JSON manifest. No artifacts.config.ts. The metadata lives in the artifact itself, as <meta> tags inside <head>:
<meta name="artifact:title" content="Voronoi Terrain">
<meta name="artifact:description" content="Interactive Voronoi diagram...">
<meta name="artifact:tags" content="generative, terrain, interactive, canvas">
<meta name="artifact:date" content="2026-03-06">title, description, and date are required. tags is optional (comma-separated). Two more are recognized but rarely used: artifact:featured ("true" to surface it on the home page) and artifact:thumbnail-delay (milliseconds — used by the thumbnail script when an artifact needs a beat to settle before being screenshotted).
Five tags total. That's the entire protocol.
The 93-line discovery script#
src/lib/artifacts.ts is 93 lines of Node fs and a regex:
function parseMeta(html: string, field: string): string | null {
const pattern1 = new RegExp(
`<meta[^>]+name="artifact:${field}"[^>]+content="([^"]*)"[^>]*>`,
"i"
);
const pattern2 = new RegExp(
`<meta[^>]+content="([^"]*)"[^>]+name="artifact:${field}"[^>]*>`,
"i"
);
const match = html.match(pattern1) || html.match(pattern2);
return match ? match[1] : null;
}Two patterns because HTML doesn't care which order attributes come in, and a hand-rolled artifact written six months ago might put content before name. The discovery code accepts both rather than dictate the order of attributes inside files I don't want to police.
The rest of the file walks public/artifacts/, reads each index.html, parses the five tags, and builds an ArtifactMeta[] sorted by date. If a folder has no index.html, it's skipped with a warning. If the required tags are missing, same. The filesystem is the registry; the meta tags are the schema.
Why a flat HTML file beats a framework#
Three concrete things I get from this design:
No shared dependencies. Every artifact is self-contained. One uses pure canvas with zero JS imports. One pulls Google Fonts via CSS @import. Adding a new one means writing one HTML file — it never has to coexist with the other seven, never has to share a build, never has to agree on a React version.
No version drift. Next.js can upgrade to whatever it likes next year. The artifacts won't notice. They predate the framework around them and will outlast it. An index.html from 2026 will still render in 2036; I can't say that about most React components I've shipped.
No build step that can rot. The HTML is the artifact. There's no dist/, no out/, no compile step that might drift from source. Open the file in a browser locally — it's the same thing the visitor sees, byte for byte.
The cost is real: no shared design system, no shared utilities, every artifact reinvents its own buttons. That's the trade. For experiments that are meant to be one-shot worlds, paying that cost is fine.
Embedding artifacts in a sandboxed iframe#
src/app/artifacts/[slug]/page.tsx reads the meta and embeds the artifact as a sandboxed iframe↗:
<iframe
src={artifact.path}
title={artifact.title}
sandbox="allow-scripts allow-same-origin"
/>The security trade-off is acknowledged directly in a comment above that line:
allow-scripts + allow-same-origin together weakens the sandbox for same-origin content. Accepted because all embedded HTML is author-controlled, and artifacts need both script execution and relative resource loading. CSP headers provide additional protection. To fully isolate, serve artifacts from a separate origin.
I keep the comment honest because the trade-off is real. same-origin is what lets an artifact load its own thumbnail or fetch a JSON file relative to itself. Drop it and every artifact has to inline everything or use absolute URLs. For a site where I write all the HTML, the looser sandbox is fine. For a site that accepts user-uploaded HTML, it would not be.
One source of truth, six surfaces#
The same getAllArtifacts() powers six surfaces:
| Surface | File |
|---|---|
| Home page feed | src/app/page.tsx |
| Artifacts index | src/app/artifacts/page.tsx |
| Artifact detail | src/app/artifacts/[slug]/page.tsx |
| OG image generator | src/app/artifacts/[slug]/opengraph-image.tsx |
| Sitemap | src/app/sitemap.ts |
| Admin dashboard | src/app/admin/page.tsx |
Add an HTML file in the right shape and it appears in all six places at the next build. Remove it and it disappears from all six. There is no second source of truth to keep in sync — which is the boring win of single-source designs.
Auto-generating thumbnails with headless Chromium#
Thumbnails are auto-generated by scripts/generate-artifact-thumbnails.mjs. The script launches headless Chromium, opens each index.html over a file:// URL, waits the per-artifact delay (default 2,000ms, configurable via the artifact:thumbnail-delay meta), and writes thumbnail.jpg next to the HTML. Same protocol-driven approach: the artifact tells the script what it needs.
That's why the meta-tag namespace is artifact:* and not just metadata fields — they're not only for discovery; they configure the tools that operate on the artifact.
The blog system uses the same pattern#
The post you're reading right now follows the same shape. The MDX draft has frontmatter:
---
title: "Self-Contained HTML Artifacts: A Portfolio Pattern That Doesn't Rot"
description: "..."
date: "2026-05-10"
tags: ["engineering", "html", "nextjs", "portfolio", "static-site"]
---Same idea as <meta name="artifact:*">: metadata travels with the content. The discovery layer is src/lib/posts.ts — same role as src/lib/artifacts.ts, but querying Supabase instead of walking the filesystem. The downstream surfaces are the same shape:
| Surface | File |
|---|---|
| Blog index | src/app/blog/page.tsx |
| Post detail | src/app/blog/[slug]/page.tsx |
| Tag pages | src/app/blog/tags/[tag]/page.tsx |
| RSS feed | src/app/blog/feed.xml/route.ts |
| OG image generator | src/app/blog/[slug]/opengraph-image.tsx |
| Home page feed | src/app/page.tsx |
| Sitemap | src/app/sitemap.ts |
The honest difference is the storage swap. Posts live in Supabase, not the filesystem, because they need editing through an admin UI and preview links for unpublished drafts. The MDX file is a draft format that the publish script writes into a DB row; once the row is published, it becomes canonical. But the protocol idea — metadata with content, one discovery layer, many surfaces — is identical.
The same shape solving two adjacent problems is usually a sign the pattern is doing real work.
What would break this#
A few rough edges I haven't hit yet but know are there:
- Slug collisions. Two folders with the same name would shadow each other. The filesystem prevents that, but a flat name with subtle differences (
my-thingvsmy_thing) might confuse me before it confuses the discovery code. - Cross-origin needs. An artifact that wants to talk to a third-party API with strict CSP might hit the iframe sandbox. Hasn't happened yet.
- Genuine code reuse. If two artifacts started sharing real logic, the protocol breaks down. The discipline is: if two artifacts share code, one of them is no longer an artifact.
The takeaway#
The protocol doesn't need to be clever. Three required meta tags and a folder convention beat any custom JSON registry I've ever written, and the surface area I don't have is the surface area that doesn't break.
If you're building a portfolio of small interactive things, try this before reaching for a framework. The HTML can come from anywhere — handwritten, generated by an AI, exported from a tool — and the system around it doesn't have to know or care. Each artifact stays its own world, sealed off from the others, and the render layer is just a thin pane of glass on top.
The boring conventions hold up the longest.