@onderwijsin/nuxt-markdown-documents

Typed, source-agnostic Markdown document definitions for Nuxt.
253
Install
npm install @onderwijsin/nuxt-markdown-documents
Latest Version:0.2.0
Vue:3
License:MIT
Last Updated:Oct 18, 2026
Links: GitHub  ·  npm
Maintainer: remihuigen

@onderwijsin/nuxt-markdown-documents

Serve public Markdown from any server-side data source in Nuxt. Define a canonical route, fetch its source, and transform it into structured metadata and a Markdown body. The module provides the raw HTTP response, YAML frontmatter serialization, metadata listing, and server-side document resolution.

Data access, publication filtering, and conversion of source content to Markdown belong to your application. No CMS, Directus, AI Toolkit, or Agent Discovery installation is required.

Installation

pnpm add @onderwijsin/nuxt-markdown-documents
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@onderwijsin/nuxt-markdown-documents"]
});

Requires Nuxt 4 and Node.js 24 or newer. Node 24 with the repository's pinned Nuxt dependency graph is the tested baseline; older Node versions are unsupported. Server runtime code avoids Node-specific APIs so it can be bundled for Cloudflare Workers. This is a portability contract, not a separate continuously tested Workers deployment matrix. Your source clients must also support their target runtime.

Define a document

Create server/documents/articles.ts in your application root. Each discovered file must default-export a document definition. This complete example uses a local source; replace its lookup and metadata enumeration with your own CMS, database, API, or application service.

// server/documents/articles.ts
import { defineMarkdownDocument } from "@onderwijsin/nuxt-markdown-documents/server";

interface Article {
  title: string;
  summary: string;
  published: boolean;
  publishedAt: string;
  tags: string[];
  markdown: string;
}

const articles = new Map<string, Article>([
  [
    "introduction",
    {
      title: "Introduction",
      summary: "A public introduction to the project.",
      published: true,
      publishedAt: "2026-10-15T09:00:00.000Z",
      tags: ["Getting started"],
      markdown: "# Introduction\n\nThe article body in **Markdown**."
    }
  ],
  [
    "working-notes",
    {
      title: "Working notes",
      summary: "An unpublished draft.",
      published: false,
      publishedAt: "",
      tags: [],
      markdown: "Private draft content."
    }
  ]
]);

export default defineMarkdownDocument({
  route: "/articles/:slug",
  async fetch({ params }) {
    const article = articles.get(params.slug ?? "");
    return article?.published ? article : null;
  },
  transform(article) {
    return {
      frontmatter: {
        title: article.title,
        description: article.summary,
        publishedAt: article.publishedAt,
        tags: article.tags
      },
      body: article.markdown
    };
  },
  list() {
    return [...articles.entries()]
      .filter(([, article]) => article.published)
      .map(([slug, article]) => ({
        title: article.title,
        description: article.summary,
        path: `/articles/${slug}`
      }));
  }
});

GET /articles/introduction.md returns Content-Type: text/plain; charset=utf-8:

---
title: Introduction
description: A public introduction to the project.
publishedAt: 2026-10-15T09:00:00.000Z
tags:
  - Getting started
---

# Introduction

The article body in **Markdown**.

GET /articles/working-notes.md returns 404. The canonical HTML page /articles/introduction remains application-owned; the module does not create or render that page.

defineMarkdownDocument is an identity helper: it preserves type inference and executes no callbacks. It is also available as a Nitro server auto-import, never as an app auto-import. Synchronous and asynchronous fetch, transform, and list callbacks are supported. The non-null fetched type flows into transform automatically.

Configuration

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@onderwijsin/nuxt-markdown-documents"],
  markdownDocuments: {
    enabled: true,
    dir: "server/documents",
    rawPrefix: "/raw",
    contentType: "plain"
  }
});
Option Default Behavior
enabled true Enables server discovery, utilities, and raw delivery. false skips registration; options are still validated.
dir server/documents Definition directory relative to application rootDir.
rawPrefix Unset Serves canonical paths with .md appended. Set a prefix such as /raw for prefixed delivery.
contentType "plain" Response MIME type: "plain" for text/plain, or "markdown" for text/markdown. Request headers do not override it.

