Step 4: Adding a blog
Add a blog: an overview page at /blog that lists its posts, and a page per post at /blog/<slug> with links to the previous and next post. Posts are child entries of the blog overview, so editors create them in the content tree under Blog, and their URL follows from that: a post with path hello-world is at /blog/hello-world.




app/
├ (alinea)/api/cms/route.ts
├ page.tsx
├ layout.tsx
├ globals.css
├ 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.tsxThe blog overview
The Blog type is a document with a fixed, read-only path. contains: ['Post'] allows posts as its children, and only posts.
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})
}
})The overview fetches the blog entry and its posts in one query. select picks the fields to return, and Query.children adds a subquery for the direct children of the entry, here filtered to posts. Children come back in the order editors arranged them in the content tree.
import {Query} from 'alinea'
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.first({
url: '/blog',
type: Blog,
select: {
title: Blog.title,
intro: Blog.intro,
posts: Query.children({
type: Post,
select: {
id: Query.id,
title: Query.title,
url: Query.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.first({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
}
}
}Blog posts
Posts have an editable path, which is the slug in the URL. The dashboard fills it in from the title as you type.
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')
}
})The post view finds the post by URL, then loads the posts under the blog to link to the previous and next one. cms.find returns an array, in content tree order. The metadata helper falls back from the SEO tab to the excerpt and then to the text of the body.
import {Query} from 'alinea'
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.first({url: `/blog/${slug}`, type: Post})
if (!post) notFound()
const blogPage = await cms.first({url: '/blog'})
if (!blogPage) notFound()
const siblings = await cms.find({
parentId: blogPage._id,
select: {
id: Query.id,
title: Query.title,
url: Query.url,
path: Query.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.first({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 ''
}Loading all posts is fine for a small blog. For a long list you can select the neighbors in the same query as the post instead: Query.previous and Query.next return the sibling before and after the entry in content tree order, or null.
const post = await cms.first({
url: `/blog/${slug}`,
type: Post,
include: {
previous: Query.previous({select: {title: Query.title, url: Query.url}}),
next: Query.next({select: {title: Query.title, url: Query.url}})
}
})Register the types
Add both types to the schema. The Pages root accepts Blog, but not Post: posts can only be created under the blog. The blog overview is seeded, so /blog exists right away.
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'
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', {
source: 'content',
mediaDir: 'public/media',
roots: {
pages: Config.root('Pages', {
contains: ['LandingPage', 'Blog'],
children: {
index: Config.page({
type: LandingPage,
fields: {
title: 'Welcome'
}
}),
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
})Add the routes
Add routes for /blog and /blog/[slug]. They stay thin and pass the slug to the entry components. generateStaticParams selects only the path of every post, so Next.js prerenders all posts at build time.
import {BlogView} from '@/entries/blog/Blog'
export {generateMetadata} from '@/entries/blog/Blog'
export default function BlogRoute() {
return <BlogView />
}import {Query} from 'alinea'
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: Query.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} />
}