In this step we keep the landing page, blocks, and shared settings root from step 3, then add a dedicated blog section. The blog overview is fixed to /blog and contains nested post pages with editable slugs.


app/
├ page.tsx
├ layout.tsx
├ blog/page.tsx
╰ blog/[slug]/page.tsx
entries/
├ landing/
│ ├ LandingPage.tsx
│ ╰ LandingPage.schema.tsx
├ blog/
│ ├ Blog.tsx
│ ╰ Blog.schema.tsx
├ post/
│ ├ Post.tsx
│ ╰ Post.schema.tsx
╰ settings/
├ SiteLayout.tsx
╰ SiteLayout.schema.tsx
blocks/
╰ ...
cms.tsxStart by defining the blog overview entry schema.
import {Config, Field} from 'alinea'
export const Blog = Config.document('Blog page', {
contains: ['Post'],
fields: {
title: Field.text('Title', {required: true, width: 0.5}),
path: Field.path('Path', {
readOnly: true,
initialValue: 'blog',
width: 0.5
}),
intro: Field.text('Intro', {multiline: true})
}
})Then implement the blog overview server component. It fetches the blog entry and its direct post children in one query, and exposes generateMetadata.
import {Query} from 'alinea'
import {Entry} from 'alinea/core/Entry'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import Link from 'next/link'
import {cms} from '@/cms'
import {Post} from '@/entries/post/Post.schema'
import {Blog} from './Blog.schema'
type PostLink = {id: string; title: string; url: string}
export async function BlogView() {
const page = await cms.get({
url: '/blog',
type: Blog,
select: {
title: Blog.title,
intro: Blog.intro,
posts: Query.children({
type: Post,
select: {
id: Entry.id,
title: Entry.title,
url: Entry.url
}
})
}
})
if (!page) notFound()
return (
<main>
<h1>{page.title}</h1>
{page.intro && <p>{page.intro}</p>}
<ul>
{page.posts.map((post: PostLink) => (
<li key={post.id}>
<Link href={post.url}>{post.title}</Link>
</li>
))}
</ul>
</main>
)
}
export async function generateMetadata(): Promise<Metadata> {
const page = await cms.get({url: '/blog', type: Blog})
if (!page) return {}
return {
title: page.metadata.title || page.title,
description: page.metadata?.description || page.intro,
openGraph: {
title: page.metadata.openGraph.title || page.metadata.title || page.title,
description:
page.metadata.openGraph.description ||
page.metadata?.description ||
page.intro,
images: page.metadata?.openGraph.image
? [page.metadata?.openGraph.image.src]
: undefined
}
}
}Define the post entry schema in its own folder.
import {Config, Field} from 'alinea'
export const Post = Config.document('Post page', {
fields: {
title: Field.text('Title', {required: true, width: 0.5}),
path: Field.path('Path', {required: true, width: 0.5}),
excerpt: Field.text('Excerpt', {multiline: true}),
body: Field.richText('Body')
}
})Implement the post detail server component with previous/next sibling navigation and a post metadata helper.
import {Entry} from 'alinea/core/Entry'
import type {TextDoc} from 'alinea'
import {Node} from 'alinea/core/TextDoc'
import {RichText} from 'alinea/ui'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import Link from 'next/link'
import {cms} from '@/cms'
import {Post} from './Post.schema'
type PostLink = {id: string; title: string; url: string; path: string}
export async function PostView({slug}: {slug: string}) {
const post = await cms.get({url: `/blog/${slug}`, type: Post})
if (!post) notFound()
const blogPage = await cms.get({url: '/blog'})
if (!blogPage) notFound()
const siblings = await cms.find({
parentId: blogPage._id,
select: {
id: Entry.id,
title: Entry.title,
url: Entry.url,
path: Entry.path
}
})
const index = siblings.findIndex(candidate => candidate.path === slug)
const previousPost: PostLink | null = index > 0 ? siblings[index - 1] : null
const nextPost: PostLink | null =
index >= 0 && index < siblings.length - 1 ? siblings[index + 1] : null
return (
<article>
<h1>{post.title}</h1>
{typeof post.body === 'string' ? <p>{post.body}</p> : <RichText doc={post.body} />}
<p>
<Link href="/blog">← Back to the full blog archive</Link>
</p>
{(previousPost || nextPost) && (
<nav aria-label="Post navigation">
<h2>Next/Previous blogpost</h2>
<ul>
{previousPost && (
<li>
<Link href={previousPost.url}>
Previous: {previousPost.title}
</Link>
</li>
)}
{nextPost && (
<li>
<Link href={nextPost.url}>Next: {nextPost.title}</Link>
</li>
)}
</ul>
</nav>
)}
</article>
)
}
export async function generatePostMetadata(slug: string): Promise<Metadata> {
const post = await cms.get({url: `/blog/${slug}`, type: Post})
if (!post) return {}
const bodyText = plainText(post.body)
return {
title: post.metadata.title || post.title,
description: post.metadata?.description || post.excerpt || bodyText,
openGraph: {
title: post.metadata.openGraph.title || post.metadata.title || post.title,
description:
post.metadata.openGraph.description ||
post.metadata?.description ||
post.excerpt ||
bodyText,
images: post.metadata?.openGraph.image
? [post.metadata?.openGraph.image.src]
: undefined
}
}
}
function plainText(value: TextDoc<any> | string | undefined): string {
if (!value) return ''
if (typeof value === 'string') return value
if (!Array.isArray(value)) return ''
const result = value
.reduce((acc, node) => {
return acc + textOf(node)
}, '')
.trim()
return result.replace(/ +(?= )/g, '')
}
function textOf(node: Node): string {
if (node._type === 'hardBreak') return '\n'
if (Node.isText(node)) {
return node.text ? ' ' + node.text : ''
} else if (Node.isElement(node) && node.content) {
return node.content.reduce((acc, node) => {
return acc + textOf(node)
}, '')
}
return ''
}Register Blog and Post in cms.tsx and seed the fixed /blog overview page under the Pages root.
import {Blog} from '@/entries/blog/Blog.schema'
import {LandingPage} from '@/entries/landing/LandingPage.schema'
import {Post} from '@/entries/post/Post.schema'
import {SiteLayout} from '@/entries/settings/SiteLayout.schema'
export const cms = createCMS({
schema: {
LandingPage,
SiteLayout,
Blog,
Post
},
workspaces: {
main: Config.workspace('Main', {
roots: {
pages: Config.root('Pages', {
contains: ['LandingPage', 'Blog'],
children: {
index: Config.page({
type: LandingPage,
fields: {
title: 'Welcome',
path: ''
}
}),
blog: Config.page({
type: Blog,
fields: {
title: 'Blog',
path: 'blog',
intro: 'Latest posts'
}
})
}
})
}
})
}
})Add dedicated Next.js routes for /blog and /blog/[slug]. Keep these routes thin and delegate rendering + metadata to entry components.
import {BlogView} from '@/entries/blog/Blog'
export {generateMetadata} from '@/entries/blog/Blog'
export default function BlogRoute() {
return <BlogView />
}import {Entry} from 'alinea/core/Entry'
import type {Metadata} from 'next'
import {cms} from '@/cms'
import {generatePostMetadata, PostView} from '@/entries/post/Post'
import {Post} from '@/entries/post/Post.schema'
interface PostRouteProps {
params: Promise<{slug: string}>
}
export async function generateStaticParams() {
const paths = await cms.find({
type: Post,
select: Entry.path
})
return paths.map(slug => ({slug}))
}
export async function generateMetadata({
params
}: PostRouteProps): Promise<Metadata> {
const {slug} = await params
return generatePostMetadata(slug)
}
export default async function BlogPostRoute({params}: PostRouteProps) {
const {slug} = await params
return <PostView slug={slug} />
}Upon completion of these steps, your website should be extended with these features:
The landing page from step 3 keeps working with text, image, and weather blocks.
A fixed /blog overview page is seeded and available right away.
Individual blog post pages support previous/next navigation between sibling posts.
Metadata generation is implemented for the blog listing and post detail routes.