The implicit default directory may be absent. An explicitly configured missing directory, or a path that is not a directory, fails setup. dir must be a nonempty relative path without traversal. It is independent of Nuxt's srcDir and a custom serverDir. Layer definition directories are not merged.

JavaScript and TypeScript files are discovered recursively, excluding declaration files. Keep helper files outside this directory, for example under server/utils. Definitions execute in the compiled Nitro server, so server aliases and auto-imports can be used there. Adding or removing definition files in development rebuilds the registry; edits to imported files use Nitro's normal dependency watcher.

rawPrefix accepts a leading-slash path made of letters, digits, underscores, hyphens, and optional nested segments, such as /content/raw. Empty strings, /, and trailing slashes are invalid. null is accepted; Nuxt treats it like an omitted value during option merging, so do not use it to clear a prefix inherited from another configuration layer.

Definition contract

Property Required Contract
route Yes Canonical application-relative HTML path or pattern, without app.baseURL or .md.
fetch(context) Yes Returns source data or null. Context contains decoded params and an optional H3 event.
transform(source) Yes Returns exactly { frontmatter: Record<string, unknown>, body: string }.
list(context?) No Returns { title, path, description? } entries for public documents.

Definitions accept only the fields listed above; unsupported fields fail validation.

A raw HTTP request supplies the event. Programmatic resolution and listing can run without one, so callbacks used that way must support event-free execution. A null fetch never reaches transform. Undefined fetch results and invalid transform results are errors.

For a single document, the module calls only its matching fetch and transform. It never calls list or retrieves an entire collection. Keep your own lookup similarly focused. For remote sources, make list request only the metadata it needs instead of downloading and transforming every body.

Listing paths identify canonical HTML, not raw Markdown URLs. Titles must be nonblank. Paths are normalized and must belong to the definition that lists them. Identical entries for a normalized path deduplicate; conflicting titles or descriptions fail. Extra listing fields are omitted. Definitions without list contribute no entries. Listing does not verify availability by fetching documents: apply the same public/published policy in both callbacks.

Routes and URL mapping

Patterns support /, literal segments, and named single-segment parameters such as /articles/:slug. Parameter names start with a letter or underscore, contain letters, digits or underscores, and appear only once per pattern. Literal segments allow Unicode letters/numbers and ._~-. Optional params, wildcards, encoded patterns, trailing slashes, queries, and fragments are unsupported. /index and paths ending in .md are reserved.

Static routes take precedence over parameter routes. Equally specific overlapping patterns fail with both definition filenames. You are responsible for keeping document-owned raw URLs distinct from application pages, server handlers, public files and other modules. The module does not inspect those routes or report collisions; overlapping ownership can cause one response to shadow another. Give each raw URL one owner, or configure a separate rawPrefix.

Canonical input Default URL With rawPrefix: "/raw" With /raw and app.baseURL: "/docs/"
/ /index.md /raw/index.md /docs/raw/index.md
/articles/one /articles/one.md /raw/articles/one.md /docs/raw/articles/one.md

Resolution, listing, and URL derivation share a canonical path policy. Non-root trailing slashes normalize away, Unicode is percent-encoded consistently, and fetch params are decoded once. Reject external or protocol-relative URLs, backslashes, traversal, encoded separators, malformed or nested encoding, queries, and fragments. Ordinary encoded parameter characters are supported.

Server utilities

These application-bound functions are Nitro server auto-imports. They are not exported implementations from the package's /server subpath and must not be imported into client code.

