DocumentationFramework guide

Data pages and collections

Nib can discover any src/pages/**/page.<extension> file, or explicitly named glob patterns such as /src/content/projects.json, validate its data, and prerender one route or many routes. The framework does not need to know what YAML, CSV, TOML, JSON, or your own format means. A page source supplies that small format-specific seam.

One file, one page

Define the data shape and static React component:

// src/team.tsx
import { z, type DataPageProps } from '@briansunter/nib'

export const memberSchema = z.object({
  name: z.string(),
  role: z.string(),
})

export function MemberPage({
  data,
}: DataPageProps<z.infer<typeof memberSchema>>) {
  return <h1>{data.name}, {data.role}</h1>
}

Register a YAML handler in nib.config.ts:

import { defineConfig, definePageSource } from '@briansunter/nib'
import { parse } from 'yaml'
import { MemberPage, memberSchema } from './src/team'

export default defineConfig({
  pageSources: [
    definePageSource({
      extensions: ['yaml', 'yml'],
      schema: memberSchema,
      load: ({ source }) => {
        const data = parse(source)
        return { data, meta: { title: data.name } }
      },
      component: MemberPage,
    }),
  ],
})

Now src/pages/team/page.yaml becomes /team/, exactly as page.md does:

name: Ada
role: Engineer

Omit path to use the containing folder. Every descriptor provides meta with a non-empty title; it may also select a named layout.

One CSV, many pages

Return an array to create several routes from one file:

definePageSource({
  extensions: ['csv'],
  schema: z.object({
    slug: z.string(),
    name: z.string(),
    price: z.coerce.number(),
  }),
  load: ({ source }) =>
    parseCsv(source, { columns: true, skip_empty_lines: true })
      .map((data: { slug: string }) => ({
        path: `/products/${data.slug}`,
        data,
        meta: { title: data.name },
      })),
  component: ProductPage,
})

src/pages/products/page.csv can therefore emit /products/pencil/, /products/notebook/, and any other build-time route. Duplicate routes, unsafe paths, invalid data, and overlapping handlers fail the build with the source filename.

Use match(file) when two handlers share an extension:

definePageSource({
  extensions: ['yaml'],
  match: (file) => file.includes('/src/pages/recipes/'),
  // schema, load, component
})

For a source outside src/pages, add patterns and keep match as the final ownership check. The generated module receives the file contents through the same PageSourceContext:

definePageSource({
  extensions: ['json'],
  patterns: ['/src/content/projects.json'],
  match: (file) => file.endsWith('/src/content/projects.json'),
  load: ({ source }) => JSON.parse(source).map((data) => ({
    path: `/projects/${data.slug}`,
    data,
    meta: { title: data.name },
  })),
  component: ProjectPage,
})

Validators

Zod 4 is included and re-exported as z. A schema can be any object with parse(value), so other parse-compatible validators work too. For complete control, replace schema with a typed validate(value, context) function. Choose one validation adapter per definition.

Validation runs during development module loading and production prerendering. Zod transforms such as z.coerce.number() and z.coerce.date() reach the page component as their transformed types.

Typed collections

Collections load build-time data for indexes, navigation, related-content lists, and other cross-page queries:

// src/content.ts
import { defineCollection, z } from '@briansunter/nib'
import { glob } from '@briansunter/nib/server'
import { parse } from 'yaml'

export const posts = defineCollection({
  loader: glob({
    base: 'src/content/posts',
    pattern: '**/*.yaml',
    load: ({ source }) => parse(source),
  }),
  schema: z.object({
    title: z.string(),
    summary: z.string(),
    published: z.coerce.date(),
  }),
})

Register it once:

import { posts } from './src/content'

export default defineConfig({
  collections: { posts },
})

Use the config type to infer every collection entry:

import type { PageProps } from '@briansunter/nib'
import type config from '../../nib.config'

export default function BlogIndex({
  collections,
}: PageProps<typeof config>) {
  return (
    <ul>
      {collections.posts.map((post) => (
        <li key={post.id}>
          {post.data.title} — {post.data.published.toDateString()}
        </li>
      ))}
    </ul>
  )
}

glob() recursively loads one entry per matching file and derives its id from the relative path. file() handles one file that returns either { id: data } or an array of { id, data }. A collection may instead provide any async loader function and use its root and read() context.

For JSON, prefer the validated server helpers over repeating parsing adapters. jsonFile() loads an array as one collection entry per element, jsonGlob() loads one entry per matched file, and jsonValue() loads one complete JSON value as a single entry:

import { defineCollection, z } from '@briansunter/nib'
import { jsonValue } from '@briansunter/nib/server'

export const travel = defineCollection(jsonValue({
  file: 'src/content/travel.json',
  schema: z.object({
    countries: z.array(z.string()),
    cities: z.array(z.object({ name: z.string() })),
  }),
  id: 'travel',
}))

Omit id to use default. JSON syntax failures include the helper name and project-relative filename; schema failures retain the collection and entry id.

Folder layouts and typed frontmatter

Add src/pages/layout.tsx for the whole site or src/pages/blog/layout.tsx for the blog subtree. Layouts compose from root to leaf, followed by an optional frontmatter-selected layout.

A Markdown schema makes arbitrary YAML frontmatter available to those layouts:

export const articleSchema = z.object({
  title: z.string(),
  tags: z.array(z.string()),
  layout: z.string().optional(),
})

export const markdown = defineMarkdown({ schema: articleSchema })
import { z, type PageLayoutProps } from '@briansunter/nib'
import { articleSchema } from '../../content'

export default function BlogLayout({
  children,
  frontmatter,
}: PageLayoutProps<z.infer<typeof articleSchema>>) {
  return (
    <article>
      <p>{frontmatter?.tags.join(' · ')}</p>
      {children}
    </article>
  )
}

Data-page layouts receive the validated value as data; Markdown layouts receive it as frontmatter. Both also receive the immutable route and typed collections.

One source of truth for pages and lists

When one data file generates both detail pages and an index, derive the collection from the page source instead of parsing the file twice:

import {
  defineCollection,
  definePageSource,
  fromPageSource,
  pageRenderer,
  z,
} from '@briansunter/nib'

export const projects = definePageSource({
  extensions: ['json'],
  patterns: ['/src/content/projects.json'],
  schema: z.object({
    slug: z.string(),
    title: z.string(),
  }),
  load: ({ source }) => JSON.parse(source).map((data) => ({
    path: `/projects/${data.slug}`,
    collectionId: data.slug,
    data,
    meta: { title: data.title },
  })),
  component: pageRenderer('./src/data-pages', 'ProjectPage'),
})

export const projectIndex = defineCollection({
  loader: fromPageSource(projects),
})

collectionId must be a non-empty string and is the stable identity reused by the derived collection. Draft descriptors are excluded from both public routes and fromPageSource() collections, so a feed or index cannot accidentally publish them. pageRenderer() keeps a large shared renderer out of the config module and lets Vite load it in the server graph.

Use fromPages() or fromMarkdownPages() when the list should be derived from the final route set. Use fromCollection(collection, mapper) to grant a feed or search plugin access to one registered collection without exposing the whole content registry.