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 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.
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.tsxA 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.
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.
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.
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.
generateStaticParamslists the URL of every entry in the Pages root, so all pages are prerendered.generateMetadatais shared by all types. Every document has the samemetadatafield, soPage.metadataselects it for blogs and posts too. Split it per type when types need different fallbacks, as the blog did in step 4.
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} />
}