// server/api/article.get.ts
export default defineEventHandler(async (event) => {
  const path = "/articles/introduction";
  const document = await resolveMarkdownDocument(path, { event });
  if (document === null) throw createError({ statusCode: 404, message: "Not found" });

  return {
    document,
    entries: await listMarkdownDocuments(),
    markdownURL: getMarkdownDocumentURL(path)
  };
});
Utility Return value Behavior
resolveMarkdownDocument(path, context?) Promise<MarkdownDocument | null> Fetches and transforms one matching document. Null means unmatched or unavailable. Metadata and body remain separate.
listMarkdownDocuments(context?) Promise<MarkdownDocumentListItem[]> Calls list callbacks only; never fetches or transforms bodies.
getMarkdownDocumentURL(path) string Applies app.baseURL and rawPrefix once. Does not check availability or route ownership.

Pass canonical paths excluding app.baseURL, not previously derived raw URLs. Utilities use the same service as HTTP delivery, without internal HTTP requests or YAML reparsing. Invalid caller paths throw statusCode 400. Callback failures and invalid output throw statusCode 500 with sanitized operation/source diagnostics; backend errors and source payloads are not included.

The public @onderwijsin/nuxt-markdown-documents/server subpath exports defineMarkdownDocument and these types: MarkdownDocument, MarkdownDocumentListItem, MarkdownDocumentDefinition<TSource>, MarkdownDocumentContext, MarkdownDocumentFetchContext, and MarkdownDocumentService. The package root also exports the ModuleOptions type for module configuration.

Frontmatter and HTTP delivery

Frontmatter accepts plain objects (including null-prototype objects), dense arrays, strings, finite numbers, booleans, and null, recursively. Dates must be strings. Undefined, non-finite numbers, bigint, functions, symbols or symbol keys, cycles, sparse arrays, accessors, hidden properties, and class instances are rejected. Validation reports the metadata path without its value.

YAML 1.2 serialization preserves scalar types and emits no custom tags or aliases. Repeated non-cyclic references serialize independently. The module writes one frontmatter block and exactly one blank line before the body, normalizing leading body blank lines at that boundary. Other body content is unchanged. Empty metadata emits the original body without delimiters or whitespace changes. Do not put a leading YAML mapping block in body; raw delivery rejects it. Ordinary Markdown horizontal rules are supported.

Responses use text/plain; charset=utf-8 by default. Set markdownDocuments.contentType to "markdown" to default to text/markdown; charset=utf-8 instead. Both formats contain identical Markdown and YAML bytes.

Request Accept and Content-Type headers do not change the response type. The module does not add header-based cache variation.

Only GET retrieves a document. Matching HEAD and other methods return 405 with Allow: GET, without calling fetch or transform. A query string does not change HTTP document selection. Missing/private documents return generic 404 responses; unsafe matched paths return 400 before retrieval. Provider, transform, and serialization failures return a generic 500. Other HTML, API, and unrelated .md routes pass through to the application.

Public data and credentials

Raw routes are public. Filter drafts, previews, and private records in both fetch and list. A privileged CMS token must not turn those records into public exports. Include only deliberately public fields in frontmatter and body; never return credentials or internal source objects wholesale.

Keep source clients, credentials, fetchers, and transforms in server files. Use your application's private runtime configuration and source validation. A consumer-owned MCP tool or discovery adapter can call the server utilities directly; it does not need to fetch the application's HTTP endpoints.

Prerendering and integrations

The module does not discover routes or schedule prerendering. There is no module or per-document prerender option; built-in prerender orchestration is deferred. Configure concrete raw URLs through Nuxt/Nitro or an existing route-discovery module. For the default URL mapping:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@onderwijsin/nuxt-markdown-documents"],
  nitro: {
    prerender: {
      routes: ["/articles/introduction.md"],
      failOnError: true
    }
  }
});

Use /raw/articles/introduction.md when rawPrefix is /raw. Route rules express rendering policy; wildcard rules do not enumerate unknown slugs. Existing Directus Prerenderer mappers may return raw Markdown URLs alongside HTML routes. Source services and private credentials must be available during prerender requests. Your host controls MIME mappings for generated files: serve .md with the desired text/plain or text/markdown MIME type and UTF-8 charset, and verify that response in the deployed environment.

The module has no HTML conversion, HTML/Markdown negotiation on canonical routes, sitemap integration, llms.txt, built-in MCP server, source adapter framework, or cache/invalidation layer. AI Toolkit integration is separate optional work; do not register competing handlers for document-owned raw URLs or convert their Markdown through an HTML converter.

