Collection Recipes

Learn how to query information from files, directories, and module exports in various contexts.

In this guide, we’ll explore the power and flexibility of collections, demonstrating how to query information from files, directories, and module exports in various contexts.

By organizing content into structured collections, we can easily generate static pages and manage complex routing and navigations. The following will walk through the process of setting up collections, rendering pages, and building various navigations like lists, paginated views, and hierarchical trees.

Routing

Creating a Collection

A collection is created by calling the collection function with a glob pattern and optional options:

@/collections.ts
import { Collection, type SourceOf } from 'renoun'

export const PostsCollection = new Collection<{
  frontmatter: {
    title: string
    description: string
  }
}>({ filePattern: '@/posts/*.mdx' })

export type PostSource = SourceOf<typeof Posts>

This will create a collection of files and directories normalized as a Source that can be used to generate static pages, render pages, and more.

Rendering a Page

app/posts/[slug].tsx
import { PostsCollection, type PostSource } from '@/collections'
import { notFound } from 'next/navigation'

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const Post = await PostsCollection.getSource(params.path)

  if (!Post) notFound()

  const frontmatter = await Post.getExport('frontmatter').getValue()
  const Content = await Post.getExport('default').getValue()

  return (
    <>
      <h1>{frontmatter.title}</h1>
      <p>{frontmatter.description}</p>
      <Content />
    </>
  )
}

Generating Static Params

To loop through each Source use the getSources method and return an object with the path as the key. This will be used to generate static multi-dimensional routes as it returns an array of path segments:

app/posts/[slug]/page.tsx
import { PostsCollection, type PostsSource } from '@/collections'
import { notFound } from 'next/navigation'

export default async function Page({ params }) {
  const source = PostsCollection.getSource((await params).slug)

  if (!source) notFound()

  const frontmatter = await source.getExport('frontmatter').getValue()
  const Post = await source.getExport('default').getValue()
  const [previous, next] = source.getSiblings()

  return (
    <>
      <h1>{frontmatter.title}</h1>
      <p>{frontmatter.description}</p>
      <Post />
      {previous ? <Sibling source={previous} direction="previous" /> : null}
      {next ? <Sibling source={next} direction="next" /> : null}
    </>
  )
}

function Sibling({
  source,
  direction,
}: {
  source: PostsSource
  direction: 'previous' | 'next'
}) {
  const frontmatter = await source.getExport('frontmatter').getValue()

  return (
    <a href={source.getPath()}>
      <span>{direction === 'previous' ? 'Previous' : 'Next'}</span>
      {frontmatter.title}
    </a>
  )
}

Generating Navigations

To generate navigations we can use the getSources and getSiblings methods to loop through each Source and generate a list or tree of links.

List Navigation

Use getSources to render a list of the immediate sources in the collection:

app/posts/page.tsx
const LIMIT = 10

export default async function Page({
  searchParams,
}: {
  searchParams: { page: string; order: 'asc' | 'desc' }
}) {
  const page = parseInt(searchParams.page, 10) || 0
  const allSources = await PostsCollection.getSources()

  // Retrieve the frontmatter for sorting
  const sourcesWithfrontmatter = await Promise.all(
    allSources.map(async (source) => {
      const frontmatter = await source.getExport('frontmatter').getValue()
      return { source, frontmatter }
    })
  )

  // Sort the sources based on the order
  sourcesWithfrontmatter.sort((a, b) => {
    if (searchParams.order === 'asc') {
      return new Date(a.frontmatter.date) - new Date(b.frontmatter.date)
    }
    return new Date(b.frontmatter.date) - new Date(a.frontmatter.date)
  })

  // Paginate the sources
  const offset = page * LIMIT
  const totalPages = Math.ceil(allSources.length / LIMIT)
  const startIndex = offset
  const endIndex = startIndex + LIMIT
  const paginatedSources = sourcesWithfrontmatter.slice(startIndex, endIndex)

  return (
    <>
      <h1>Posts</h1>
      <nav>
        <ul>
          {paginatedSources.map(({ source }) => (
            <Post key={source.getPath()} Source={source} />
          ))}
        </ul>
      </nav>
      <nav>
        <ul>
          {Array.from(Array(totalPages).keys()).map((index) => (
            <li key={index}>
              <Link href={`/posts/page/${index}`}>{index + 1}</Link>
            </li>
          ))}
        </ul>
      </nav>
    </>
  )
}

