Fetching latest headlines…
Static Sites With YAML Data in Next.js 15 App Router
NORTH AMERICA
🇺🇸 United StatesMay 11, 2026

Static Sites With YAML Data in Next.js 15 App Router

1 views0 likes0 comments
Originally published byDev.to

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:

  • params is a Promise in Next 15. This tripped me up coming from Next 14. You have to await it before destructuring.
  • generateStaticParams runs at build time. This is what triggers SSG — each returned slug becomes a static HTML file.
  • generateMetadata is 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-remote or 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 dev after 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.

Comments (0)

Sign in to join the discussion

Be the first to comment!