Troubleshooting

Symptom Check
Definition is not discovered Check root-relative dir, nested source extensions, and the file's default export. Keep helpers elsewhere.
Raw request returns 404 Use the derived raw URL and inspect the matching fetch result and publication filter.
Raw request returns 500 Call the structured resolver server-side for sanitized fetch/transform diagnostics; check JSON metadata and body-owned frontmatter.
Listing fails Check nonblank titles, canonical paths, definition ownership, and conflicting duplicate metadata.
Server fails at startup Resolve ambiguous document-definition patterns identified in the error.

Nuxt LLMS integration

When nuxt-llms is registered in the application, definitions can opt into its output with section metadata:

export default defineMarkdownDocument({
  route: "/articles/:slug",
  llms: { title: "Articles", description: "Public articles and guides" },
  fetch: ({ params }) => getArticle(params.slug),
  transform: (article) => ({ frontmatter: { title: article.title }, body: article.body }),
  list: () => getPublicArticleMetadata()
});

Each opted-in definition contributes one section to /llms.txt. Its list() callback supplies link titles, descriptions, and canonical paths; links point to Markdown URLs and respect app.baseURL and markdownDocuments.rawPrefix. Existing listing validation, deduplication, and route ownership checks apply. Definitions without llms are excluded; without list() a section has no links. Both llms.title and llms.description are required strings, and the title must contain non-whitespace text.

Enable llms.full in the nuxt-llms configuration to generate /llms-full.txt. The integration resolves each listed document and includes its serialized frontmatter and Markdown body under its section. Documents whose fetch returns null are skipped. Callback and validation failures fail generation using the same sanitized errors as the document service. Both hooks pass the current request event to callbacks. Prerendered output reflects build-time data, so callbacks must also work during prerendering. Listing remains metadata-only for /llms.txt.

Experimental AI Ready integration

With nuxt-ai-ready 2.5.3 registered, the same definition llms metadata and list() callbacks contribute sections to /llms.txt. Use either AI Ready or Nuxt LLMS as the discovery route owner, not both. The adapter runs in Nitro on GET /llms.txt, respecting app.baseURL, and appends sections to the request-local AI Ready configuration. Existing sections are preserved, listings receive the request event, and links use the module's raw Markdown URLs with rawPrefix. Definitions without llms are excluded; listing validation is unchanged and no document bodies are fetched.

AI Ready owns formatting and caching. In 2.5.3, section descriptions appear in the preamble, while section headings and links appear below it. Listings run even on cache hits, but output changes only when AI Ready regenerates it. Prerendered files reflect build-time data. This proof of concept relies on AI Ready's 2.5.3 runtime configuration, not its setup-time ai-ready:llms-txt hook. It does not extend application-owned static discovery files or provide /llms-full.txt orchestration.

AI Ready generates the full export from prerendered pages. Document list() entries and runtime Markdown requests do not populate its page index; without prerendering or runtime indexing, /llms-full.txt reports "No pages indexed". Configure concrete canonical pages with nitro.prerender.routes (for example, ["/articles/public"]) in the consuming app. The full export uses our original document bodies, while AI Ready strips their YAML frontmatter from the aggregate; arbitrary frontmatter remains available in the individual generated .md files.

Nitro captures request-hook errors and continues serving the AI Ready route. If a document listing fails, /llms.txt can still return HTTP 200 with AI Ready's base output and no document sections. Check server error reporting when document sections are unexpectedly absent.

Original Markdown content

When AI Ready is registered, the module also supplies original documents through its Nitro content hooks. No additional configuration is needed:

  • ai-ready:markdown:source resolves the requested document before runtime HTML conversion. Known documents supply their original Markdown; unknown or unavailable documents leave AI Ready's existing source or HTML fallback unchanged. Application base URLs and raw prefixes are handled.
  • ai-ready:page:markdown replaces converted output for known canonical document routes, including prerendering and indexing calls. These callbacks may run without a request event, so document fetchers used during indexing must support event-free execution.

