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 settings type gets its own folder under entries/, with a schema file and the components that render it.
app/
├ (alinea)/api/cms/route.ts
├ page.tsx
├ layout.tsx
╰ globals.css
entries/
├ landing/
│ ╰ ...
╰ settings/
├ SiteLayout.tsx
╰ SiteLayout.schema.tsx
blocks/
╰ ...
cms.tsxDefine 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.
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.
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.
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.
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>
)
}