Morphos & Kosmesis are here.Explore
PraxisJS

Content

@praxisjs/content — markdown content collections with @Collection, class-based frontmatter schema, runtime validation, and a Vite plugin for zero-boilerplate build-time loading.

Content

Markdown content collections for PraxisJS. Define the shape of your .md files with a class, let the Vite plugin handle file loading at build time, and access entries reactively via @Collection.

npm install @praxisjs/content
pnpm add @praxisjs/content
yarn add @praxisjs/content
bun add @praxisjs/content

Setup

Add the Vite plugin once to your config — it transforms @Collection('./path/*.md') string globs into build-time import.meta.glob calls automatically:

// vite.config.ts
import { praxisjs }      from '@praxisjs/vite-plugin'
import { contentPlugin } from '@praxisjs/content/vite'

export default {
  plugins: [contentPlugin(), praxisjs()],
}

contentPlugin() must come before praxisjs()

Both plugins run with enforce: "pre", and Vite preserves relative order within that tier — so whichever one appears first in the array runs first. praxisjs() includes decorator lowering, which strips @Collection('./path/*.md') down to a plain Collection('./path/*.md') call. If that runs before contentPlugin()'s regex transform sees the @, the glob string reaches @praxisjs/content unexpanded — getCollection() then iterates the string's individual characters as if they were file paths, producing one broken "entry" per character instead of your actual files.


Content directory convention

Each collection lives in its own file alongside its markdown folder. The glob path is relative to the file where the decorator is written.