The module serializes the complete document frontmatter into the supplied Markdown, preserving arbitrary JSON-compatible properties such as nested authors, tags, numbers, booleans, and nulls. It does not spread these properties onto AI Ready's source object, which accepts only a few standard fields. String title, description, and updatedAt values are also passed through the source metadata; updatedAt becomes AI Ready's last_updated while remaining in the original metadata.

AI Ready layers its own response fields over source frontmatter, including the canonical URL and standard title/description fields, and may append its sitemap footer. Its generated values win for colliding keys; custom properties otherwise survive in a single YAML block. Conversion-hook replacement writes our serialized document into the converted Markdown; AI Ready's separate index fields such as extracted HTML titles and headings remain controlled by AI Ready. This does not change the module's standalone raw delivery behavior when AI Ready is absent.

Source retrieval and serialization errors fail AI Ready's Markdown request with a server error; they do not silently fall back to an HTML representation. This differs from discovery listing errors in Nitro's request hook described above.

AI Ready indexing and MCP limitations

The module supplies original Markdown to AI Ready. The consuming application owns prerendering, runtime indexing, and content freshness. Configure concrete prerender routes or sitemap sources and runtime sync in the consumer; document list() entries do not configure either automatically. Publishing flows, reindexing after edits, removal of unpublished documents from the stored index, and cache freshness are also consumer responsibilities. The module does not orchestrate these operations or invalidate AI Ready's caches.

These restrictions apply to the experimental integration with AI Ready 2.5.3:

  • The content hooks replace Markdown while AI Ready is already processing a route. They do not discover routes, schedule prerendering, or initiate indexing. Runtime source responses do not update the page index. Definition list() entries only populate our llms.txt sections.
  • MCP reads AI Ready's stored page index. Prerender canonical HTML pages or use AI Ready's runtime indexing to make their documents available to its tools. Installing MCP Toolkit and configuring database storage remain application responsibilities.
  • HTML is the metadata authority for prerendered and indexed pages: title, description, headings, keywords, and declared update dates come from HTML extraction. Our integration replaces the Markdown body, including serialized document frontmatter, but does not synchronize those separate index fields from document metadata. Keep HTML metadata aligned when accurate MCP listings and search metadata matter. Arbitrary frontmatter is preserved inside Markdown, not exposed as structured MCP fields.
  • Documents without a working canonical HTML page can be served through the runtime Markdown source hook, but are not supported by AI Ready's HTML-based prerendering and route-indexing pipeline. A raw Markdown URL alone is insufficient for that pipeline.
  • In AI Ready 2.5.3, polling and cron process pending index entries, including newly discovered routes. They do not periodically refresh every indexed page after its TTL expires. For edits between deployments, call AI Ready's authenticated reindex endpoint or trusted indexing helpers; TTL controls freshness checks for manual indexing and sitemap refreshes. Unpublishing also needs an explicit stored-index cleanup policy.
  • AI Ready 2.5.3's MCP tools cache listings and the pages resource for one hour, and search and Markdown retrieval for five minutes. A successful reindex does not guarantee immediately fresh MCP responses; account for these cache lifetimes in the consumer's publishing and freshness policy.

Related Packages

@onderwijsin/nuxt-directus-client

Server-safe Directus REST integration for Nuxt applications.

3,264 0
@onderwijsin/nuxt-turnstile

Nuxt Turnstile integration with client helpers and server-side validation.

2,924 0
@onderwijsin/nuxt-theme-customizer

Nuxt module for runtime theme selection and custom color editing in Nuxt UI.

2,719 0
@onderwijsin/nuxt-module-utils

Shared runtime and Nuxt module utilities for Onderwijs in modules.

2,622 0
@onderwijsin/nuxt-newsletter-signup

Provider-independent newsletter signup endpoint for Nuxt.

2,353 0

Version History

Version Released Vue License
0.2.0 ^3.0 MIT
0.1.0 ^3.0 MIT
Pre-releases (1)
Version Released License
0.0.0-stage