Skip to content

Step 3: Add a root for shared layout content

Content that appears on every page, such as the header and footer, doesn't belong to one page. In this step we add a separate Settings root with one entry for that shared layout content, and render it from the root layout.

A root like this is also a good home for other content that isn't a page:

  • Lists of tags or categories

  • Shared entries such as authors, referenced from pages with an entry field

  • Labels and short texts used across the site

The Global settings entry in the Settings root, with the header and footer textThe Global settings entry in the Settings root, with the header and footer text

The settings type gets its own folder under entries/, with a schema file and the components that render it.

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

entries/
├ landing/
│ ╰ ...
╰ settings/
  ├ SiteLayout.tsx
  ╰ SiteLayout.schema.tsx

blocks/
╰ ...

cms.tsx

Define the settings type

This entry has no page of its own, so it uses Config.type instead of Config.document: no SEO tab. The dashboard lists entries by their title and stores them under their path, so we add both fields ourselves and make the path read-only. preview: false hides the live preview for this type, since there is no page to show.

entries/settings/SiteLayout.schema.tsx
import {Config, Field} from 'alinea'

export const SiteLayout = Config.type('Site layout', {
  preview: false,
  fields: {
    title: Field.text('Entry title', {
      initialValue: 'Global settings',
      width: 0.5
    }),
    path: Field.path('Path', {
      readOnly: true,
      initialValue: 'settings',
      width: 0.5
    }),
    headerText: Field.text('Header text', {required: true}),
    footerText: Field.text('Footer text', {required: true})
  }
})

The header and footer take the settings entry as a prop. Infer.Entry<typeof SiteLayout> is the type of a queried entry of this type.

entries/settings/SiteLayout.tsx
import type {Infer} from 'alinea'
import type {SiteLayout as SiteLayoutEntry} from './SiteLayout.schema'

type SiteLayoutProps = Infer.Entry<typeof SiteLayoutEntry>

export function SiteHeader({settings}: {settings: SiteLayoutProps}) {
  return <header>{settings.headerText}</header>
}

export function SiteFooter({settings}: {settings: SiteLayoutProps}) {
  return <footer>{settings.footerText}</footer>
}

Add the Settings root

Add a settings root next to pages. It only accepts SiteLayout entries, and the seeded settings page makes sure the one entry the layout needs always exists. The icon option takes any React component that renders an SVG and shows it next to the root in the dashboard.

cms.tsx
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'
import type {SVGProps} from 'react'
import {LandingPage} from '@/entries/landing/LandingPage.schema'
import {SiteLayout} from '@/entries/settings/SiteLayout.schema'

export const cms = createCMS({
  schema: {
    LandingPage,
    SiteLayout
  },
  workspaces: {
    main: Config.workspace('Main', {
      source: 'content',
      mediaDir: 'public/media',
      roots: {
        pages: Config.root('Pages', {
          contains: ['LandingPage'],
          children: {
            index: Config.page({
              type: LandingPage,
              fields: {
                title: 'Welcome'
              }
            })
          }
        }),
        settings: Config.root('Settings', {
          icon: MaterialSymbolsSettingsOutline,
          contains: ['SiteLayout'],
          children: {
            settings: Config.page({
              type: SiteLayout,
              fields: {
                title: 'Global settings',
                headerText: 'My website',
                footerText: 'Copyright 2026'
              }
            })
          }
        }),
        media: Config.media()
      }
    })
  },
  baseUrl: {
    development: 'http://localhost:3000',
    production:
      process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL ??
      'http://localhost:3000'
  },
  handlerUrl: '/api/cms',
  adminPath: '/admin',
  preview: true
})

// Probably best to place this in a separate file, but for the sake of simplicity we'll keep it here
export function MaterialSymbolsSettingsOutline(props: SVGProps<SVGSVGElement>) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="1em"
      height="1em"
      viewBox="0 0 24 24"
      {...props}
    >
      {/* Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE */}
      <path
        fill="currentColor"
        d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zM11 20h1.975l.35-2.65q.775-.2 1.438-.587t1.212-.938l2.475 1.025l.975-1.7l-2.15-1.625q.125-.35.175-.737T17.5 12t-.05-.787t-.175-.738l2.15-1.625l-.975-1.7l-2.475 1.05q-.55-.575-1.212-.962t-1.438-.588L13 4h-1.975l-.35 2.65q-.775.2-1.437.588t-1.213.937L5.55 7.15l-.975 1.7l2.15 1.6q-.125.375-.175.75t-.05.8q0 .4.05.775t.175.75l-2.15 1.625l.975 1.7l2.475-1.05q.55.575 1.213.963t1.437.587zm1.05-4.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5M12 12"
      />
    </svg>
  )
}

Render it in the layout

Query the settings in the root layout so the header and footer wrap every page. cms.workspaces.main.settings refers to the root, and cms.get returns the one entry of this type in it. Unlike cms.first it throws when nothing matches, which is what you want for content the site can't render without.

app/layout.tsx
import {cms} from '@/cms'
import {SiteFooter, SiteHeader} from '@/entries/settings/SiteLayout'
import {SiteLayout} from '@/entries/settings/SiteLayout.schema'
import './globals.css'

export default async function RootLayout({
  children
}: {
  children: React.ReactNode
}) {
  const settings = await cms.get({
    root: cms.workspaces.main.settings,
    type: SiteLayout
  })

  return (
    <html lang="en">
      <body>
        <SiteHeader settings={settings} />
        {children}
        <SiteFooter settings={settings} />
        <cms.previews widget />
      </body>
    </html>
  )
}