Step 4: Blog

    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.

    step4_astep4_b
    project structure
    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.tsx

    Start by defining the blog overview entry schema.

    entries/blog/Blog.schema.tsx
    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.

    entries/blog/Blog.tsx
    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.

    entries/post/Post.schema.tsx
    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.

    entries/post/Post.tsx
    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.

    cms.tsx
    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.

    app/blog/page.tsx
    import {BlogView} from '@/entries/blog/Blog'
    
    export {generateMetadata} from '@/entries/blog/Blog'
    
    export default function BlogRoute() {
      return <BlogView />
    }
    app/blog/[slug]/page.tsx
    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.