Skip to content

Examples

Complete queries for pages most sites have. They use the example schema and the Next.js App Router, and assume a pages root without i18n unless noted.

A page for every url

A catch-all route that renders whatever entry lives at the requested url, and prerenders all of them at build time:

app/[[...slug]]/page.tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
import {notFound} from 'next/navigation'

interface PageProps {
  params: Promise<{slug?: Array<string>}>
}

export async function generateStaticParams() {
  const urls = await cms.find({
    root: cms.workspaces.main.pages,
    select: Query.url
  })
  return urls.map(url => ({slug: url.split('/').filter(Boolean)}))
}

export default async function Page({params}: PageProps) {
  const {slug = []} = await params
  const page = await cms.first({
    root: cms.workspaces.main.pages,
    url: `/${slug.join('/')}`,
    select: {type: Query.type, title: Query.title}
  })
  if (!page) notFound()
  return <h1>{page.title}</h1>
}

Render a different component per page.type, each fetching the data it needs by id or url.

Blog overview with pagination

app/blog/page.tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'

const perPage = 10

interface BlogPageProps {
  searchParams: Promise<{page?: string}>
}

export default async function BlogPage({searchParams}: BlogPageProps) {
  const page = Math.max(1, Number((await searchParams).page) || 1)
  const [posts, total] = await Promise.all([
    cms.find({
      type: BlogPost,
      select: {
        title: Query.title,
        url: Query.url,
        date: BlogPost.publishDate,
        intro: BlogPost.intro
      },
      orderBy: {desc: BlogPost.publishDate},
      skip: (page - 1) * perPage,
      take: perPage
    }),
    cms.count({type: BlogPost})
  ])
  const pageCount = Math.ceil(total / perPage)
  return (
    <main>
      {posts.map(post => (
        <a key={post.url} href={post.url}>
          <h2>{post.title}</h2>
          <p>{post.intro}</p>
        </a>
      ))}
      <p>
        Page {page} of {pageCount}
      </p>
    </main>
  )
}

Posts that share at least one category with the current post, newest first, without the post itself:

import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'

export async function relatedPosts(postId: string) {
  const post = await cms.get({
    type: BlogPost,
    id: postId,
    select: {categories: BlogPost.categories}
  })
  const categoryIds = post.categories.map(category => category.entryId)
  if (categoryIds.length === 0) return []
  return cms.find({
    type: BlogPost,
    id: {isNot: postId},
    filter: {categories: {includes: {_entry: {in: categoryIds}}}},
    select: {title: Query.title, url: Query.url},
    orderBy: {desc: BlogPost.publishDate},
    take: 3
  })
}

To require all categories instead of any, combine one includes per category with and:

import {cms} from '@/cms'
import {BlogPost} from '@/schema'

export function postsInAllCategories(categoryIds: Array<string>) {
  return cms.find({
    type: BlogPost,
    filter: {
      and: categoryIds.map(id => ({categories: {includes: {_entry: id}}}))
    }
  })
}

Filtering a listing from the url

Optional filters from search params, skipped when they're empty:

import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'

interface Filters {
  category?: string
  q?: string
}

export function filteredPosts({category, q}: Filters) {
  return cms.find({
    type: BlogPost,
    search: q || undefined,
    filter: {
      categories: category ? {includes: {_entry: category}} : undefined
    },
    select: {title: Query.title, url: Query.url},
    // Sort by date unless the visitor searched, then by relevance
    orderBy: q ? undefined : {desc: BlogPost.publishDate}
  })
}

Redirect old urls

When an editor changes the path of a published document, or moves it, its previous url is kept in its metadata aliases. Look it up when nothing matches, and redirect:

app/[[...slug]]/page.tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
import {notFound, permanentRedirect} from 'next/navigation'

export async function findPage(url: string) {
  const page = await cms.first({url, select: {title: Query.title}})
  if (page) return page
  const moved = await cms.first({alias: url, select: Query.url})
  if (moved) permanentRedirect(moved)
  notFound()
}

Sitemap

Every page with its translations, for app/sitemap.ts. This one uses a translated pages root:

app/sitemap.ts
import {cms} from '@/cms'
import {Query} from 'alinea'
import type {MetadataRoute} from 'next'

const baseUrl = 'https://example.com'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const pages = await cms.find({
    root: cms.workspaces.main.pages,
    locale: 'en',
    select: {
      url: Query.url,
      updatedAt: Query.updatedAt,
      translations: Query.translations({
        includeSelf: true,
        select: {locale: Query.locale, url: Query.url}
      })
    }
  })
  return pages.map(page => ({
    url: baseUrl + page.url,
    lastModified: page.updatedAt ? new Date(page.updatedAt * 1000) : undefined,
    alternates: {
      languages: Object.fromEntries(
        page.translations.map(t => [t.locale, baseUrl + t.url])
      )
    }
  }))
}

Site settings

Settings that appear on every page, like the footer, fit in a seeded entry in their own root (see Roots). There's exactly one, so query it by type with get:

import {cms} from '@/cms'
import {Settings} from '@/schema'

export async function Footer() {
  const settings = await cms.get({type: Settings})
  return <footer>{settings.title}</footer>
}

In a translated settings root, pass the locale too, otherwise get returns whichever translation comes first.