I recently shipped a directory site and went back to a pattern I keep reaching for: YAML files as the data layer for static Next.js sites. No CMS, no database, no API routes. Just files in a directory, read at build time.
This post walks through the setup. It's not flashy — it's the kind of architecture that gets out of your way so you can focus on content.
When this pattern fits
- You have a known set of entities (products, games, restaurants, neighborhoods, libraries)
- Content updates daily, not by-the-second
- You want zero runtime cost
- You want git to be your version control AND your CMS
If any of those is false, use a database. If all are true, this is the simplest thing that works.
The directory
data/
games/
klondike.yaml
freecell.yaml
spider.yaml
app/
games/
[slug]/
page.tsx
lib/
games.ts
Each .yaml file is one page. Filename becomes URL slug. The folder is the database.
One YAML file per entity
# data/games/klondike.yaml
name: "Klondike"
slug: "klondike"
metaTitle: "Klondike Solitaire — Rules, Strategy & Where to Play"
metaDescription: "Learn Klondike solitaire rules, strategy tips, and where to play online. The classic single-deck patience game."
difficulty: 2
deckCount: 1
tags: ["classic", "single-deck", "easy"]
description: >
Klondike is the most popular solitaire variant…
rules: >
Deal cards face down across seven tableau columns…
YAML over JSON because long-form prose with newlines is readable. Block scalars (>) collapse whitespace into single paragraphs, which is exactly what you want for content fields.
The loader
// lib/games.ts
import fs from "fs";
import path from "path";
import yaml from "js-yaml";
import type { Game } from "./types";
const GAMES_DIR = path.join(process.cwd(), "data", "games");
export function loadGame(slug: string): Game {
const filePath = path.join(GAMES_DIR, `${slug}.yaml`);
const content = fs.readFileSync(filePath, "utf-8");
return yaml.load(content) as Game;
}
export function getAllGameSlugs(): string[] {
return fs
.readdirSync(GAMES_DIR)
.filter((f) => f.endsWith(".yaml"))
.map((f) => f.replace(".yaml", ""));
}
js-yaml is the standard library. No streaming, no async — readFileSync is fine because this only runs at build time.
The Game type is hand-maintained in lib/types.ts. You could generate it from a JSON Schema if you want runtime validation, but for a small project, a TypeScript interface plus careful YAML editing is enough.
The page
// app/games/[slug]/page.tsx
import { loadGame, getAllGameSlugs } from "@/lib/games";
import { notFound } from "next/navigation";
export function generateStaticParams() {
return getAllGameSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const game = loadGame(slug);
return {
title: game.metaTitle,
description: game.metaDescription,
};
}
export default async function GamePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const game = loadGame(slug);
if (!game) notFound();
return (
<article>
<h1>{game.name}</h1>
<section dangerouslySetInnerHTML={{ __html: game.description }} />
<section>
<h2>Rules</h2>
<p>{game.rules}</p>
</section>
</article>
);
}
A few Next.js 15 specifics worth flagging:
-
paramsis aPromisein Next 15. This tripped me up coming from Next 14. You have toawaitit before destructuring. -
generateStaticParamsruns at build time. This is what triggers SSG — each returned slug becomes a static HTML file. -
generateMetadatais called per page. Each entity gets a unique title/description without any extra wiring.
The build output
$ pnpm build
…
Route (app) Size First Load JS
├ ● /games/[slug] 173 B 113 kB
├ ├ /games/klondike
├ ├ /games/freecell
├ ├ /games/spider
├ └ [+24 more paths]
…
Every YAML file becomes a pre-rendered HTML page. No runtime, no cold start, no database query. Vercel serves them from the edge CDN as flat files.
Sitemap and robots
next-sitemap reads your built routes and generates sitemap.xml automatically. Two files of config:
// next-sitemap.config.js
module.exports = {
siteUrl: "https://www.solitaireassociation.com",
generateRobotsTxt: true,
changefreq: "weekly",
};
// package.json
{
"scripts": {
"postbuild": "next-sitemap"
}
}
Done. Every game page is in the sitemap, with a <lastmod> you can wire to a dateModified field in your YAML.
What I'd watch out for
-
Don't put HTML in YAML. Markdown is fine if you render it with
next-mdx-remoteor a similar pipeline. Raw HTML in a content field becomes a security/XSS surface you don't want. - Keep YAML files small. If your entity description is 5,000 words, split it into separate fields or use MDX. YAML parsers slow down on large block scalars.
-
TypeScript types must match reality. YAML is loose — a typo in a field name silently becomes
undefined. Add a runtime check at boot or use a schema library like Zod if this matters to you. -
Hot reload doesn't always pick up YAML changes. In dev, you sometimes need to restart
next devafter editing a YAML file. Not a dealbreaker, but worth knowing.
When to graduate to a CMS
If you start needing:
- Multiple non-technical editors
- Workflow (drafts, approvals, scheduled publishing)
- Image uploads from a UI
- Localization at scale
…then a headless CMS earns its keep. Until then, YAML + git + Next.js is faster, cheaper, and easier to reason about.
What I built with it
The pattern is currently powering solitaireassociation.com, a directory of solitaire variants. Each game (Klondike, FreeCell, Spider, etc.) is one YAML file. The full build runs in under 30 seconds on Vercel.
If you're building something with a known set of entities and want each to get its own pre-rendered page, this is probably the simplest stack that works.
United States
NORTH AMERICA
Related News
What Does "Building in Public" Actually Mean in 2026?
19h ago
The Agentic Headless Backend: What Vibe Coders Still Need After the UI Is Done
19h ago
Why I’m Still Learning to Code Even With AI
21h ago
I gave Claude a persistent memory for $0/month using Cloudflare
1d ago
NYT: 'Meta's Embrace of AI Is Making Its Employees Miserable'
1d ago