src/
  content/
    blog.ts          ← Blog schema  →  reads ./blog/*.md
    docs.ts          ← Docs schema  →  reads ./docs/*.md
    blog/
      getting-started.md
      signals-deep-dive.md
    docs/
      installation.md
Storybook
Live demo — Content collection

Defining a collection

Create a file per collection. Extend ContentSchema and declare fields with default values — the defaults define both the TypeScript type and the runtime fallback when frontmatter is missing.

// src/content/blog.ts
import { Collection, ContentSchema } from '@praxisjs/content'

@Collection('./blog/*.md')
export class Blog extends ContentSchema {
  title = ''           // string, warns if missing
  date  = ''           // string, warns if missing
  draft = false        // boolean, defaults to false (no warning)
  tags: string[] = []  // string[], defaults to [] (no warning)
}

Frontmatter file example:

---
title: Getting Started with PraxisJS
date: 2024-01-15
draft: false
tags: [tutorial, beginner]
---

# Getting Started

Welcome to **PraxisJS**! This guide walks you through the basics.

Using in a component

@Collection(Blog) on a field injects a reactive Resource<Entry<Blog>[]>:

import { Collection } from '@praxisjs/content'
import type { Entry, Resource } from '@praxisjs/content'
import { Blog } from '../content/blog'

@Component()
class BlogList extends StatefulComponent {
  @Collection(Blog) posts!: Resource<Entry<Blog>[]>

  render() {
    return (
      <div>
        {() => this.posts.pending() && <p>Loading…</p>}
        {() => this.posts.data()
          ?.filter(p => !p.data.draft)
          .map(p => (
            <article>
              <h2>{p.data.title}</h2>
              <time>{p.data.date}</time>
              <div innerHTML={p.html} />
            </article>
          ))
        }
      </div>
    )
  }
}

The Resource exposes .data(), .pending(), .error(), and .refetch() — the same reactive API as @praxisjs/core's resource().


Entry<S> shape

interface Entry<S extends ContentSchema> {
  slug: string  // filename without extension: 'getting-started'
  data: S       // schema instance with all fields (defaults applied)
  body: string  // raw markdown content
  html: string  // rendered HTML (via marked)
}

Imperative access

Use getCollection, getEntry, getPage, and getTotal outside of component context — in route guards, utilities, or SSR code:

import { getCollection, getEntry, getPage, getTotal } from '@praxisjs/content'
import { Blog } from '../content/blog'

const posts = await getCollection(Blog)                          // Promise<Entry<Blog>[]>
const post  = await getEntry(Blog, 'getting-started')            // Promise<Entry<Blog> | null>
const count = getTotal(Blog)                                     // number — no I/O, reads glob keys
const page  = await getPage(Blog, { page: 2, pageSize: 10 })    // Promise<Entry<Blog>[]>

getTotal is synchronous and reads zero files — it only counts the keys already present in the glob record. getPage loads only the files for the requested slice, all in parallel.


Static paths for @praxisjs/ssg

collectionStaticPaths(Schema) builds a per-route getStaticPaths for @praxisjs/ssg directly from a collection — every entry's slug is substituted into the route's dynamic segment, so there's no manual getCollection + .map() glue to write:

// src/routes.ts
import { collectionStaticPaths } from '@praxisjs/content'
import type { RouteEntry } from '@praxisjs/ssg'
import { Blog } from './content/blog'

export const routes: RouteEntry[] = [
  // ...other routes
  {
    path: '/blog/:slug',
    component: BlogPost,
    getStaticPaths: collectionStaticPaths(Blog),
  },
]

The route's own full path (/blog/:slug) is passed in automatically by @praxisjs/ssg — it must contain exactly one dynamic segment. Routes with zero or multiple params aren't a single-field substitution; write a plain getStaticPaths function for those instead. See the SSG guide for the full picture.


Pagination

Use @PagedCollection to wire a collection directly to any composable that exposes page and pageSize — such as Pagination from @praxisjs/composables. It loads only the current page's files and re-fetches automatically when the page changes.

import { Component, Compose } from '@praxisjs/decorators'
import { Collection, ContentSchema, PagedCollection, getTotal } from '@praxisjs/content'
import type { Entry, Resource } from '@praxisjs/content'
import { Pagination } from '@praxisjs/composables'

@Collection('./blog/*.md')
export class Blog extends ContentSchema {
  title = ''
  date  = ''
}

const PAGE_SIZE = 10

@Component()
class BlogList extends StatefulComponent {
  @Compose(Pagination, { total: getTotal(Blog), pageSize: PAGE_SIZE })
  pager!: Pagination

  @PagedCollection(Blog, 'pager')
  posts!: Resource<Entry<Blog>[]>

  render() {
    return (
      <div>
        {() => this.posts.pending() && <p>Loading…</p>}
        {() => this.posts.data()?.map(p => <article><h2>{p.data.title}</h2></article>)}
        <button disabled={() => !this.pager.hasPrev} onClick={() => this.pager.prev()}>← Prev</button>
        <span>{() => `${this.pager.page} / ${this.pager.totalPages}`}</span>
        <button disabled={() => !this.pager.hasNext} onClick={() => this.pager.next()}>Next →</button>
      </div>
    )
  }
}

@PagedCollection(Blog, 'pager') reads pager.page and pager.pageSize reactively — when the page changes, only the new slice is fetched. Previous page data is kept visible while loading (equivalent to keepPreviousData: true).

The second argument is the field name of the composable on the same component. The composable must be declared before the @PagedCollection field.

Storybook
Live demo — paginated collection

Schema validation

Fields are validated at runtime against the default values declared in the schema class:

SituationBehaviour
Frontmatter field matches default typeCopied to data as-is
Frontmatter field has wrong typeconsole.warn, default value used
Frontmatter field missing, default is '' / false / 0 / []Silently keeps default
Frontmatter field missing, default is non-emptyconsole.warn, default value used

Custom renderer

Override the default marked-based HTML renderer per collection:

import { marked } from 'marked'

@Collection({
  glob: import.meta.glob('./blog/*.md', { query: '?raw', import: 'default' }),
  render: (md) => marked.parse(md, { gfm: true }),
})
class Blog extends ContentSchema { ... }

When using a custom render, you can skip the Vite plugin and pass the glob result directly.


Bundle size

By default the plugin generates a lazy glob — each .md file becomes its own chunk and is only fetched when getCollection is called. All files in a collection are loaded in parallel.

If you need all files inlined into the main bundle (e.g. small static site, SSR), add eager: true when passing the glob directly via the CollectionConfig form above.

On this page