Paginated Navigation

To paginate the sources, we can use the getSources method to retrieve all sources, sort them, and paginate them:

app/posts/page.tsx
const LIMIT = 10

export default async function Page({
  searchParams,
}: {
  searchParams: { page: string; order: 'asc' | 'desc' }
}) {
  const page = parseInt(searchParams.page, 10) || 0
  const allSources = await PostsCollection.getSources()

  // Retrieve the frontmatter for sorting
  const sourcesWithfrontmatter = await Promise.all(
    allSources.map(async (source) => {
      const frontmatter = await source.getExport('frontmatter').getValue()
      return { source, frontmatter }
    })
  )

  // Sort the sources based on the order
  sourcesWithfrontmatter.sort((a, b) => {
    if (searchParams.order === 'asc') {
      return new Date(a.frontmatter.date) - new Date(b.frontmatter.date)
    }
    return new Date(b.frontmatter.date) - new Date(a.frontmatter.date)
  })

  // Paginate the sources
  const offset = page * LIMIT
  const totalPages = Math.ceil(allSources.length / LIMIT)
  const startIndex = offset
  const endIndex = startIndex + LIMIT
  const paginatedSources = sourcesWithfrontmatter.slice(startIndex, endIndex)

  return (
    <>
      <h1>Posts</h1>
      <nav>
        <ul>
          {paginatedSources.map(({ source }) => (
            <Post key={source.getPath()} Source={source} />
          ))}
        </ul>
      </nav>
      <nav>
        <ul>
          {Array.from(Array(totalPages).keys()).map((index) => (
            <li key={index}>
              <Link href={`/posts/page/${index}`}>{index + 1}</Link>
            </li>
          ))}
        </ul>
      </nav>
    </>
  )
}

Tree Navigation

Similar to list navigation, we can use getSources recursively to render a tree of links:

app/posts/layout.tsx
import { PostsCollection } from '@/collections'

export default async function Layout() {
  return (
    <nav>
      <ul>
        <TreeNavigation source={PostsCollection} />
      </ul>
    </nav>
  )
}

async function TreeNavigation({ source }: { source: PostSource }) {
  const sources = source.getSources()
  const path = source.getPath()
  const depth = source.getDepth()
  const frontmatter = await source.getExport('frontmatter').getValue()

  if (sources.length === 0) {
    return (
      <li style={{ paddingLeft: `${depth}rem` }}>
        <Link href={path} style={{ color: 'white' }}>
          {frontmatter.title}
        </Link>
      </li>
    )
  }

  const childrenSources = sources.map((childSource) => (
    <TreeNavigation key={childSource.getPath()} source={childSource} />
  ))

  if (depth > 0) {
    return (
      <li style={{ paddingLeft: `${depth}rem` }}>
        <Link href={path} style={{ color: 'white' }}>
          {frontmatter.title}
        </Link>
        <ul>{childrenSources}</ul>
      </li>
    )
  }

  return <ul>{childrenSources}</ul>
}

Sibling Navigation

app/posts/[slug]/page.tsx
import { PostsCollection, type PostsSource } from '@/collections'
import { notFound } from 'next/navigation'

export default async function Page({ params }) {
  const source = PostsCollection.getSource((await params).slug)

  if (!source) notFound()

  const frontmatter = await source.getExport('frontmatter').getValue()
  const Post = await source.getExport('default').getValue()
  const [previous, next] = source.getSiblings()

  return (
    <>
      <h1>{frontmatter.title}</h1>
      <p>{frontmatter.description}</p>
      <Post />
      {previous ? <Sibling source={previous} direction="previous" /> : null}
      {next ? <Sibling source={next} direction="next" /> : null}
    </>
  )
}

