Anhur
What it is
Anhur turns files in your repo (Markdown, MDX, YAML, JSON) into typed data your app can import, like allPosts and getPost("hello").
At build time it:
- Reads content from folders you configure
- Validates each document with Zod schemas you write
- Optionally compiles MDX/Markdown, copies images, resolves relations
- Writes modules under
.anhur/generated - Runs optional integrations (e.g. Orama search index)
- Lets the app import generated modules as
anhur/generated
Vite plugs this into vite dev / vite build via @anhur/vite. The anhur CLI does the same without Vite. Optional folder-based i18n (content/posts/en/…, de/…). Local content as typed code — not a hosted CMS.
More framing: references/why.md
Why you need it
Without something like Anhur, teams usually:
- Hand-parse Markdown in routes (no shared schema, easy to drift)
- Keep content in a CMS (network, auth, preview complexity) when files in git would do
- Copy-paste front matter shapes across pages with no compile-time checks
- Bolt on i18n later as a second system
Anhur is for when content lives in the repo, authors edit files, and the app should treat that content as typed, validated modules — lists, getters, relations, drafts, assets, search — with one config file.
What it is for
| Use | Example |
|---|---|
| Blogs / docs / changelogs | MDX posts, Markdown pages, YAML authors |
| Marketing / product sites | Localized pages + site settings singleton |
| Catalogs / structured data | JSON products with unique SKUs and file attachments |
| Directories / SEO facets | defineView / defineIndex / defineGroup at build |
| Multi-locale sites | Same collection under en/ / de/ folders |
| In-app full-text search | Orama index over one or many collections |
| CDN-delivered assets | Build-time upload via assets({ storage }) + files-sdk |
Not for (today): remote CMS as the source of truth, Next-only adapters, browser/signed media uploads (runtime media library), non-Zod schema libraries, vector / AI search.
When to use this skill
- Explain Anhur to a user or choose it vs CMS / hand loaders
- Greenfield or migrate a site/app onto Anhur content
- Scaffold or enforce the modular
cms/tree (collections, singletons, enums, objects, views, content) - Add/change collections, singletons, views/indexes/groups, processors, schemas, or integrations
- Wire folder i18n / mix localized and monolingual sources
- Load or list localized content the Anhur way (
getX({ locale, … }),_meta.locale) — not custom filters - Wire Orama search (
orama({…})inintegrations,createSearcher) - Wire Vite (
anhur/generatedalias + asset serving) or CI (anhur build) - Wire remote asset storage (
assets({ storage }), files-sdk, CDNbase, prune) - Debug processor/schema mismatches, assets, drafts, localization, views, or search index output
Package map
| Package | Install when | Provides |
|---|---|---|
@anhur/core |
Always | defineConfig, collections/singletons, defineView/defineIndex/defineGroup, schema as s, CLI anhur, build/watch, integrations runtime |
@anhur/vite |
Vite apps | Plugin: Vite-watcher rebuilds, build logs, anhur/generated alias, serve .anhur/assets |
@anhur/mdx |
MDX bodies | mdx() processor, schema as m → m.mdx(), MDXContent from @anhur/mdx/react |
@anhur/markdown |
Markdown→HTML | markdown() processor, schema as md → md.markdown() |
@anhur/assets |
Images/files | assets() processor, a.image() / a.file(), optional CDN sync via storage + files-sdk |
@anhur/orama |
Full-text search | orama() integration + createSearcher (browser/server) |
Rule: every schema helper from an opt-in package needs its processor in defineConfig({ processors }). Missing processor → build fails.
Integrations (search, custom post-codegen work) go in defineConfig({ integrations }), not processors. See references/search.md.
Details: references/packages.md
Integration checklist
Copy and track:
- [ ] Install packages (core + vite and/or opt-ins)
- [ ] Add thin anhur.config.ts (processors / localization / content / views / integrations only)
- [ ] Scaffold cms/: collections/, singletons/, enums/, objects/, views/, content/, content.ts
- [ ] Register processors matching schema helpers
- [ ] One module per collection / singleton; shared enums/objects extracted
- [ ] Author files under cms/content/ (locale folders when using folder i18n)
- [ ] Export `content` tuple from cms/content.ts; wire views via createDerivedHelpers
- [ ] Optional: views: [defineView / defineIndex / defineGroup]
- [ ] Optional: integrations: [orama({…})]
- [ ] Optional: assets({ storage }) for CDN upload (files-sdk peers, enabled in CI/prod only)
- [ ] Vite: plugins: [anhur()] + tsconfig paths for anhur/generated
- [ ] Include .anhur/generated in tsconfig; gitignore .anhur/cache (optional commit generated)
- [ ] Smoke: anhur build OR vite dev; import from anhur/generated (and search index if used)
Project layout (required): references/project-structure.md
Full Vite/tsconfig/CLI steps: references/integration.md
Minimal Vite app
bun add @anhur/core @anhur/vite
# optional:
bun add @anhur/mdx @anhur/markdown @anhur/assets @anhur/orama
vite.config.ts:
import { defineConfig } from "vite";
import anhur from "@anhur/vite";
export default defineConfig({
plugins: [anhur() /* { configPath: "anhur.config.ts" } */],
});
tsconfig.json paths (Vite plugin aliases at runtime; TS still needs this):
{
"include": ["**/*.ts", "**/*.tsx", ".anhur/generated"],
"compilerOptions": {
"paths": {
"anhur/generated": ["./.anhur/generated"]
}
}
}
Search (@anhur/orama)
Put orama({…}) in integrations. Plain defineConfig({…}) is enough — no
defineConfig<typeof content>. defineConfig infers the inline content array
and types index / store (do not pass content into orama).
import { defineConfig } from "@anhur/core";
import { orama } from "@anhur/orama";
export default defineConfig({
content: [posts, pages],
integrations: [
orama({
collections: {
posts: {
schema: { title: "string", summary: "string" },
index: (doc) => ({
title: doc.title,
summary: doc.summary ?? "",
}),
store: (doc) => ({
title: doc.title,
slug: doc.slug,
href: `/posts/${doc.slug}`,
}),
},
},
}),
],
});
- Rejects unknown collection keys and singleton names at typecheck
- Writes
.anhur/generated/search/orama.json(override withdirectory/filename) - App:
import { createSearcher } from "@anhur/orama/client"then load that JSON (browser or server)
Full options, client usage, custom integrations: references/search.md
Project structure (required)
Do not put collections, singletons, enums, objects, or views inline in anhur.config.ts. Use a modular tree (default root cms/):
anhur.config.ts # thin wire-up only
cms/
content.ts # `export const content = […] as const`
collections/ # one defineCollection per file
singletons/ # one defineSingleton per file
enums/ # shared s.enum(…)
objects/ # shared s.object(…) fragments
views/
helpers.ts # createDerivedHelpers(content)
*.ts # one defineView / defineIndex / defineGroup each
content/ # author MD/MDX/YAML/JSON + assets only
posts/en/….mdx # folder i18n when localization is enabled
authors/….yml # localized: false → no locale segment
Full rules, localization layouts, checklists, anti-patterns: references/project-structure.md
Config shape
Thin config + modular modules (abbreviated):
// cms/enums/product-status.ts
import { schema as s } from "@anhur/core";
export const productStatus = s.enum(["active", "discontinued"]);
// cms/collections/posts.ts
import { defineCollection, schema as s } from "@anhur/core";
import { schema as a } from "@anhur/assets";
import { schema as m } from "@anhur/mdx";
export const posts = defineCollection({
name: "posts",
directory: "cms/content/posts",
include: "**/*.{md,mdx}",
generate: { emitIds: true, emitSlugs: true },
schema: s.object({
title: s.string(),
slug: s.slug(),
featured: s.boolean().optional(),
draft: s.boolean().optional(),
cover: a.image().optional(),
body: m.mdx(),
}),
transform: (doc, ctx) => {
if (doc.draft === true) return ctx.skip("draft");
return doc;
},
});
// cms/collections/products.ts — monolingual catalog
import { productStatus } from "../enums/product-status";
export const products = defineCollection({
name: "products",
directory: "cms/content/products",
include: "**/*.json",
localized: false,
generate: { split: "list-only", listOmit: [] },
schema: s.object({
name: s.string(),
sku: s.unique(),
category: s.string(),
status: productStatus.optional(),
price: s.string(),
}),
});
// cms/content.ts
export const content = [posts, products] as const;
// cms/views/helpers.ts
import { createDerivedHelpers } from "@anhur/core";
import { content } from "../content";
export const { defineView, defineGroup, defineIndex } = createDerivedHelpers(content);
// cms/views/featured-posts.ts — import defineView from ./helpers, not @anhur/core
import { posts } from "../collections/posts";
import { defineView } from "./helpers";
export const featuredPosts = defineView({
name: "featuredPosts",
from: posts,
where: (doc): doc is typeof doc & { featured: true } => doc.featured === true,
generate: { limit: 12 },
});
// anhur.config.ts
import { assets } from "@anhur/assets";
import { defineConfig } from "@anhur/core";
import { mdx } from "@anhur/mdx";
import { orama } from "@anhur/orama";
import { content } from "./cms/content";
import { featuredPosts } from "./cms/views/featured-posts";
import { productBySku } from "./cms/views/product-by-sku";
import { productsByCategory } from "./cms/views/products-by-category";
export default defineConfig({
localization: {
strategy: "folder",
locales: ["en", "de"],
defaultLocale: "en",
},
processors: [mdx({ gfm: true }), assets({ dir: ".anhur/assets", base: "/anhur-assets/" })],
content,
views: [featuredPosts, productBySku, productsByCategory],
integrations: [
orama({
collections: {
posts: {
schema: { title: "string" },
index: (doc) => ({ title: doc.title }),
store: (doc) => ({ slug: doc.slug }),
},
},
}),
],
});
Schema helpers, generate splits, hooks: references/schemas.md
Views / indexes / groups: references/views.md
CDN / object storage for assets: references/assets-storage.md
Content layout
Author files live under cms/content/ (or the chosen root’s content/).
Localized (default when localization is set): {directory}/{locale}/…
Example: cms/content/posts/en/hello.mdx, cms/content/posts/de/hello.mdx.
Monolingual: set localized: false (files directly under directory, or singleton filePath).
Mix localized and monolingual sources in one project. defaultLocale must be listed in locales.
Built-in loaders: front-matter MD/MDX, YAML, JSON. Extra loaders via loaders (matched before builtins).
Localization — consuming data
Folder i18n is already baked into generated exports. Use those APIs — do not hand-filter with a custom lang field, re-glob cms/content/, or invent a second content i18n layer.
When localization is set, import the catalog from generated output:
import { type Locale, locales, defaultLocale } from "anhur/generated";
// Locale = "de" | "en" • locales / defaultLocale are runtime consts
| Need | Do this |
|---|---|
| One document in a locale | await getPost({ locale, slug }) — locale: Locale required |
| List for a locale | allPosts.filter((p) => p._meta.locale === locale) |
| Default-locale singleton | import settings (primary export) |
| Singleton in any locale | await getSettings({ locale }) — locale required |
| Monolingual collection | getAuthor("jane") / allAuthors — no locale in the typed API |
| Shared app locale type | import type { Locale } from "anhur/generated" |
Rules that bite:
_meta.localeis typed asLocaleon localized docs — not a schema field you invent.allPostsincludes every locale. Always scope lists with_meta.locale(or a viewwherethat checks it).- Localized getters require
{ locale: Locale, … }in.d.ts. BaregetPost("slug")is runtime-only / discouraged. defineIndex({ key: "slug" })fails if the same slug exists inenandde. Use the getter, or a composite key`${doc._meta.locale}:${doc.slug}`.- References resolve same locale first — no custom join for “German author of German post.”
import { type Locale, allPosts, getPost, getSettings, settings } from "anhur/generated";
const locale: Locale = params.locale;
const post = await getPost({ locale, slug: params.slug });
if (!post) throw notFound();
const posts = allPosts.filter((p) => p._meta.locale === locale);
settings; // defaultLocale only
const localizedSettings = await getSettings({ locale });
Full patterns, indexes/views, singletons, anti-patterns: references/localization.md
Generated API (default light split)
When localization is set:
Locale—"en" \| "de"(sorted union from config)locales/defaultLocale— runtime consts (+.d.ts)
For collection posts:
allPosts— light list of all locales (default omitsbodywhen that field exists);_meta.locale: LocalegetPost({ locale, id?, slug? })— full document;localerequired when the source is localized- Optional:
PostId,PostSlugwhengenerate.emitIds/emitSlugs
For localized singleton settings:
settings—defaultLocaledocumentsettingsAll(defaultvariantsName; overridable) — all locale variants whenemitAllgetSettings({ locale })—locale: Localerequired
From views (list-only — no getters):
| Helper | Example export |
|---|---|
defineView |
allFeaturedPosts |
defineIndex |
productBySku + ProductBySkuKey literal union |
defineGroup |
productsByCategory → { key, count, items }[] |
import {
type Locale,
allPosts,
getPost,
productBySku,
productsByCategory,
settings,
} from "anhur/generated";
const locale = "en" satisfies Locale;
const post = await getPost({ locale, slug: "hello" });
if (!post) throw notFound(); // or your router’s missing-page helper
const enPosts = allPosts.filter((p) => p._meta.locale === locale);
productBySku["W-100"]?.price;
productsByCategory.find((g) => g.key === "widgets")?.items;
Import id is always anhur/generated (not a relative path).
Monolingual collections (localized: false) keep string id/slug getters (no locale in the typed query).
CLI (no Vite)
bunx anhur build --root .
bunx anhur watch --root .
# --config path/to/anhur.config.ts
React MDX
import { MDXContent } from "@anhur/mdx/react";
export function PostBody({ code }: { code: string }) {
return <MDXContent code={code} />;
}
code is the compiled string from m.mdx() on the full document (getPost).
Hard rules
- Modular layout: collections, singletons, enums, objects, and views live under
cms/(or the project’s documented equivalent).anhur.config.tsonly wiresprocessors, optionallocalization,content,views, andintegrations. See references/project-structure.md. - One concern per file: one
defineCollection/defineSingleton/ view / enum / object module. Shared closed sets →cms/enums/; reusable objects →cms/objects/. Author files only undercms/content/. - Export a single
contenttuple fromcms/content.ts(as const). Bind views withcreateDerivedHelpers(content)incms/views/helpers.tsand importdefineView/defineIndex/defineGroupfrom there — not from@anhur/core. - Processor + schema field must match (
m.mdx↔mdx(),a.image↔assets(), …). - Relative body images/links need
assets(); without it they fail the build. - Prefer
m.mdx()/md.markdown()/s.raw()for bodies — not ad-hoc compile intransform. - Drafts:
draft: trueorctx.skip(reason)intransform. - Search / post-codegen packages use
integrations: [orama({…})], notprocessorsorcomplete: orama(…). - Use plain
defineConfig({ content, integrations: [orama({…})] })— nodefineConfig<typeof content>, and never passcontentintoorama. - Put
defineView/defineIndex/defineGroupinviews, never in thecontenttuple. Multi-collection views requireselect. Index keys must be unique or the build fails. - Prefer
defineIndexover filtering a hugelist-onlycollection for detail routes. Prefergenerate.compareover stringlistSortfor numeric fields stored as strings. - Localization: folder strategy under
{directory}/{locale}/…whenlocalizationis set; uselocalized: falseper source for monolingual trees. Consume with generatedLocale/getX({ locale, … })/_meta.locale— never customlangfields, filesystem re-parse, or bare-slug indexes across locales. See references/localization.md. - Do not invent a Next adapter, Valibot schemas, or Orama vector/AI search — out of scope. CDN/object upload is supported via
assets({ storage })+ files-sdk (gate withenabled, requireprefix, prefer afilesfactory; empty-emit skips prune unlesspruneEmpty: true). - Package scope
@anhurmay rename before/after publish; keep config names (anhur.config.ts,.anhur/,anhur/generated) unless the project documents a rename.
Failure modes
See references/pitfalls.md.
Done when
- Layout matches modular
cms/tree (thin config,content.ts, helpers-bound views) anhur buildor VitebuildStartsucceeds- App imports from
anhur/generatedtypecheck - Opt-in fields used only with matching processors
- Localized folders match
locales/defaultLocale(orlocalized: falseper source) - App loads locale content via
Locale+ getters +_meta.locale(no custom lang filters / content re-parse) - If search is configured:
.anhur/generated/search/orama.jsonexists andcreateSearcherworks in app - If views are configured: derived exports import and typecheck (
allFeatured…, indexes, groups)