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.
Top comments (0)