function Sibling({
  source,
  direction,
}: {
  source: PostsSource
  direction: 'previous' | 'next'
}) {
  const frontmatter = await source.getExport('frontmatter').getValue()

  return (
    <a href={source.getPath()}>
      <span>{direction === 'previous' ? 'Previous' : 'Next'}</span>
      {frontmatter.title}
    </a>
  )
}

Metadata

Generating Metadata

To generate metadata for file, export a metadata constant that returns an object with the any metadata properties you want to include:

components/Button.tsx
export const metadata = {
  title: 'Button',
  description: 'A button component',
}

Using Metadata

To use the metadata, we can get the value of the metadata constant from the file and use it to set the page title and description:

components/[slug]/page.tsx
import { Collection } from 'renoun/collections'
import { notFound } from 'next/navigation'

type Schema = {
  metadata: {
    title: string
    description: string
  }
}

export const Components = new Collection<Schema>({
  filePattern: '@/components/**/index.{ts,tsx}',
})

export default async function Page({ params }) {
  const source = Components.getSource((await params).slug)

  if (!source) notFound()

  const metadata = await source.getExport('metadata').getValue()

  return (
    <>
      <h1>{metadata.title}</h1>
      <p>{metadata.description}</p>
    </>
  )
}

Generating Metadata for Collections

To generate metadata for collections, we can use the root directory in the targeted file pattern to set the metadata:

components/index.ts
export const metadata = {
  title: 'Components',
  description: 'A collection of components',
}

Using Metadata for Collections

Similar to above, we can get the value of the metadata constant for a collection and use it to set the page title and description:

components/page.tsx
import { Components } from '@/collections'

export default async function Page() {
  const metadata = await Components.getSource().getExport('metadata').getValue()

  return (
    <>
      <h1>{metadata.title}</h1>
      <p>{metadata.description}</p>
    </>
  )
}

This works by finding the index file related to the root directory we’re targeting in the component’s collection and extracting the metadata from it.

Blogs

blog/[slug]/page.tsx

import type { MDXContent, SourceOf } from 'renoun'
import { Collection } from 'renoun/collections'
import { notFound } from 'next/navigation'
import { getSiteMetadata } from '@/utils'

export const Posts = new Collection<{
  default: MDXContent
  frontmatter: {
    title: string
    description: string
  }
}>({ filePattern: '@/posts/*.mdx' })

export type PostSource = SourceOf<typeof Posts>

export function generateStaticParams() {
  return Posts.getSources().map((source) => ({
    slug: source.getPath(),
  }))
}

export async function generateMetadata({ params }) {
  const source = await Posts.getSource((await params).slug)

  if (!source) notFound()

  const frontmatter = await source.getExport('frontmatter').getValue()

  return getSiteMetadata({
    title: `${frontmatter.title} - MDXTS`,
    description: frontmatter.description,
  })
}

export default async function Page({ params }) {
  const source = await Posts.getSource((await params).slug)

  if (!source) notFound()

  const frontmatter = await source.getExport('frontmatter').getValue()
  const Content = await source.getExport('default').getValue()

  return (
    <>
      <h1>{frontmatter.title}</h1>
      <p>{frontmatter.description}</p>
      <Content />
    </>
  )
}

blog/layout.tsx

import { Posts, type PostSource } from './[slug]/page'

function Navigation({ source }: { source: PostSource }) {
  const sources = await source.getSources()

  if (sources.length === 0) return null

  return (
    <ul>
      {sources.map((sourceItem) => (
        <li key={sourceItem.getPath()}>
          {sourceItem.getPath()}
          <Navigation source={sourceItem} />
        </li>
      ))}
    </ul>
  )
}

export default function BlogLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div>
      <aside>
        <h2>Posts</h2>
        <Navigation source={Posts} />
      </aside>
      <main>{children}</main>
    </div>
  )
}

