Skip to content

Step 5: Use a catch-all slug route

So far every page type has its own route in app/. That works while the site structure is fixed, but editors can't add an About page with Team and History pages below it without a developer adding routes. In this step one catch-all route renders every entry in the Pages root, whatever its URL.

The Team page nested below About in the content tree, previewed at its nested urlThe Team page nested below About in the content tree, previewed at its nested url

The Blog and Post types stay as they are. A new Page type takes over from the landing page, and app/[[...slug]]/page.tsx replaces app/page.tsx, app/blog/page.tsx and app/blog/[slug]/page.tsx.

project structure
app/
├ (alinea)/api/cms/route.ts
├ layout.tsx
├ globals.css
╰ [[...slug]]/page.tsx

entries/
├ page/
│ ├ Page.tsx
│ ╰ Page.schema.tsx
├ blog/
│ ├ Blog.tsx
│ ╰ Blog.schema.tsx
├ post/
│ ├ Post.tsx
│ ╰ Post.schema.tsx
╰ settings/
  ├ SiteLayout.tsx
  ╰ SiteLayout.schema.tsx

blocks/
╰ ...

cms.tsx

A page type that nests

Page has the same blocks as the landing page and an editable path. contains: ['Page'] lets editors create pages below pages, to any depth. The URL of a nested page is built from the paths of its parents: /about/team.

entries/page/Page.schema.tsx
import {Config, Field} from 'alinea'
import {ImageBlock} from '@/blocks/image/ImageBlock.schema'
import {TextBlock} from '@/blocks/text/TextBlock.schema'
import {WeatherBlock} from '@/blocks/weather/WeatherBlock.schema'

export const Page = Config.document('Page', {
  contains: ['Page'],
  fields: {
    title: Field.text('Title', {required: true, width: 0.5}),
    path: Field.path('Path', {required: true, width: 0.5}),
    blocks: Field.list('Blocks', {
      schema: {
        TextBlock,
        ImageBlock,
        WeatherBlock
      }
    })
  }
})

PageView receives the entry as a prop and maps each block to its view, like the landing page did in step 2.

entries/page/Page.tsx
import type {Infer} from 'alinea'
import {ImageBlockView} from '@/blocks/image/ImageBlock'
import {TextBlockView} from '@/blocks/text/TextBlock'
import {WeatherBlockView} from '@/blocks/weather/WeatherBlock'
import {Page} from './Page.schema'

type PageData = Infer.Entry<typeof Page>

export function PageView({page}: {page: PageData}) {
  return (
    <main>
      <h1>{page.title}</h1>
      {page.blocks.map(block => {
        if (block._type === 'TextBlock') return <TextBlockView key={block._id} block={block} />
        if (block._type === 'ImageBlock') return <ImageBlockView key={block._id} block={block} />
        if (block._type === 'WeatherBlock') return <WeatherBlockView key={block._id} block={block} />
        return null
      })}
    </main>
  )
}

Register the type

Replace LandingPage with Page in the schema and in the Pages root. The root no longer seeds a homepage: editors create it as a page with the path index, which resolves to /. If you followed the previous steps, your content still has the old landing page in content/pages/index.json, with a type that no longer exists: delete that file, then create the homepage in the dashboard.

cms.tsx
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'
import {Blog} from '@/entries/blog/Blog.schema'
import {Page} from '@/entries/page/Page.schema'
import {Post} from '@/entries/post/Post.schema'
import {SiteLayout} from '@/entries/settings/SiteLayout.schema'

export const cms = createCMS({
  schema: {
    Page,
    SiteLayout,
    Blog,
    Post
  },
  workspaces: {
    main: Config.workspace('Main', {
      source: 'content',
      mediaDir: 'public/media',
      roots: {
        pages: Config.root('Pages', {
          contains: ['Page', 'Blog'],
          children: {
            blog: Config.page({
              type: Blog,
              fields: {title: 'Blog', intro: 'Latest posts'}
            })
          }
        })
        // The settings and media roots stay the same as in step 3
      }
    })
  }
  // baseUrl, handlerUrl, adminPath and preview stay the same as in step 3
})

The catch-all route

The optional catch-all segment [[...slug]] also matches /, with no segments. The route joins the segments into a URL, looks up the entry with cms.first({url}) and branches on its _type, so each page type keeps rendering in its own component.

  • generateStaticParams lists the URL of every entry in the Pages root, so all pages are prerendered.

  • generateMetadata is shared by all types. Every document has the same metadata field, so Page.metadata selects it for blogs and posts too. Split it per type when types need different fallbacks, as the blog did in step 4.

app/[[...slug]]/page.tsx
import {Query} from 'alinea'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import {cms} from '@/cms'
import {BlogView} from '@/entries/blog/Blog'
import {PageView} from '@/entries/page/Page'
import {Page} from '@/entries/page/Page.schema'
import {PostView} from '@/entries/post/Post'

interface RouteProps {
  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 === '/' ? [] : url.slice(1).split('/')}))
}

export async function generateMetadata({
  params
}: RouteProps): Promise<Metadata> {
  const {slug = []} = await params
  const url = slug.length > 0 ? `/${slug.join('/')}` : '/'
  const page = await cms.first({
    url,
    include: {
      title: Query.title,
      metadata: Page.metadata // Every document type has the same metadata field, so Page.metadata works for all of them
    }
  })
  if (!page) return {}

  return {
    title: page.metadata?.title || page.title,
    description: page.metadata?.description,
    openGraph: {
      title:
        page.metadata?.openGraph?.title || page.metadata?.title || page.title,
      description:
        page.metadata?.openGraph?.description || page.metadata?.description,
      images: page.metadata?.openGraph?.image
        ? [page.metadata.openGraph.image.src]
        : undefined
    }
  }
}

export default async function CatchAllPage({params}: RouteProps) {
  const {slug = []} = await params
  const url = slug.length > 0 ? `/${slug.join('/')}` : '/'
  const page = await cms.first({url})

  if (!page) notFound()

  if (page._type === 'Blog') {
    return <BlogView />
  }

  if (page._type === 'Post') {
    const postSlug = slug[slug.length - 1]
    if (!postSlug) notFound()
    return <PostView slug={postSlug} />
  }

  const regularPage = await cms.first({url, type: Page})
  if (!regularPage) notFound()
  return <PageView page={regularPage} />
}