blog/page.tsx

import { Posts, type PostSource } from './[slug]/page'

export default async function BlogPage() {
  return (
    <>
      <h1>All Posts</h1>
      <ul>
        {Posts.getSources().map((source) => (
          <BlogPost key={source.getPath()} source={source} />
        ))}
      </ul>
    </>
  )
}

async function BlogPost({ source }: { source: PostSource }) {
  const frontmatter = await source.getExport('frontmatter').getValue()

  return (
    <li>
      <a href={source.getPath()}>
        <h2>{frontmatter.title}</h2>
        <p>{frontmatter.description}</p>
      </a>
    </li>
  )
}

API Documentation

components/[slug]/page.tsx

import { collection } from 'renoun'

export const Components = collection('@/components/**/index.{ts,tsx}')

export const ComponentsMDX = collection('@/components/**/README.mdx')

export function generateStaticParams() {
  return Components.getSources().map((Component) => ({
    slug: Component.getPath(),
  }))
}

export default async function Page({ params }) {
  const Component = Components.getSource((await params).slug)
  const ComponentMDX = ComponentsMDX.getSource((await params).slug)

  if (!Component && !ComponentMDX) notFound()

  const Content = await ComponentMDX.getExport('default').getValue()

  return (
    <>
      <h1>{ComponentFile.getLabel()}</h1>
      <Content />
      <Component>
        <APIReference />
      </Component>
    </>
  )
}

components/[slug]/[example]/page.tsx

import { Collection } from 'renoun/collections'
import { Tokens } from 'renoun/components'
import { notFound } from 'next/navigation'

export const ComponentExamples = new Collection<
  Record<string, React.ComponentType>
>({
  filePattern: '@/components/**/*.examples.tsx',
})

export function generateStaticParams() {
  return ComponentExamples.getSources().map((Component) => {
    const componentPath = Component.getPath()

    return Component.getExports().map(([exportName]) => ({
      component: componentPath,
      example: exportName,
    }))
  })
}

export default async function Page({
  params,
}: {
  params: { component: string; example: string }
}) {
  const ExampleSource = ComponentExamples.getSource(params.component)

  if (!ExampleSource) notFound()

  const ExportedSource = ExampleSource.getExport(params.example)

  if (!ExportedSource) notFound()

  const name = ExportedSource.getName()
  const Example = await ExportedSource.getValue()

  return (
    <div>
      {name}
      <ExampleSource>
        {/* show all examples and highlight the focused example */}
        <Tokens
          focus={[[ExportedSource.getStart(), ExportedSource.getEnd()]]}
        />

        {/* alternatively, pass the source */}
        <Tokens focus={[ExportedSource]} />
      </ExampleSource>

      <ExportedSource>
        {/* display highlighted example source */}
        <Tokens />
      </ExportedSource>

      {/* render the example */}
      <Example />
    </div>
  )
}

TypeScript Configuration

packages/[slug]/page.tsx

import { APIReference, collection, type CollectionOptions } from 'renoun'
import { notFound } from 'next/navigation'

const sharedOptions = {
  tsConfigFilePath: '../packages/mdxts/tsconfig.json',
} satisfies CollectionOptions

export const Packages = collection('src/**/index.{ts,tsx}', sharedOptions)

export const PackagesMDX = collection('src/**/README.mdx', sharedOptions)

export function generateStaticParams() {
  return Packages.getSources().map((file) => ({
    component: file.getPath(),
  }))
}

export default async function Page({ params }) {
  const [PackageFile, PackageMDXFile] = await Promise.all([
    Packages.getSource(params.component),
    PackagesMDX.getSource(params.component),
  ])

  if (!PackageFile && !PackageMDXFile) notFound()

  const PackageDocs = await PackageMDXFile.getExport('default').getValue()

  return (
    <>
      <h1>{PackageFile.getLabel()}</h1>
      <PackageDocs />
      <PackageFile>
        <APIReference />
      </PackageFile>
    </>
  )
}
Last updated