# Alinea CMS Docs
### Get started (https://v2.alineacms.com/docs/get-started)
Install Alinea in a Next.js project, build a first site with the tutorial, or upgrade an existing 1.x project.
### Introduction (https://v2.alineacms.com/docs)
Alinea is an open source headless CMS written in Typescript. It stores content in flat files in your repository so they can be checked into Git. This means you can roll back to a previous version, compare changes, and track who made changes. Content is bundled with deploys so it can be retrieved without network roundtrips.
Image: dashboard (https://v2.alineacms.com/admin/file/dashboard.png)
### Quickstart (https://v2.alineacms.com/docs/quickstart)
Add Alinea to a Next.js project in a few steps. Prefer to let your coding agent do the work? Paste this prompt into Claude Code, Cursor or Codex and it follows the [AI-assisted setup guide](https://v2.alineacms.com/docs/ai-setup).
> Read https://v2.alineacms.com/start.md and help me add Alinea to my Next.js project
## 1. Set up Next.js
Create a new Next.js project in a directory of your choice. To add Alinea to an existing project, skip to step 2. Alinea's Next.js integration uses the App Router and requires Node.js 24 or higher and React 19.
```shellscript
npx create-next-app@latest
```
Note (info): Read the full instructions in the [Next.js docs](https://nextjs.org/docs/getting-started/installation)
## 2. Install Alinea
Navigate to the newly created project directory and install the package with your preferred package manager.
Variant: npm
```shellscript
npm install alinea@preview
```
Variant: yarn
```shellscript
yarn add alinea@preview
```
Variant: pnpm
```shellscript
pnpm add alinea@preview
```
Variant: bun
```shellscript
bun add alinea@preview
```
## 3. Initialize the project
Alinea requires a [config file](https://v2.alineacms.com/docs/configuration) which can be auto-generated by running `alinea init`. If you prefer plain JavaScript over TypeScript, rename the created file from `cms.ts` to `cms.js`.
Variant: npm
```shellscript
npx alinea@preview init
```
Variant: yarn
```shellscript
yarn alinea init
```
Variant: pnpm
```shellscript
pnpm alinea init
```
Variant: bun
```shellscript
bun alinea init
```
In a Next.js project `alinea init` sets up everything the dashboard needs:
- `cms.ts` (in `src/` if your project has a `src` directory) holds your schema, workspaces and settings such as `baseUrl`, `handlerUrl: '/api/cms'` and `adminPath: '/admin'`. The production `baseUrl` is read from `NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL`, which Vercel sets during the build. Replace it with the URL of your site when you deploy elsewhere.
- `app/(alinea)/api/cms/route.ts` is the API route the dashboard talks to. It passes your `cms` to `createHandler` from `alinea/next`. It imports `@/cms`, so check that the `@/*` path alias in `tsconfig.json` points to the folder that holds `cms.ts`.
- `content/pages/welcome.json` is a first entry. `content/media` holds the entries of the media library, the uploaded files themselves are written to `public/media` (the workspace's `mediaDir`).
- The `dev` and `build` scripts in `package.json` are rewritten to run through Alinea: `next dev` becomes `alinea dev -- next dev` and `next build` becomes `alinea build -- next build`.
- `/public/admin.html` and `/public/admin/` are added to `.gitignore`: `alinea build` writes the dashboard there.
## 4. Adjust Next.js config
Wrap your Next.js config with `withAlinea`. It serves the dashboard on your site at `adminPath`, forwards media file requests, and keeps the generated content package out of the server bundle:
Variant: next.config.ts
```tsx
import {withAlinea} from 'alinea/next'
import type {NextConfig} from 'next'
const nextConfig: NextConfig = {
// Your Next.js options
}
export default withAlinea(nextConfig)
```
Variant: next.config.js
```tsx
const {withAlinea} = require('alinea/next')
const nextConfig = {
// Your Next.js options
}
module.exports = withAlinea(nextConfig)
```
## 5. Start the dev server
Congratulations, Alinea is now ready to boot! Start your dev script:
Variant: npm
```shellscript
npm run dev
```
Variant: yarn
```shellscript
yarn dev
```
Variant: pnpm
```shellscript
pnpm dev
```
Variant: bun
```shellscript
bun run dev
```
The script runs `alinea dev -- next dev`: Alinea compiles `cms.ts`, starts its local server and then starts `next dev` with the settings `withAlinea` needs. Open the dashboard at [http://localhost:3000/admin](http://localhost:3000/admin) and have a look around.
Note (info): Running `npx alinea dev` on its own only starts the local dashboard on http://localhost:4500, not your site. Keep the `alinea dev -- next dev` form in your dev script so both run together and the dashboard is served from your Next.js app. The same goes for `alinea build -- next build`, which generates your content before the site is built.
## 6. Show content on a page
The generated `Page` type only has a title. Add an image and a body to it in `cms.ts`, and export it so your pages can query it:
File: cms.ts
```tsx
import {Config, Field} from 'alinea'
// Export the type so your pages can query it
export const Page = Config.document('Page', {
fields: {
cover: Field.image('Cover image'),
body: Field.richText('Body')
}
})
```
Open the Welcome page in the dashboard, pick a cover image (upload one in the picker), write some text and publish. Then render it on your homepage by replacing `app/page.tsx`:
File: app/page.tsx
```tsx
import {cms, Page} from '@/cms'
import {RichText} from 'alinea/ui'
import Image from 'next/image'
import {notFound} from 'next/navigation'
export default async function Home() {
const page = await cms.first({type: Page, path: 'welcome'})
if (!page) notFound()
return (
{page.title}
{page.cover && (
)}
)
}
```
`cms.first` finds the entry stored in `content/pages/welcome.json` by its path, and passing `type: Page` types the result. An image field returns the `src`, dimensions and alt text `next/image` needs, and `withAlinea` allows these urls in your image config. See the [Image field](https://v2.alineacms.com/docs/fields/image) for blur placeholders and focal points, and the [Rich text field](https://v2.alineacms.com/docs/fields/rich-text) for rendering rich text with your own components.
## 7. Enable live previews
To see the page you are editing next to the editor, set `preview: true` in `cms.ts` (`alinea init` leaves it commented out) and render the preview component in your root layout:
File: app/layout.tsx
```tsx
import {cms} from '@/cms'
export default function RootLayout({children}: {children: React.ReactNode}) {
return (
{children}
)
}
```
The `widget` prop adds a small toolbar to your pages to switch to the dashboard, read more in [Live previews](https://v2.alineacms.com/docs/live-previews).
## Next steps
- Define your content types in the [schema](https://v2.alineacms.com/docs/schema) and pick [fields](https://v2.alineacms.com/docs/fields).
- Render content in your pages with [queries](https://v2.alineacms.com/docs/query).
- Follow the [tutorial](https://v2.alineacms.com/docs/tutorial) to build a complete site.
- [Deploy](https://v2.alineacms.com/docs/deploy) your site with a backend for publishing.
### Set up with an AI agent (https://v2.alineacms.com/docs/ai-setup)
Let a coding agent such as Claude Code, Cursor or Codex add Alinea to your Next.js project. Paste the prompt below into your agent: it reads this guide as Markdown from [/start.md](https://v2.alineacms.com/start.md), sets up Alinea step by step, then helps you model your content and create the first entries.
> Read https://v2.alineacms.com/start.md and help me add Alinea to my Next.js project
The rest of this page is written for the agent. You are adding Alinea, a git-based headless CMS, to the user's Next.js project. Content is stored as JSON files in the repository and the schema is TypeScript in `cms.ts`. Follow the steps in order, run the commands yourself, check each result before moving on and ask the user whenever a step needs a decision. The complete documentation is available as one file at [/llms-full.txt](https://v2.alineacms.com/llms-full.txt), fetch it when you need an API that is not covered here. Once Alinea is installed, `node_modules/alinea/docs/` holds the docs of the installed version as a Markdown file per page, start at `index.md`.
## 1. Check the project
- Confirm this is a Next.js project that uses the App Router: `next` is listed in the `dependencies` of `package.json` and there is an `app/` or `src/app/` directory. Alinea's Next.js integration needs the App Router. If there is no project yet, ask the user whether to create one with `npx create-next-app@latest`.
- Detect the package manager from the lockfile: `package-lock.json` (npm), `pnpm-lock.yaml` (pnpm), `yarn.lock` (yarn), `bun.lock` or `bun.lockb` (bun). Use it for every command in this guide. The examples use npm: replace `npx alinea` with `pnpm alinea`, `yarn alinea` or `bun alinea`, and `npm run` with `pnpm`, `yarn` or `bun run`.
- Check that `node --version` is 24 or higher and that the project uses React 19, the Alinea CLI refuses to run otherwise.
- If a `cms.ts`, `cms.tsx`, `src/cms.ts` or `src/cms.tsx` file already exists, Alinea is already set up: check steps 4 and 5, then continue at step 6.
- Suggest committing any pending changes first, so the user can review what you change.
## 2. Install Alinea
Install the package in the project root.
```shellscript
npm install alinea@preview
```
With other package managers: `pnpm add alinea@preview`, `yarn add alinea@preview` or `bun add alinea@preview`.
## 3. Run alinea init
```shellscript
npx alinea@preview init
```
In a Next.js project `alinea init` creates:
- `cms.ts`, in `src/` if the project has a `src` directory: an example schema and workspace, `baseUrl`, `handlerUrl: '/api/cms'` and `adminPath: '/admin'`.
- `app/(alinea)/api/cms/route.ts`, also under `src/` when it exists: the API route the dashboard talks to. It is only created when `next` is listed in `dependencies`.
- `content/pages/welcome.json` and `content/media/`: a first entry and the media folder.
- `.gitignore` entries for `/public/admin.html` and `/public/admin/`, where `alinea build` writes the dashboard.
- `AGENTS.md`: a short Alinea section that points coding agents to the bundled docs and the MCP server. It is added to an existing `AGENTS.md` once, or creates the file.
- It prefixes the `dev` and `build` scripts in `package.json` with `alinea dev --` and `alinea build --`.
Read the generated files. The route imports `@/cms`: check that `tsconfig.json` maps `@/*` to the folder that holds `cms.ts` (create-next-app sets this up), otherwise fix the import.
## 4. Wire up Next.js
Wrap the existing Next.js config with `withAlinea` and keep all of its options. It serves the dashboard at `/admin` and keeps the generated content package out of the server bundle.
File: next.config.ts
```tsx
import {withAlinea} from 'alinea/next'
import type {NextConfig} from 'next'
const nextConfig: NextConfig = {
// The existing options
}
export default withAlinea(nextConfig)
```
In a CommonJS `next.config.js` use `const {withAlinea} = require('alinea/next')` and `module.exports = withAlinea(nextConfig)`.
Check that the API route exists, and create it if `alinea init` did not:
File: app/(alinea)/api/cms/route.ts
```tsx
import {cms} from '@/cms'
import {createHandler} from 'alinea/next'
// This handler responds to the API requests of the dashboard
const handler = createHandler({cms})
export const GET = handler
export const POST = handler
```
Check the scripts in `package.json`. Keep any flags the user had after `next dev` or `next build`.
```json
{
"scripts": {
"dev": "alinea dev -- next dev",
"build": "alinea build -- next build"
}
}
```
`alinea build` writes the dashboard to `public/admin.html` and `public/admin/`. Check that `.gitignore` lists `/public/admin.html` and `/public/admin/` (`alinea init` adds them).
## 5. Enable live previews
Set `preview: true` in the `createCMS` options in `cms.ts` (`alinea init` leaves it commented out). Then render the preview component in the root layout, add only this line to the existing `app/layout.tsx`:
File: app/layout.tsx
```tsx
import {cms} from '@/cms'
export default function RootLayout({children}: {children: React.ReactNode}) {
return (
{children}
)
}
```
The dashboard now shows the page next to the editor and updates it while the user types. Remove the `widget` prop to hide the small preview toolbar on the page. `baseUrl.production` in `cms.ts` is read from `NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL`, which Vercel sets during the build. Ask the user where the site is deployed: if it is not on Vercel, ask for its production URL and set it as `baseUrl.production`.
## 6. Start the dev server
Start the dev script in the background and keep it running for the rest of this guide.
```shellscript
npm run dev
```
`alinea dev` compiles `cms.ts`, starts the local CMS and then starts `next dev`. Read the output:
- `MCP server: http://localhost:4500/mcp` is the MCP server for the next step. The port moves to 4501 and up when 4500 is taken, always use the printed url.
- `- Alinea CMS: http://localhost:3000/admin` is the dashboard. Ask the user to open it and have a look around.
If the output shows an error in `cms.ts`, fix it before continuing. The config is recompiled whenever you save it.
## 7. Connect to the Alinea MCP server
While `alinea dev` runs it also serves a Model Context Protocol server that reads the schema and creates, edits and publishes entries through the same write path as the dashboard. It fills in internal fields such as ids, ordering keys and rich text nodes, so use it for all content changes instead of writing JSON files. It only runs during `alinea dev` and only accepts requests from localhost.
In Claude Code, add it with the url from the dev server output:
```shellscript
claude mcp add --transport http alinea http://localhost:4500/mcp
```
For other agents add an HTTP MCP server named `alinea` with the same url, for example in a project `.mcp.json` (Cursor reads `.cursor/mcp.json`):
```json
{
"mcpServers": {
"alinea": {
"type": "http",
"url": "http://localhost:4500/mcp"
}
}
}
```
Most agents only load MCP servers when a session starts. If the tools do not show up, ask the user to restart or reload the session, and to point you back to [/start.md](https://v2.alineacms.com/start.md) to continue at this step.
The tools are `describe_schema`, `find_entries`, `get_entry`, `create_entry`, `update_entry`, `publish_entry`, `delete_entry`, `move_entry` and `upload_file`. Call `describe_schema` first. The [MCP server](https://v2.alineacms.com/docs/mcp) page describes every tool, its parameters and the value formats it accepts. If the MCP server cannot be connected, edit the content JSON files following [Working with AI agents](https://v2.alineacms.com/docs/ai-agents) instead.
## 8. Model the content
Ask the user what the site needs before you write any schema: which pages and sections, which content repeats (posts, products, team members, events), what is shared across pages (navigation, footer, settings) and whether the site is translated. Propose a schema in plain words and wait for confirmation.
Then define the types in `cms.ts`, or in separate files imported there:
- `Config.document(label, {fields, contains})` for entries that have their own page. Title, path (the url slug) and SEO metadata fields are added automatically, `contains` lists the types allowed as children.
- `Config.type(label, {fields})` for blocks in lists or rich text, and for entries without a page of their own such as site settings.
- Fields such as `Field.text`, `Field.richText`, `Field.image`, `Field.link`, `Field.entry`, `Field.list`, `Field.select`, `Field.check`, `Field.date` and `Field.number`, see [Fields](https://v2.alineacms.com/docs/fields).
- Register every type in `schema` and list the types each root accepts in `contains`. Pages that must always exist, such as the homepage, can be seeded with `Config.page` in the `children` of a root.
File: cms.ts
```tsx
import {Config, Field} from 'alinea'
import {createCMS} from 'alinea/next'
export const Page = Config.document('Page', {
fields: {
intro: Field.text('Intro', {multiline: true}),
body: Field.richText('Body')
}
})
export const Blog = Config.document('Blog', {
contains: ['BlogPost'],
fields: {
intro: Field.text('Intro', {multiline: true})
}
})
export const BlogPost = Config.document('Blog post', {
fields: {
publishDate: Field.date('Publish date'),
image: Field.image('Image'),
body: Field.richText('Body')
}
})
export const cms = createCMS({
schema: {Page, Blog, BlogPost},
workspaces: {
main: Config.workspace('Main', {
source: 'content',
mediaDir: 'public/media',
roots: {
pages: Config.root('Pages', {
contains: ['Page', 'Blog']
}),
media: Config.media()
}
})
},
baseUrl: {
development: 'http://localhost:3000',
production:
process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL ??
'http://localhost:3000'
},
preview: true,
handlerUrl: '/api/cms',
adminPath: '/admin'
})
```
The example `content/pages/welcome.json` entry has type `Page`. If you remove or rename that type, delete the entry with `delete_entry`. Save `cms.ts`, check the dev server output for errors and call `describe_schema` again to confirm the new types.
## 9. Create the first entries
Create the starting content with the MCP tools: find the parent with `find_entries`, then call `create_entry` with the type, the parent and the field values. Rich text fields accept Markdown, link fields accept entry ids, and `upload_file` adds images to the media library and returns the id for image fields. Entries are published by default. Write real starting content with the user rather than placeholder text, then ask them to review it in the dashboard. Every entry is a JSON file under `content/`.
## 10. Render the content
Query content in server components with the `cms` instance: `cms.find` returns an array, `cms.first` returns the first match or null, `cms.get` returns one entry and throws when nothing matches. An entry's url is built from the paths of its parents, a post with path `hello` below a blog with path `blog` is at `/blog/hello`.
File: app/blog/page.tsx
```tsx
import {Query} from 'alinea'
import Link from 'next/link'
import {BlogPost, cms} from '@/cms'
export default async function BlogPage() {
const posts = await cms.find({
type: BlogPost,
select: {id: Query.id, title: Query.title, url: Query.url}
})
return (
{posts.map(post => (
{post.title}
))}
)
}
```
File: app/blog/[slug]/page.tsx
```tsx
import {Query} from 'alinea'
import {RichText} from 'alinea/ui'
import {notFound} from 'next/navigation'
import {BlogPost, cms} from '@/cms'
interface PostPageProps {
params: Promise<{slug: string}>
}
export default async function PostPage({params}: PostPageProps) {
const {slug} = await params
const post = await cms.first({type: BlogPost, url: `/blog/${slug}`})
if (!post) notFound()
return (
{post.title}
)
}
export async function generateStaticParams() {
const paths = await cms.find({type: BlogPost, select: Query.path})
return paths.map(slug => ({slug}))
}
```
Open the pages in the browser and in the dashboard preview. Filtering, ordering, relations and child queries are described in [Querying content](https://v2.alineacms.com/docs/query).
## 11. Verify and hand over
Run a production build and fix every error it reports.
```shellscript
npm run build
```
Then give the user a short summary: the files you changed, how to open the dashboard (`npm run dev`, then http://localhost:3000/admin) and what to do next. Publishing from a deployed site needs a backend for login, drafts and git commits, see [Deploying](https://v2.alineacms.com/docs/deploy).
Further reading:
- Docs of the installed version: `node_modules/alinea/docs/index.md`
- [Complete documentation in one file](https://v2.alineacms.com/llms-full.txt)
- [Index of all docs pages as Markdown](https://v2.alineacms.com/llms.txt)
- [Configuration](https://v2.alineacms.com/docs/configuration)
- [Schema](https://v2.alineacms.com/docs/schema) and [fields](https://v2.alineacms.com/docs/fields)
- [Querying content](https://v2.alineacms.com/docs/query)
- [Live previews](https://v2.alineacms.com/docs/live-previews)
- [MCP server](https://v2.alineacms.com/docs/mcp)
- [Working with AI agents](https://v2.alineacms.com/docs/ai-agents)
- [CLI](https://v2.alineacms.com/docs/cli)
- [Deploying](https://v2.alineacms.com/docs/deploy)
### Tutorial (https://v2.alineacms.com/docs/tutorial)
Build a small but complete Next.js website with Alinea, one feature at a time. Each step adds to the code of the previous one and ends with a list of things you can check in the browser and the dashboard. The finished code of every step is in the repository at [apps/web/tutorial-sites](https://github.com/alineacms/alinea/tree/main/apps/web/tutorial-sites).
1. [Landing page](https://v2.alineacms.com/docs/tutorial/step-1-landing-page): one fixed homepage entry with a title and SEO metadata, and live previews.
2. [Content blocks](https://v2.alineacms.com/docs/tutorial/step-2-block-list): a list of text, image and weather blocks that editors arrange freely.
3. [Shared root](https://v2.alineacms.com/docs/tutorial/step-3-layout-root): a settings root with the header and footer text used on every page.
4. [Blog](https://v2.alineacms.com/docs/tutorial/step-4-blog): a blog overview with nested posts, dedicated routes and previous/next links.
5. [Catch-all route](https://v2.alineacms.com/docs/tutorial/step-5-catch-all): one route that renders every page, so editors can build their own page tree.
## Before you start
Set up a Next.js project with Alinea by following the [Quickstart](https://v2.alineacms.com/docs/quickstart): `alinea init` gives you the API route, the `dev` and `build` scripts and a `cms.ts` file that you replace in step 1. The tutorial uses the App Router, TypeScript and Next.js 16.
## Conventions used in every step
- One folder per page type and block type. Each folder holds a schema file (`LandingPage.schema.tsx`) and a server component that renders it (`LandingPage.tsx`). Page types live in `entries/`, block types in `blocks/`.
- Components fetch their own data. Every page component queries the entry it renders and exports its own `generateMetadata`. The files in `app/` stay thin and only pass route parameters along.
- The config is `cms.tsx` in the project root, and the `@/*` path alias in `tsconfig.json` points to the root, so imports read `@/cms` and `@/entries/...`. If your project keeps its code in `src/`, put these folders there instead.
Because each component loads its own data, you can make any of them a [cached component](https://nextjs.org/docs/app/getting-started/cache-components) when it fits. Step 2 does this for a block that calls a weather API.
### Step 1: Simple landing page (https://v2.alineacms.com/docs/tutorial/step-1-landing-page)
Start with a homepage served at `/` that shows a title editors can change in the dashboard, with SEO metadata and a live preview.
Image: tutorial-step1 (https://v2.alineacms.com/admin/file/screenshots/tutorial-step1.webp)
The landing page gets its own folder with two files: `LandingPage.schema.tsx` defines the fields editors fill in, `LandingPage.tsx` renders them.
File: project structure
```
app/
├ (alinea)/api/cms/route.ts
├ page.tsx
├ layout.tsx
╰ globals.css
entries/
╰ landing/
├ LandingPage.tsx
╰ LandingPage.schema.tsx
cms.tsx
```
## Define the schema
`Config.document` creates a type for entries that have a page of their own. Every document gets a `title` and a `path` field, an SEO tab with the metadata fields and a Details tab that shows who created and last changed the entry. Here we redefine `path` to make it read-only with an empty initial value, so a landing page created in the dashboard always resolves to `/`.
File: entries/landing/LandingPage.schema.tsx
```tsx
import {Config, Field} from 'alinea'
export const LandingPage = Config.document('Landing page', {
fields: {
title: Field.text('Title', {required: true, width: 0.5}),
path: Field.path('Path', {readOnly: true, width: 0.5, initialValue: ''})
}
})
```
## Render the page
The view is an async server component that queries its own entry. `cms.first` returns the first entry that matches, or `null` when there is none, so a missing page renders the Next.js 404 page. (`cms.get` throws instead, which suits entries that must exist.) Passing `type: LandingPage` both filters on the type and types the result.
The same file exports `generateMetadata`, which reads the fields of the SEO tab and falls back to the title.
File: entries/landing/LandingPage.tsx
```tsx
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import {cms} from '@/cms'
import {LandingPage} from './LandingPage.schema'
export async function LandingPageView() {
const page = await cms.first({url: '/', type: LandingPage})
if (!page) notFound()
return (
{page.title}
)
}
export async function generateMetadata(): Promise {
const page = await cms.first({url: '/', type: LandingPage})
if (!page) return {}
return {
title: page.metadata.title || page.title,
description: page.metadata?.description,
openGraph: {
title: page.metadata.openGraph.title || page.metadata.title || page.title,
description: page.metadata.openGraph.description || page.metadata?.description,
images: page.metadata?.openGraph.image
? [page.metadata?.openGraph.image.src]
: undefined
}
}
}
```
## Register the type
Replace the generated config with this `cms.tsx`. The type goes in `schema`, and the Pages root lists it in `contains` so editors can create landing pages there.
`Config.page` seeds an entry: Alinea creates it in your content folder when it doesn't exist yet, and editors can't delete it from the dashboard. The key becomes the entry's path, and `index` is a special path that resolves to the parent's URL, `/` in this case. Seeding is optional, editors can also create the page themselves.
File: cms.tsx
```tsx
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'
import {LandingPage} from '@/entries/landing/LandingPage.schema'
export const cms = createCMS({
schema: {LandingPage},
workspaces: {
main: Config.workspace('Main', {
source: 'content',
mediaDir: 'public/media',
roots: {
pages: Config.root('Pages', {
contains: ['LandingPage'],
children: {
// Optionally seed this page, alternatively you can simply create the page from the CMS directly
index: Config.page({
type: LandingPage,
fields: {
title: 'Welcome'
}
})
}
}),
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
})
```
`baseUrl.production` is read from `NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL`, which Vercel sets during the build. Replace it with the URL of your site when you deploy elsewhere. See [Configuration](https://v2.alineacms.com/docs/configuration) for all options.
## Wire up Next.js
Wrap your Next.js config with `withAlinea` so the dashboard is served on `/admin`. The API route for `/api/cms` (`app/(alinea)/api/cms/route.ts`) was created by `alinea init`, see the [Quickstart](https://v2.alineacms.com/docs/quickstart).
File: next.config.ts
```ts
import {withAlinea} from 'alinea/next'
import type {NextConfig} from 'next'
const nextConfig: NextConfig = {}
export default withAlinea(nextConfig)
```
The route file only renders the view and re-exports its metadata:
File: app/page.tsx
```tsx
import {LandingPageView} from '@/entries/landing/LandingPage'
export {generateMetadata} from '@/entries/landing/LandingPage'
export default function Page() {
return
}
```
Render `cms.previews` in the root layout. It enables live previews in the dashboard, and the `widget` prop adds a small toolbar to your pages to switch to the dashboard.
File: app/layout.tsx
```tsx
import {cms} from '@/cms'
import './globals.css'
export default function RootLayout({children}: {children: React.ReactNode}) {
return (
{children}
)
}
```
The layout also imports `globals.css`: a few base styles so the pages, and the preview next to the editor, are easy to read. Replace them with your own styles, nothing in the tutorial depends on them.
File: app/globals.css
```css
/* A few base styles so the pages are easy to read, replace them with your own */
body {
max-width: 720px;
margin: 0 auto;
padding: 32px 24px;
font-family:
system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
font-size: 16px;
line-height: 1.6;
color: #1f2328;
background: #fff;
}
h1,
h2,
h3 {
margin: 1.5em 0 0.5em;
line-height: 1.25;
}
h1 {
margin-top: 0.5em;
font-size: 2.25rem;
}
p,
ul,
ol {
margin: 0 0 1em;
}
a {
color: #2458d3;
}
img {
max-width: 100%;
height: auto;
}
header,
footer {
padding: 16px 0;
color: #59636e;
}
header {
border-bottom: 1px solid #d1d9e0;
}
footer {
margin-top: 48px;
border-top: 1px solid #d1d9e0;
}
```
Note (info): Run `npm run dev` and check that:
- The Pages root in the dashboard at `/admin` contains one landing page entry.
- Editing the title updates the preview next to the editor while you type.
- Values you enter in the SEO tab show up in the page's `` and meta tags.
### Step 2: Add a list of blocks (https://v2.alineacms.com/docs/tutorial/step-2-block-list)
Most pages are built from a stack of blocks: a text section, an image, a call to action. In this step editors get a list of blocks on the landing page that they can add, reorder and remove: text blocks, image blocks and a weather block that loads data from an external API. The [list field](https://v2.alineacms.com/docs/fields/list) models this: each item in the list has one of the types you allow.
Image: tutorial-step2 (https://v2.alineacms.com/admin/file/screenshots/tutorial-step2.webp)
Just like page types, every block type gets a folder with a schema file and a view component.
File: project structure
```
app/
├ (alinea)/api/cms/route.ts
├ page.tsx
├ layout.tsx
╰ globals.css
entries/
╰ landing/
├ LandingPage.tsx
╰ LandingPage.schema.tsx
blocks/
├ text/
│ ├ TextBlock.tsx
│ ╰ TextBlock.schema.tsx
├ image/
│ ├ ImageBlock.tsx
│ ╰ ImageBlock.schema.tsx
╰ weather/
├ WeatherBlock.tsx
╰ WeatherBlock.schema.tsx
cms.tsx
```
## Text block
Block types are defined with `Config.type`, not `Config.document`: they don't have a URL, title or metadata of their own. The text block has one [rich text field](https://v2.alineacms.com/docs/fields/rich-text). `inline: true` shows a minimal editor without the field label, which keeps a block with a single field compact.
File: blocks/text/TextBlock.schema.tsx
```tsx
import {Config, Field} from 'alinea'
export const TextBlock = Config.type('Text block', {
fields: {
body: Field.richText('Text', {inline: true})
}
})
```
The `RichText` component from `alinea/ui` renders rich text as React elements, without `dangerouslySetInnerHTML`. Pass a component or element per tag to change how it renders: here every `a` becomes a Next.js `Link`, so internal links navigate client-side. `Infer.ListItem` gives the type of one list item of this block type, including `_id` and `_type`.
File: blocks/text/TextBlock.tsx
```tsx
import type {Infer} from 'alinea'
import {RichText} from 'alinea/ui'
import NextLink from 'next/link'
import type {TextBlock} from './TextBlock.schema'
type TextBlockData = Infer.ListItem
function Link({href, ...props}: {href?: string; [key: string]: any}) {
if (!href) return
return
}
export function TextBlockView({block}: {block: TextBlockData}) {
return
}
```
## Image block
The image block has an image field and an alt text.
File: blocks/image/ImageBlock.schema.tsx
```tsx
import {Config, Field} from 'alinea'
export const ImageBlock = Config.type('Image block', {
fields: {
image: Field.image('Image', {required: true, width: 0.5}),
alt: Field.text('Alt text', {width: 0.5})
}
})
```
An image field returns an object with `src`, `width` and `height` (among others), or `undefined` when no image is picked. `withAlinea` allows the image URLs in `images.localPatterns`, so they work with the [Next.js Image component](https://nextjs.org/docs/app/api-reference/components/image), which resizes and optimizes them.
File: blocks/image/ImageBlock.tsx
```tsx
import type {Infer} from 'alinea'
import Image from 'next/image'
import type {ImageBlock} from './ImageBlock.schema'
type ImageBlockData = Infer.ListItem
export function ImageBlockView({block}: {block: ImageBlockData}) {
if (!block.image) return null
const {src, width, height} = block.image
return (
)
}
```
## Weather block
Blocks can load their own data too. Editors enter a region, and the block shows the current weather there. The `help` option adds a description below the field.
File: blocks/weather/WeatherBlock.schema.tsx
```tsx
import {Config, Field} from 'alinea'
export const WeatherBlock = Config.type('Weather block', {
fields: {
title: Field.text('Title', {required: true, width: 0.5}),
region: Field.text('Region', {
required: true,
width: 0.5,
help: 'City or region name, for example: Brussels or New York'
})
}
})
```
The view is an async server component that fetches the weather from [Open-Meteo](https://open-meteo.com/). The `'use cache'` directive caches `getCurrentWeather` per region, and `cacheLife` keeps each result for 15 minutes, so a page with this block doesn't call the API on every request.
File: blocks/weather/WeatherBlock.tsx
```tsx
import type {Infer} from 'alinea'
import {cacheLife} from 'next/cache'
import type {WeatherBlock} from './WeatherBlock.schema'
type WeatherBlockData = Infer.ListItem
type GeocodingResponse = {
results?: Array<{
name: string
country?: string
latitude: number
longitude: number
}>
}
type ForecastResponse = {
current?: {
temperature_2m?: number
weather_code?: number
}
current_units?: {
temperature_2m?: string
}
}
const weatherCodeLabels: Record = {
0: 'Clear sky',
1: 'Mainly clear',
2: 'Partly cloudy',
3: 'Overcast',
45: 'Fog',
48: 'Depositing rime fog',
51: 'Light drizzle',
53: 'Moderate drizzle',
55: 'Dense drizzle',
61: 'Slight rain',
63: 'Moderate rain',
65: 'Heavy rain',
71: 'Slight snowfall',
73: 'Moderate snowfall',
75: 'Heavy snowfall',
80: 'Rain showers',
81: 'Rain showers',
82: 'Violent rain showers',
95: 'Thunderstorm'
}
async function getCurrentWeather(region: string) {
'use cache'
cacheLife({stale: 900, revalidate: 900, expire: 900})
const geocoding = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(region)}&count=1`
)
if (!geocoding.ok) return null
const geocodingData = (await geocoding.json()) as GeocodingResponse
const result = geocodingData.results?.[0]
if (!result) return null
const forecast = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${result.latitude}&longitude=${result.longitude}¤t=temperature_2m,weather_code&timezone=auto`
)
if (!forecast.ok) return null
const forecastData = (await forecast.json()) as ForecastResponse
if (!forecastData.current) return null
return {
location: result.country
? `${result.name}, ${result.country}`
: result.name,
temperature: forecastData.current.temperature_2m,
unit: forecastData.current_units?.temperature_2m ?? '°C',
summary:
weatherCodeLabels[forecastData.current.weather_code ?? -1] ??
'Current weather'
}
}
export async function WeatherBlockView({block}: {block: WeatherBlockData}) {
const weather = await getCurrentWeather(block.region)
return (
)}
)
}
```
`'use cache'` and `cacheLife` are part of Cache Components, which Next.js doesn't enable by default. Turn it on in `next.config.ts`. Next.js then prerenders the page at build time with the cached weather in it, and renders it again once the 15 minutes are up:
File: next.config.ts
```ts
import {withAlinea} from 'alinea/next'
import type {NextConfig} from 'next'
const nextConfig: NextConfig = {
cacheComponents: true
}
export default withAlinea(nextConfig)
```
## Add the list to the landing page
Add a `blocks` list field to the landing page. The keys of `schema` become the `_type` of each item.
File: entries/landing/LandingPage.schema.tsx
```tsx
import {Config, Field} from 'alinea'
import {ImageBlock} from '@/blocks/image/ImageBlock.schema'
import {TextBlock} from '@/blocks/text/TextBlock.schema'
import {WeatherBlock} from '@/blocks/weather/WeatherBlock.schema'
export const LandingPage = Config.document('Landing page', {
fields: {
title: Field.text('Title', {required: true, width: 0.5}),
path: Field.path('Path', {readOnly: true, width: 0.5, initialValue: ''}),
blocks: Field.list('Blocks', {
schema: {
TextBlock,
ImageBlock,
WeatherBlock
}
})
}
})
```
Render each block by checking its `_type`: TypeScript narrows `block` to the matching block type inside each branch. `generateMetadata` now falls back to the text of the first text block when the SEO tab has no description. `plainText` walks the rich text nodes to collect the text.
File: entries/landing/LandingPage.tsx
```tsx
import type {TextDoc} from 'alinea'
import {Node} from 'alinea/core/TextDoc'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import {ImageBlockView} from '@/blocks/image/ImageBlock'
import {TextBlockView} from '@/blocks/text/TextBlock'
import {WeatherBlockView} from '@/blocks/weather/WeatherBlock'
import {cms} from '@/cms'
import {LandingPage} from './LandingPage.schema'
export async function LandingPageView() {
const page = await cms.first({url: '/', type: LandingPage})
if (!page) notFound()
return (
{page.title}
{page.blocks.map(block => {
if (block._type === 'TextBlock') return
if (block._type === 'ImageBlock') return
if (block._type === 'WeatherBlock') return
return null
})}
)
}
export async function generateMetadata(): Promise {
const page = await cms.first({url: '/', type: LandingPage})
if (!page) return {}
let fallbackDescription = ''
for (const block of page.blocks) {
if (block._type === 'TextBlock') {
fallbackDescription = plainText(block.body)
break
}
}
return {
title: page.metadata.title || page.title,
description: page.metadata?.description || fallbackDescription,
openGraph: {
title: page.metadata.openGraph.title || page.metadata.title || page.title,
description: page.metadata.openGraph.description || page.metadata?.description,
images: page.metadata?.openGraph.image
? [page.metadata?.openGraph.image.src]
: undefined
}
}
}
export function plainText(value: TextDoc | 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 ''
}
```
Note (info): Check that:
- The landing page has a Blocks field where you can add, drag to reorder and remove text, image and weather blocks.
- Text blocks render with their formatting and links.
- A weather block shows the current weather for its region.
- Without a description in the SEO tab, the page description is the text of the first text block.
### Step 3: Add a root for shared layout content (https://v2.alineacms.com/docs/tutorial/step-3-layout-root)
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](https://v2.alineacms.com/docs/fields/entry)
- Labels and short texts used across the site
Image: tutorial-step3 (https://v2.alineacms.com/admin/file/screenshots/tutorial-step3.webp)
The settings type gets its own folder under `entries/`, with a schema file and the components that render it.
File: 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.
File: entries/settings/SiteLayout.schema.tsx
```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` is the type of a queried entry of this type.
File: entries/settings/SiteLayout.tsx
```tsx
import type {Infer} from 'alinea'
import type {SiteLayout as SiteLayoutEntry} from './SiteLayout.schema'
type SiteLayoutProps = Infer.Entry
export function SiteHeader({settings}: {settings: SiteLayoutProps}) {
return {settings.headerText}
}
export function SiteFooter({settings}: {settings: SiteLayoutProps}) {
return
}
```
## 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.
File: cms.tsx
```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) {
return (
)
}
```
## 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.
File: app/layout.tsx
```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 (
{children}
)
}
```
Note (info): Check that:
- The dashboard shows a Settings root, with its icon, that holds one Global settings entry.
- Every page shows the header and footer text from that entry.
- Changing the header text in the dashboard updates the site.
### Step 4: Adding a blog (https://v2.alineacms.com/docs/tutorial/step-4-blog)
Add a blog: an overview page at `/blog` that lists its posts, and a page per post at `/blog/` 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`.
Image: tutorial-step4-blog (https://v2.alineacms.com/admin/file/screenshots/tutorial-step4-blog.webp)
Image: tutorial-step4-post (https://v2.alineacms.com/admin/file/screenshots/tutorial-step4-post.webp)
File: project structure
```
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.tsx
```
## The blog overview
The `Blog` type is a document with a fixed, read-only path. `contains: ['Post']` allows posts as its children, and only posts.
File: entries/blog/Blog.schema.tsx
```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})
}
})
```
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.
File: entries/blog/Blog.tsx
```tsx
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 (
{page.title}
{page.intro &&
{page.intro}
}
{page.posts.map((post: PostLink) => (
{post.title}
))}
)
}
export async function generateMetadata(): Promise {
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.
File: entries/post/Post.schema.tsx
```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')
}
})
```
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.
File: entries/post/Post.tsx
```tsx
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 (
{post.title}
{typeof post.body === 'string' ?
{post.body}
: }
← Back to the full blog archive
{(previousPost || nextPost) && (
)}
)
}
export async function generatePostMetadata(slug: string): Promise {
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 | 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`.
File: entries/post/Post.tsx
```tsx
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.
File: cms.tsx
```tsx
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.
File: app/blog/page.tsx
```tsx
import {BlogView} from '@/entries/blog/Blog'
export {generateMetadata} from '@/entries/blog/Blog'
export default function BlogRoute() {
return
}
```
File: app/blog/[slug]/page.tsx
```tsx
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 {
const {slug} = await params
return generatePostMetadata(slug)
}
export default async function BlogPostRoute({params}: PostRouteProps) {
const {slug} = await params
return
}
```
Note (info): Check that:
- The Pages root contains a Blog entry, and `/blog` lists the posts you create under it.
- Dragging posts in the content tree changes their order on `/blog` and in the previous/next links.
- Each post page has its own title and description in the page metadata.
- The landing page and the shared header and footer from step 3 still work.
### Step 5: Use a catch-all slug route (https://v2.alineacms.com/docs/tutorial/step-5-catch-all)
So far every page type has its own route in `app/`. That works while the site structure is fixed, but editors can't add an About page with Team and History pages below it without a developer adding routes. In this step one catch-all route renders every entry in the Pages root, whatever its URL.
Image: tutorial-step5 (https://v2.alineacms.com/admin/file/screenshots/tutorial-step5.webp)
The Blog and Post types stay as they are. A new `Page` type takes over from the landing page, and `app/[[...slug]]/page.tsx` replaces `app/page.tsx`, `app/blog/page.tsx` and `app/blog/[slug]/page.tsx`.
File: project structure
```
app/
├ (alinea)/api/cms/route.ts
├ layout.tsx
├ globals.css
╰ [[...slug]]/page.tsx
entries/
├ page/
│ ├ Page.tsx
│ ╰ Page.schema.tsx
├ blog/
│ ├ Blog.tsx
│ ╰ Blog.schema.tsx
├ post/
│ ├ Post.tsx
│ ╰ Post.schema.tsx
╰ settings/
├ SiteLayout.tsx
╰ SiteLayout.schema.tsx
blocks/
╰ ...
cms.tsx
```
## A page type that nests
`Page` has the same blocks as the landing page and an editable path. `contains: ['Page']` lets editors create pages below pages, to any depth. The URL of a nested page is built from the paths of its parents: `/about/team`.
File: entries/page/Page.schema.tsx
```tsx
import {Config, Field} from 'alinea'
import {ImageBlock} from '@/blocks/image/ImageBlock.schema'
import {TextBlock} from '@/blocks/text/TextBlock.schema'
import {WeatherBlock} from '@/blocks/weather/WeatherBlock.schema'
export const Page = Config.document('Page', {
contains: ['Page'],
fields: {
title: Field.text('Title', {required: true, width: 0.5}),
path: Field.path('Path', {required: true, width: 0.5}),
blocks: Field.list('Blocks', {
schema: {
TextBlock,
ImageBlock,
WeatherBlock
}
})
}
})
```
`PageView` receives the entry as a prop and maps each block to its view, like the landing page did in step 2.
File: entries/page/Page.tsx
```tsx
import type {Infer} from 'alinea'
import {ImageBlockView} from '@/blocks/image/ImageBlock'
import {TextBlockView} from '@/blocks/text/TextBlock'
import {WeatherBlockView} from '@/blocks/weather/WeatherBlock'
import {Page} from './Page.schema'
type PageData = Infer.Entry
export function PageView({page}: {page: PageData}) {
return (
{page.title}
{page.blocks.map(block => {
if (block._type === 'TextBlock') return
if (block._type === 'ImageBlock') return
if (block._type === 'WeatherBlock') return
return null
})}
)
}
```
## Register the type
Replace `LandingPage` with `Page` in the schema and in the Pages root. The root no longer seeds a homepage: editors create it as a page with the path `index`, which resolves to `/`. If you followed the previous steps, your content still has the old landing page in `content/pages/index.json`, with a type that no longer exists: delete that file, then create the homepage in the dashboard.
File: cms.tsx
```tsx
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'
import {Blog} from '@/entries/blog/Blog.schema'
import {Page} from '@/entries/page/Page.schema'
import {Post} from '@/entries/post/Post.schema'
import {SiteLayout} from '@/entries/settings/SiteLayout.schema'
export const cms = createCMS({
schema: {
Page,
SiteLayout,
Blog,
Post
},
workspaces: {
main: Config.workspace('Main', {
source: 'content',
mediaDir: 'public/media',
roots: {
pages: Config.root('Pages', {
contains: ['Page', 'Blog'],
children: {
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
})
```
## The catch-all route
The optional catch-all segment `[[...slug]]` also matches `/`, with no segments. The route joins the segments into a URL, looks up the entry with `cms.first({url})` and branches on its `_type`, so each page type keeps rendering in its own component.
- `generateStaticParams` lists the URL of every entry in the Pages root, so all pages are prerendered.
- `generateMetadata` is shared by all types. Every document has the same `metadata` field, so `Page.metadata` selects it for blogs and posts too. Split it per type when types need different fallbacks, as the blog did in step 4.
File: app/[[...slug]]/page.tsx
```tsx
import {Query} from 'alinea'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import {cms} from '@/cms'
import {BlogView} from '@/entries/blog/Blog'
import {PageView} from '@/entries/page/Page'
import {Page} from '@/entries/page/Page.schema'
import {PostView} from '@/entries/post/Post'
interface RouteProps {
params: Promise<{slug?: Array}>
}
export async function generateStaticParams() {
const urls = await cms.find({
root: cms.workspaces.main.pages,
select: Query.url
})
return urls.map(url => ({slug: url === '/' ? [] : url.slice(1).split('/')}))
}
export async function generateMetadata({
params
}: RouteProps): Promise {
const {slug = []} = await params
const url = slug.length > 0 ? `/${slug.join('/')}` : '/'
const page = await cms.first({
url,
include: {
title: Query.title,
metadata: Page.metadata // Every document type has the same metadata field, so Page.metadata works for all of them
}
})
if (!page) return {}
return {
title: page.metadata?.title || page.title,
description: page.metadata?.description,
openGraph: {
title:
page.metadata?.openGraph?.title || page.metadata?.title || page.title,
description:
page.metadata?.openGraph?.description || page.metadata?.description,
images: page.metadata?.openGraph?.image
? [page.metadata.openGraph.image.src]
: undefined
}
}
}
export default async function CatchAllPage({params}: RouteProps) {
const {slug = []} = await params
const url = slug.length > 0 ? `/${slug.join('/')}` : '/'
const page = await cms.first({url})
if (!page) notFound()
if (page._type === 'Blog') {
return
}
if (page._type === 'Post') {
const postSlug = slug[slug.length - 1]
if (!postSlug) notFound()
return
}
const regularPage = await cms.first({url, type: Page})
if (!regularPage) notFound()
return
}
```
Note (info): Check that:
- You can create a page, add pages below it and open them at their nested URL, such as `/about/team`.
- A page with the path `index` renders at `/`.
- The blog and its posts still work at the same URLs, including the previous/next links.
Note (warning): A catch-all route isn't always the best choice. All page types now share one route, so client components used by any of them end up in the JavaScript that route loads, and route-level settings such as `revalidate` apply to every page. Dedicated routes, as in step 4, keep those separate. Many sites combine both: fixed routes for sections with their own layout and a catch-all for the rest.
### Upgrading from 1.x (https://v2.alineacms.com/docs/upgrading)
This guide walks through moving an existing Alinea 1.7 project to 2.0. Your content files don't need a migration and the query API is unchanged. Most projects only need new runtime versions, one config option and a look at their media URLs. The dashboard was rebuilt, so custom dashboard code needs the most attention.
## Checklist
1. Run Node.js 24 or higher, locally, in CI and wherever your site runs, and use React 19.
2. Update the package: `npm install alinea@preview` (or `pnpm add`, `yarn add`, `bun add`).
3. Add `handlerUrl` to your config, it is now required. Replace `dashboardFile` with `adminPath`.
4. Make sure your Next.js config is wrapped in `withAlinea` and your scripts run `alinea dev -- next dev` and `alinea build -- next build`: media URLs are now served through your site.
5. Move children of `Config.media(...)` into its `children` option.
6. Remove the options of `Field.metadata(...)` and the `step` option of `Field.time`.
7. Rich text: pass `extensions` as a function and upgrade custom extensions to Tiptap 3.
8. Handler: replace `remote` with `backend`, and move entry hooks to `beforeCommit` / `afterCommit`.
9. Self-hosted OAuth2: provide `validateClaims`.
10. Custom field views and dashboard code: import hooks from `alinea/cms` and components from `alinea/components` instead of `alinea/dashboard` and `alinea/ui`.
11. Run `alinea build`, open the dashboard and save an entry to review the diff.
## Runtime requirements
Node.js 24 or higher. The CLI exits with "Alinea requires Node version 24 or higher" on older versions. The bundled content database is opened with Node's built-in `node:sqlite` (or `bun:sqlite` under Bun), so the runtime that serves your site needs Node 24 too, not only the machine that builds it.
React 19. `react` and `react-dom` 19 are now peer dependencies and the CLI checks for them. In practice that means Next.js 15 or newer.
## Configuration
### handlerUrl is required
Alinea 1.7 assumed `/api/cms` when `handlerUrl` was missing. Alinea 2.0 throws `Missing handlerUrl in Alinea config` from `alinea dev`, `alinea build` and your site. Set it to the route that exports `createHandler`:
File: cms.ts
```tsx
// 1.7
export const cms = createCMS({
schema,
workspaces,
baseUrl,
dashboardFile: 'admin.html'
})
// 2.0
export const cms = createCMS({
schema,
workspaces,
baseUrl,
handlerUrl: '/api/cms',
adminPath: '/admin'
})
```
The handler now matches its path exactly, so requests have to use the configured URL (a trailing slash no longer matches).
### dashboardFile is replaced by adminPath
`adminPath` is the path the dashboard is served on, `/admin` by default. `alinea build` writes the dashboard to `public/admin.html` and `withAlinea` serves it at `/admin`. `dashboardFile` still works when `adminPath` isn't set, but it is deprecated. See [Configuration](https://v2.alineacms.com/docs/configuration) for the new `maxUploadSize` and `tracer` options.
### syncInterval is a fallback
Deployed sites now check whether the content in the repository changed (by its commit sha) and sync right away when it did, which is what makes [instant publishing](https://v2.alineacms.com/docs/instant-publishing) work. `syncInterval` from your config is used as the fallback interval and defaults to 60 seconds. `0` syncs on every query, and passing `disableSync: true` to a query turns syncing off for it.
## Media URLs
Image and file links now point to a route on your site instead of the file in `public/`. The `src` of an image link and the `url`/`href` of a file link used to be the stored location, such as `/media/landscape.2V4c….jpg`. In 2.0 they are the media entry's url: `/admin/file/landscape.jpg?v=…`, below your `adminPath`.
`withAlinea` rewrites `${adminPath}/file/*` to your handler, which looks up the file and serves it. It also adds an `images.localPatterns` entry so `next/image` accepts these URLs. When a file is moved or renamed, its old URL keeps working through an alias.
What to check:
- Your Next.js config is wrapped in `withAlinea` and your scripts run through `alinea dev --` and `alinea build --`, which pass it the `adminPath` and `handlerUrl`. Without them media URLs return 404.
- Media requests now reach your handler route. A static export without the handler can't serve them.
- If you defined `images.localPatterns` or `remotePatterns` yourself, check that the admin file route is still allowed. The `uploads.alinea.cloud` remote pattern is no longer added.
- Don't change the `mediaDir` of an existing workspace: stored locations are relative to it. New projects default to `public/media`.
A workspace can set `mediaUrl` to add a prefix to its media URLs, which helps when several workspaces have files with the same name.
## Content and schema
Content files don't change format: entries, rich text, links and list rows written by 1.7 are read as they are. New properties are added when an entry is saved again (see below).
### Config.media takes options
The media root now takes the same options as other roots. Children move to `children`:
```tsx
// 1.7
media: Config.media({uploads: Config.page(...)})
// 2.0
media: Config.media({children: {uploads: Config.page(...)}})
```
A 1.7 call with children is silently read as options, so its children disappear. The media root also accepts `i18n`, which translates the alt text of media files.
### Field.metadata has no options
`Field.metadata(label)` no longer takes `inferTitleFrom`, `inferDescriptionFrom` or `inferImageFrom`. The metadata field now also stores the URL aliases of the entry and audit fields: `createdAt`, `createdBy`, `updatedAt` and `updatedBy`, with the name and email of the editor. They are filled in when an entry is saved. Keep that in mind for public repositories. The description is limited to 160 characters.
### Other field changes
- `Field.time` no longer has a `step` option.
- `Field.file` pickers now also show images.
- `Field.entry.multiple` no longer allows the same entry twice by default, pass `allowDuplicates: true` to keep the 1.7 behavior.
- Date fields are displayed as day-month-year in the dashboard. The stored value is still an ISO date.
- Trackers from `Config.track.options` must return their value synchronously, and `Config.track.value` was removed.
### Rich text extensions and toolbars
The rich text editor moved to Tiptap 3, so custom extensions must be Tiptap 3 compatible. `extensions` is now a function that receives the default extensions, instead of an object that replaced them:
```tsx
// 1.7
Field.richText('Body', {
extensions: {...myExtensions}
})
// 2.0
Field.richText('Body', {
extensions: defaults => ({...defaults, ...myExtensions})
})
```
`defaultToolbar(enableTables)` became `defaultToolbar({enableTables, enableImages})`, and toolbar button icons are components (or elements) instead of nodes. The toolbar presets, `defaultToolbar`, the `ToolbarButton` type and the default `extensions` are exported from `alinea/field/richtext` (`alinea/field/richtext/Toolbar` still works).
The `RichText` renderer from `alinea/ui` always renders tables with a ``, and the `tbody` view override is gone.
### Edit.move
```tsx
// 1.7
Edit.move({id, after: siblingId})
// 2.0
Edit.move({id, target: siblingId, dropPosition: 'after'})
```
`dropPosition` is `'after'`, `'before'` or `'on'` (move into the target), and `targetType: 'root'` moves an entry to the top of a root.
### What changes when you save
The first save of an entry in 2.0 can produce a larger diff than you expect: missing field defaults are filled in (also in list rows), the metadata audit fields are added and media files gain an `alt` field. To normalize all files at once instead of entry by entry, run `alinea build --fix` and commit the result.
## Handler and backend
### Commit hooks replace entry hooks
The `beforeCreate`, `afterUpdate`, … hooks of 1.7 were declared but never called. They are replaced by two hooks that run for every commit, whatever it contains:
File: app/(alinea)/api/cms/route.ts
```tsx
import {cms} from '@/cms'
import {createHandler} from 'alinea/next'
import {revalidatePath} from 'next/cache'
const handler = createHandler({
cms,
beforeCommit({mutations}) {
// Inspect or rewrite the changes, return the mutations to commit
return mutations
},
afterCommit({sha}) {
revalidatePath('/', 'layout')
}
})
export const GET = handler
export const POST = handler
```
Errors thrown in `afterCommit` are logged and don't fail the commit. See [Instant publishing](https://v2.alineacms.com/docs/instant-publishing).
### remote is replaced by backend
The `remote` option of `createHandler` was removed. Pass a `backend`: the options object from 1.7 (`database`, `auth`, `oauth2`, `github`) still works unchanged, and it now also takes `uploads: {s3}`. You can also compose a backend from parts exported by the new `alinea/backend` entry, which replaces deep imports such as `alinea/backend/api/CreateBackend`:
```tsx
import {auth, createBackend, database, github, uploads} from 'alinea/backend'
const backend = createBackend(
database({driver: '@vercel/postgres', client: db}),
auth.basic(
(username, password) =>
username === process.env.ALINEA_USERNAME &&
password === process.env.ALINEA_PASSWORD
),
github({/* the github options from 1.7 */}),
uploads.s3({/* bucket and credentials */})
)
const handler = createHandler({cms, backend})
```
### Self-hosted backends
- OAuth2: `validateClaims` is now required, the backend refuses to start without it. Check at least the issuer and audience of the token there.
- GitHub: the `author` option was removed. Commits get a `Co-authored-by` trailer for the signed-in editor instead.
- Custom auth implementations: `authenticate` receives a second `options` argument.
- Database: new `alinea_user` and `alinea_user_role` tables are created automatically on first use (on Postgres with row level security enabled). Drafts are no longer stored in the database, the 1.7 `alinea_draft` table is not used anymore.
## Dashboard
The dashboard was rebuilt on React Aria Components. Nothing changes for editors beyond a new interface, but code that extends the dashboard needs updating.
### Components
`alinea/ui` now only exports the `RichText` renderer and the `imageBlurUrl` helper (and the deprecated `HStack`/`VStack`). Its `Button`, `Chip`, `Icon`, `Loader`, `Typo` and other components were removed, as were the files behind deep imports like `alinea/ui/Button`. Use the public component library instead, it is what the dashboard itself is built from:
```tsx
// 1.7
import {Button, Loader} from 'alinea/ui'
// 2.0
import {Button, Spinner} from 'alinea/components'
```
See [Components](https://v2.alineacms.com/docs/components). Dashboard icons are available from `alinea/dashboard/icons`.
### Custom field views
Field views are still referenced by path (`view: '@/fields/Range.view'`) or passed as a component. The hooks and helpers for custom views now come from one entry point, `alinea/cms`. From `alinea/dashboard`, `useField` and its related hooks still work but are deprecated: the new `useField` returns a `[value, setValue]` tuple, and the options and error have their own hooks:
```tsx
// 1.7
import {useField} from 'alinea/dashboard'
const {value, mutator, options, error} = useField(field)
// 2.0
import {useField, useFieldError, useFieldOptions} from 'alinea/cms'
const [value, setValue] = useField(field)
const options = useFieldOptions(field)
const error = useFieldError(field)
```
`useFieldValue`, `useFieldOptions`, `useFieldError` and `useFieldKey` move to `alinea/cms` too and `useFieldMutator` becomes `useFieldSetter`. Deep imports of dashboard hooks move as well: `useLocale` and `useGraph` from `alinea/dashboard/hook/*` are exported from `alinea/cms`, and `useEntryEditor` is replaced by `useEntry`. `alinea/dashboard/hooks` is internal now. `InputLabel` is now an alias of the `Field` component: it takes `label`, `description`, `error`, `required`, `disabled`, `readOnly`, `icon` and `shared`. Its 1.7 props `help`, `width`, `inline`, `size` and the fold props are gone, so help text passed through `{...options}` is no longer shown: pass it as `description`, or replace it with `FieldChrome` from `alinea/cms`, which reads the label, help text and error from the field. The form atoms (`useForm`, `FormProvider`, `FormRow` and so on) were removed from `alinea/dashboard`. See [Custom fields](https://v2.alineacms.com/docs/custom-fields) for a complete example.
### Custom type views
A type's `view` now only receives `{type}`, the `editor` prop of 1.7 is gone. A custom `auth` view in the config is not used: the dashboard always renders its own sign-in.
### List overviews
The lists of children are configured on the parent with the new `overview` option. `summaryRow` and `summaryThumb` views are no longer rendered, and `orderChildrenBy` and the `overview: true` field option are deprecated:
File: schema/Blog.ts
```tsx
import {Config, Field} from 'alinea'
export const BlogPost = Config.document('Blog post', {
// 1.7: summaryRow and summaryThumb views
fields: {
cover: Field.image('Cover'),
// 1.7: Field.date('Publish date', {overview: true})
publishDate: Field.date('Publish date')
}
})
export const Blog = Config.document('Blog', {
contains: ['BlogPost'],
fields: {},
overview: {
columns: {
publishDate: Config.column({
header: 'Published',
select: BlogPost.publishDate
})
},
// 1.7: orderChildrenBy: {desc: BlogPost.publishDate}
sort: {desc: BlogPost.publishDate},
thumbnail: BlogPost.cover
}
})
```
- `summaryRow`: define the columns with `overview.columns`.
- `summaryThumb`: set the card image with `overview.thumbnail`. By default cards show the first image found in the entry's fields.
- `orderChildrenBy`: still works, move it to `overview.sort`.
- `overview: true` on a field: still shows the field as a column, but only while the parent has no `overview.columns`.
See [Overviews](https://v2.alineacms.com/docs/overviews) for sorting, custom cells and toolbar actions.
### Other removed entry points
`alinea/yjs` and `alinea/cloud/view/CloudAuth` no longer exist. `alinea/picker/entry/EntryPicker` and `alinea/picker/url/UrlPicker` no longer ship the picker UI: `EntryPickerModal`, `UrlPickerForm` and `UrlPickerModal` were removed without a public replacement. Import the picker option types (`EditorInfo`, `EditorLocation`, `EditorLimitLocation` and `EntryPickerConditions`) from `alinea/field/link`. Code that subclassed fields relied on the removed shape system (`alinea/core/Shape`, `Field.shape`, `Type.shape`): the field constructors no longer take a shape, and `postProcess` became `queryValue`.
## After upgrading
Run your build and open the dashboard. Things to try: an image renders on your site through `/admin/file/...`, publishing an entry updates the site, and custom fields still render. Then have a look at what's new in 2.0: [Field.localiser](https://v2.alineacms.com/docs/internationalization) for per-field translations, URL aliases and backlinks, [instant publishing](https://v2.alineacms.com/docs/instant-publishing) and the [MCP server](https://v2.alineacms.com/docs/mcp) for coding agents.
### Content model (https://v2.alineacms.com/docs/content-model)
Your content model lives in code, in the config you pass to `createCMS`: a schema of types and their fields, and workspaces with roots that decide where entries can be created. Editors fill it in through the dashboard, and every entry is saved as a JSON file in your repository.
File: cms.ts
```tsx
import {Config, Field} from 'alinea'
import {createCMS} from 'alinea/next'
const Page = Config.document('Page', {
fields: {
body: Field.richText('Body')
}
})
const Blog = Config.document('Blog', {
contains: ['BlogPost'],
fields: {}
})
const BlogPost = Config.document('Blog post', {
fields: {
publishDate: Field.date('Publish date'),
cover: Field.image('Cover image'),
body: Field.richText('Body')
}
})
export const cms = createCMS({
schema: {Page, Blog, BlogPost},
workspaces: {
main: Config.workspace('My site', {
source: 'content',
mediaDir: 'public/media',
roots: {
pages: Config.root('Pages', {contains: ['Page', 'Blog']}),
media: Config.media()
}
})
},
baseUrl: {
development: 'http://localhost:3000',
production: 'https://example.com'
},
handlerUrl: '/api/cms'
})
```
## The building blocks
- [Types](https://v2.alineacms.com/docs/schema/type) describe one kind of entry, such as a page or a blog post, with its fields and dashboard settings. [Documents](https://v2.alineacms.com/docs/schema/document) are types with a title, path and SEO metadata built in.
- The [schema](https://v2.alineacms.com/docs/schema) lists every type by name. The name (`BlogPost`) is what gets stored in content files and what you refer to in `contains`.
- [Fields](https://v2.alineacms.com/docs/fields) hold the data: text, rich text, links, images, lists of blocks and more.
- [Workspaces](https://v2.alineacms.com/docs/workspaces) group content, for example one per website, each with its own content directory.
- [Roots](https://v2.alineacms.com/docs/workspaces/root) are the top-level sections of a workspace's content tree, like "Pages" or "Settings". Entries nest below them, and a root can be translated.
- [Media](https://v2.alineacms.com/docs/workspaces/media) roots hold uploaded images and files.
Once content is saved you read it back with [queries](https://v2.alineacms.com/docs/query), which are typed from the same schema.
## Where content is stored
Every entry is a JSON file in the workspace's `source` directory. The folder structure follows the content tree: a root is a folder, an entry is a file named after its path, and the children of an entry live in a folder with the same name.
```tsx
content
├ pages // the "pages" root
│ ├ index.json // entry with path "index", url "/"
│ ├ blog.json // url "/blog"
│ ╰ blog // children of blog.json
│ ├ hello-world.json
│ ╰ hello-world.draft.json
╰ media // the media root
├ landscape.json // metadata of an uploaded file
╰ documents.json // a media folder
public/media // mediaDir: the uploaded files themselves
╰ landscape.2V4c3kRS.jpg
```
A few details that are good to know:
- Drafts and archived versions sit next to the published file as `.draft.json` and `.archived.json`.
- In a root with [i18n](https://v2.alineacms.com/docs/internationalization) each locale gets its own folder: `content/pages/en/...`, `content/pages/fr/...`.
- With more than one workspace, each `source` directory must be named after its workspace key and share the same parent folder, for example `content/main` and `content/blog`.
- An entry file holds the field values plus a few internal properties such as `_id`, `_type` and `_index` (its position among its siblings). The path isn't stored separately: it's the file name.
Because content is plain files, it's versioned, reviewed and deployed with your code. `alinea dev` and `alinea build` compile your config and index the content into the `@alinea/generated` package in `node_modules`, which your site queries at runtime. That package is regenerated on every run, so don't edit or commit it.
### Schema (https://v2.alineacms.com/docs/schema)
The schema is an object that maps names to [Types](https://v2.alineacms.com/docs/schema/type). Pass it to `createCMS` as the `schema` option: every type an editor can create as an entry has to be listed here.
File: schema.ts
```tsx
import {Config, Field} from 'alinea'
export const Blog = Config.document('Blog', {
contains: ['BlogPost'],
fields: {}
})
export const BlogPost = Config.document('Blog post', {
fields: {
publishDate: Field.date('Publish date'),
body: Field.richText('Body')
}
})
```
File: cms.ts
```tsx
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'
import * as schema from './schema'
export const cms = createCMS({
schema,
workspaces: {
main: Config.workspace('Blog', {
source: 'content',
roots: {pages: Config.root('Pages', {contains: ['Blog']})}
})
},
handlerUrl: '/api/cms'
})
```
In this example `Blog` is a container that accepts `BlogPost` children, so editors create an overview page and add posts below it.
## Type names
The key of a type in the schema is its name. It's written to the `_type` property of every entry file and it's the name you use in `contains`, in link conditions (`{_type: 'BlogPost'}`) and in query results (`Query.type`). The label you pass to `Config.document` or `Config.type` is only shown in the dashboard.
- Names must start with a letter and contain only letters, digits and underscores. `Entry`, `MediaFile` and `MediaLibrary` are reserved: Alinea adds the last two for uploads and media folders.
- Renaming a key renames the type: existing entries that still carry the old name fail to load with "has an unknown type". Rename the `_type` in those files at the same time.
- Every name used in `contains` must exist in the schema, otherwise `alinea dev` stops with an error that names the missing type.
## Organizing a larger schema
Exporting every type from one module and importing it with `import * as schema` keeps the config short. In larger projects it's common to keep each type next to the component that renders it and collect them in one place:
File: schema.ts
```tsx
export {Article} from './pages/article/Article.schema'
export {Home} from './pages/home/Home.schema'
export {Settings} from './pages/settings/Settings.schema'
```
Types that are only used as blocks inside a [List](https://v2.alineacms.com/docs/fields/list) or [Rich text](https://v2.alineacms.com/docs/fields/rich-text) field don't need to be in the schema: they're registered by the field that uses them.
## Good to know
- A type can appear only once in the schema, and a field instance can belong to only one type. Registering either twice throws "is already in use", see [Fields must be unique](https://v2.alineacms.com/docs/schema/type).
- `Config.schema({types: {...}})` is available as a helper, but it returns the types object as is: a plain object works the same.
### Type (https://v2.alineacms.com/docs/schema/type)
A type describes one kind of entry: the fields editors fill in and how entries of that type behave in the dashboard. Create one with `Config.type(label, options)` and add it to the [schema](https://v2.alineacms.com/docs/schema) to make it available as an entry. For pages you'll usually reach for [`Config.document`](https://v2.alineacms.com/docs/schema/document), which is a type with title, path and metadata fields included.
```tsx
import {Config, Field} from 'alinea'
export const Author = Config.type('Author', {
fields: {
title: Field.text('Name', {required: true, width: 0.5}),
path: Field.path('Path', {required: true, width: 0.5}),
bio: Field.text('Bio', {multiline: true})
}
})
```
The label ("Author") is what editors see. The name the type is stored under is its key in the schema.
## Options
- `fields` (required): the [fields](https://v2.alineacms.com/docs/fields) of the type, keyed by name. Field names must start with a letter and contain only letters, digits and underscores. Spread [tabs](https://v2.alineacms.com/docs/fields/tabs) or `Field.view(...)` sections in between to lay out the form.
- `contains`: the types that can be created as children of this entry, by schema name or by reference. Without it, entries of this type can't have children.
- `overview`: how the dashboard lists the children: columns, the default order (`sort`), the layout and actions. See [Overviews](https://v2.alineacms.com/docs/overviews).
- `insertOrder`: where new children are added: `'first'`, `'last'` or `'free'` (default), which lets the editor choose in the create dialog.
- `entryUrl`: a function that computes the url of entries of this type, see below.
- `icon`: a React component shown next to entries of this type in the sidebar. Icon sets from [Icones](https://icones.js.org) can be copied as components.
- `hidden`: set to `true` to leave entries of this type out of the sidebar tree. They can still be linked to and queried.
- `defaultView`: `'edit'` opens the form when an entry is selected, `'overview'` shows a table of its children first. The default is `'overview'` for entries that have children and `'edit'` otherwise.
- `preview`: turn [live previews](https://v2.alineacms.com/docs/live-previews) on or off for this type (`true`/`false`), or pass a React component that renders the preview. It overrides the setting of the root, workspace and config.
- `view`: a React component, or a path to one, that replaces the entry editor for this type. It receives `{type}`.
- `orderChildrenBy` (deprecated): sort children by one or more fields. Use `overview.sort`, which still accepts the same value.
- `summaryRow`, `summaryThumb` (deprecated): not used by the dashboard. Configure the columns and card image in the `overview` of the parent instead.
## Entries need a title
Every entry has a title: it's shown in the sidebar, used for search and returned as `Query.title`. A type that editors create entries of needs a `title` text field. Add a `path` field to let editors control the url segment, otherwise it's derived from the title. `Config.document` includes both.
Types that are only used as blocks in a [List](https://v2.alineacms.com/docs/fields/list) or [Rich text](https://v2.alineacms.com/docs/fields/rich-text) field don't need either.
## Children
`contains` turns a type into a container. Here a `Blog` holds posts, and new posts are added at the top of the list:
```tsx
import {Config, Field} from 'alinea'
export const BlogPost = Config.document('Blog post', {
fields: {
publishDate: Field.date('Publish date')
}
})
export const Blog = Config.document('Blog', {
contains: ['BlogPost'],
insertOrder: 'first',
fields: {}
})
```
Use `overview.sort` when children should always be sorted by a field instead of by hand. The `overview` also sets the columns of the list of children:
```tsx
import {Config, Field, Query} from 'alinea'
export const Event = Config.document('Event', {
fields: {date: Field.date('Date')}
})
export const Agenda = Config.document('Agenda', {
contains: ['Event'],
fields: {},
overview: {
sort: [{desc: Event.date}, {asc: Query.title}],
columns: {
date: Config.column({header: 'Date', width: 140, select: Event.date})
}
}
})
```
The sort and `insertOrder` only affect the dashboard. Queries return children in their stored order unless you pass an [`orderBy`](https://v2.alineacms.com/docs/query/structural). See [Overviews](https://v2.alineacms.com/docs/overviews) for the other options.
## Urls
An entry's url is built from the paths of its parents and its own path, prefixed with the locale in translated roots: `/blog/hello-world` or `/fr/blog/hello-world`. A path of `index` is left out, so an `index` entry at the top of a root gets `/`.
`entryUrl` replaces that url for a type. It receives the default url and the data it's built from: `path`, `parentPaths`, `locale`, `workspace`, `root`, `status`, `data` (the entry's field values) and `defaultUrl`.
```tsx
import {Config} from 'alinea'
export const NewsArticle = Config.document('News article', {
// Always /news/, wherever the article sits in the tree
entryUrl({path, locale}) {
return locale ? `/${locale}/news/${path}` : `/news/${path}`
},
fields: {}
})
```
Urls are computed when content is saved and indexed, and stored with the entry. That's what lets you query by url (`cms.get({url})`) in a catch-all route. Keep them in line with your routes: a page that isn't served on its url shows up broken in previews and links.
Two entries in the same root can't be published on the same url: publishing the second one fails with a message naming the entry that already uses it. Keep that in mind when `entryUrl` drops the parent paths.
## Good to know
### Fields must be unique
A field instance can be used only once: Alinea throws "is already in use" when the same field shows up twice in a type or in two types of your schema. Queries rely on this to know which type and name a field reference like `BlogPost.publishDate` points to. Create shared fields with a function so every use gets its own instance:
```tsx
import {Config, Field} from 'alinea'
// Not allowed: the same instance in two places
// const summary = Field.text('Summary')
// Fine: every call creates a new field
const summary = () => Field.text('Summary', {multiline: true})
export const Article = Config.document('Article', {
fields: {summary: summary()}
})
export const Event = Config.document('Event', {
fields: {summary: summary()}
})
```
### Type references are fields
A type exposes its fields as properties: `BlogPost.publishDate` is the field itself. Use these references to [select, filter and sort](https://v2.alineacms.com/docs/query) in queries, in [overviews](https://v2.alineacms.com/docs/overviews) and in [conditional options](https://v2.alineacms.com/docs/fields). The TypeScript type of an entry is available with [`Infer`](https://v2.alineacms.com/docs/typescript).
### Document (https://v2.alineacms.com/docs/schema/document)
`Config.document` creates a [Type](https://v2.alineacms.com/docs/schema/type) for pages: it adds a title, a path and a metadata field to the fields you define. Use it for anything that gets its own url; use `Config.type` for entries that don't, such as settings or blocks.
```tsx
import {Config, Field} from 'alinea'
export const Page = Config.document('Page', {
fields: {
intro: Field.text('Intro', {multiline: true}),
body: Field.richText('Body')
}
})
```
## Included fields
- `title`: a required [text field](https://v2.alineacms.com/docs/fields/text), shown in the sidebar and returned as `Query.title`.
- `path`: a required [path field](https://v2.alineacms.com/docs/fields/path) that slugifies the title as you type. It's the last segment of the entry's url.
- `metadata`: SEO and sharing data, on a separate "Metadata" tab. It holds `title`, `description` (at most 160 characters), `openGraph` (`image`, `title`, `description`) and the entry's url `aliases`.
The dashboard shows `title`, `path` and your own fields on a "Document" tab, and the metadata on a "Metadata" tab.
The metadata field also keeps an audit trail: `createdAt` and `createdBy` are set when the entry is created, `updatedAt` and `updatedBy` on every save. Timestamps are Unix timestamps in seconds, the users are the editor's name and email. When an entry is published on a new url, its previous url is added to `aliases`. These values are written to the content files, so keep in mind that editor emails end up in your repository.
## Renamed and moved pages
When an entry is published on a new url, because its path changed or it moved to another parent, its previous url is added to `metadata.aliases`. Links between entries keep working, since they point to the entry rather than its url. For visitors who arrive on an old url, look up the entry by its alias and redirect to where it lives now, for example in the catch-all route that renders your pages:
```tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
import {notFound, permanentRedirect} from 'next/navigation'
// No page was found at `url`, check if one used to live there
const moved = await cms.first({
alias: url,
select: Query.url
})
if (moved) permanentRedirect(moved)
notFound()
```
The dashboard shows the other side: the References tab in the side panel of an entry lists every entry that links to it, so editors can see what is affected before they rename, move or delete a page.
## Options
`Config.document` takes the same options as [`Config.type`](https://v2.alineacms.com/docs/schema/type): `contains`, `orderChildrenBy`, `entryUrl`, `icon` and so on. To change a built-in field, define it in `fields` under the same name. A home page, for example, often gets a read-only path (see [a fixed path for the home page](https://v2.alineacms.com/docs/fields/path)):
```tsx
import {Config, Field} from 'alinea'
export const Home = Config.document('Home', {
fields: {
path: Field.path('Path', {readOnly: true, width: 0.5})
}
})
```
## Using the metadata
Select the metadata field in the query for a page and map it to your framework's head tags. With the Next.js App Router:
File: app/[[...slug]]/page.tsx
```tsx
import {cms} from '@/cms'
import {Page} from '@/schema'
import {Query} from 'alinea'
import type {Metadata} from 'next'
interface PageProps {
params: Promise<{slug?: Array}>
}
export async function generateMetadata({params}: PageProps): Promise {
const {slug = []} = await params
const page = await cms.first({
type: Page,
url: `/${slug.join('/')}`,
select: {title: Query.title, metadata: Page.metadata}
})
if (!page) return {}
const {metadata} = page
return {
title: metadata.title || page.title,
description: metadata.description,
openGraph: {
title: metadata.openGraph.title || metadata.title || page.title,
description: metadata.openGraph.description || metadata.description,
images: metadata.openGraph.image?.src
}
}
}
```
Image urls are relative to your site (`/admin/file/...`), so set [`metadataBase`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabase) in your root layout to get absolute Open Graph urls.
`Query.createdAt` and `Query.updatedAt` read the audit timestamps directly, for example to sort by the most recently created entries: `orderBy: {desc: Query.createdAt}`.
### Fields (https://v2.alineacms.com/docs/fields)
Fields make data editable. Every field is created with a `Field` function that takes a label and an options object, and is added to the `fields` of a [Type](https://v2.alineacms.com/docs/schema/type) under the name its value is stored as. Pick a field below for its options and the value it stores, or try them all in the playground further down. If none fits, you can write a [custom field](https://v2.alineacms.com/docs/custom-fields).
Fields are the building blocks of a schema: each stores a value and gives editors an input in the dashboard. To build React views for the dashboard, see [Components](https://v2.alineacms.com/docs/components).
### Basic
- [Text](https://v2.alineacms.com/docs/fields/text) (`Field.text`, stores string): A single or multiline plain string.
- [Rich text](https://v2.alineacms.com/docs/fields/rich-text) (`Field.richText`, stores TextDoc): Formatted text with headings, links, tables and your own blocks.
- [Select](https://v2.alineacms.com/docs/fields/select) (`Field.select`, stores option key): One or more keys from a fixed set of options.
- [Number](https://v2.alineacms.com/docs/fields/number) (`Field.number`, stores number): A number, kept between the bounds you set.
- [Check](https://v2.alineacms.com/docs/fields/check) (`Field.check`, stores boolean): A checkbox that stores true or false.
- [Date](https://v2.alineacms.com/docs/fields/date) (`Field.date`, stores date string): A calendar date, stored without a timezone.
- [Time](https://v2.alineacms.com/docs/fields/date) (`Field.time`, stores time string): A time of day, stored without a timezone.
- [Code](https://v2.alineacms.com/docs/fields/code) (`Field.code`, stores string): A monospace editor with syntax highlighting.
- [Path](https://v2.alineacms.com/docs/fields/path) (`Field.path`, stores URL segment): The URL segment of an entry, generated from its title.
### Links and media
- [Link](https://v2.alineacms.com/docs/fields/link) (`Field.link`, stores entry, URL or file): A link to an entry, an external URL or a file.
- [Entry](https://v2.alineacms.com/docs/fields/entry) (`Field.entry`, stores entry reference): A reference to other entries in the CMS.
- [URL](https://v2.alineacms.com/docs/fields/url) (`Field.url`, stores external link): A link to an external website, email or phone number.
- [Image](https://v2.alineacms.com/docs/fields/image) (`Field.image`, stores image reference): An image from the media library, with focal point.
- [File](https://v2.alineacms.com/docs/fields/file) (`Field.file`, stores file reference): A file from the media library, such as a PDF.
### Structure
- [List](https://v2.alineacms.com/docs/fields/list) (`Field.list`, stores array of rows): An ordered list of blocks that editors add and reorder.
- [Object](https://v2.alineacms.com/docs/fields/object) (`Field.object`, stores nested object): A group of fields stored together as one object.
- [Tabs](https://v2.alineacms.com/docs/fields/tabs) (`Field.tabs`, stores layout only): Splits a long form into tabs, values stay on the entry.
- [Localiser](https://v2.alineacms.com/docs/internationalization#localised-fields) (`Field.localiser`, stores value per locale, new in 2.0): Keeps every translation of a value in a single field.
Also available:
- `Field.metadata`: SEO and Open Graph fields, plus URL aliases and timestamps
- `Field.json`: Any JSON value in a plain JSON editor
- `Field.view`: A React element placed between fields, such as a note
## Try them
Every field type in one form, edit the code to see the dashboard change.
```
import {Config, Field} from 'alinea'
export default Config.type('Kitchen sink', {
fields: {
...Field.tabs(
Field.tab('Basic fields', {
fields: {
title: Field.text('Text field'),
path: Field.path('Path field', {
help: 'Creates a slug of the value of another field'
}),
richText: Field.richText('Rich text field'),
select: Field.select('Select field', {
options: {
a: 'Option a',
b: 'Option b'
}
}),
number: Field.number('Number field', {
minValue: 0,
maxValue: 10
}),
check: Field.check('Check field', {description: 'Check me please'}),
date: Field.date('Date field', {width: 0.5}),
time: Field.time('Time field', {width: 0.5}),
code: Field.code('Code field')
}
}),
Field.tab('Link fields', {
fields: {
externalLink: Field.url('External link'),
entry: Field.entry('Internal link'),
linkMultiple: Field.link.multiple('Mixed links, multiple'),
image: Field.image('Image link'),
file: Field.file('File link')
}
}),
Field.tab('List fields', {
fields: {
list: Field.list('My list field', {
schema: {
Text: Config.type('Text', {
fields: {
title: Field.text('Item title'),
text: Field.richText('Item body text')
}
}),
Image: Config.type('Image', {
fields: {
image: Field.image('Image')
}
})
}
})
}
}),
Field.tab('Inline fields', {
fields: {
street: Field.text('Street', {width: 0.6, inline: true}),
streetNr: Field.text('Number', {width: 0.2, inline: true}),
box: Field.text('Box', {width: 0.2, inline: true}),
zip: Field.text('Zipcode', {width: 0.2, inline: true}),
city: Field.text('City', {width: 0.4, inline: true}),
country: Field.text('Country', {width: 0.4, inline: true})
}
})
)
}
})
```
## Common options
Most fields accept these options next to their own. The field pages list the exceptions.
- `help`: instructions shown below the label. Plain text or a React node.
- `width`: a number between 0 and 1 to make the field take part of a row, `0.5` puts two fields side by side.
- `inline`: a compact version of the field: the label is hidden and used as placeholder where the input allows it.
- `initialValue`: the value of the field when an entry or list row is created.
- `required`: the field must have a value. Empty strings, `null`, empty lists, empty links and rich text without text count as missing.
- `validate`: a function that receives the value and returns an error message to show, `false` for a generic "Field is invalid", or `true`/`undefined` when the value is fine.
- `readOnly`: show the value but don't allow editing.
- `hidden`: don't show the field in the dashboard. Its value is kept and can still be queried or set with a [conditional option](https://v2.alineacms.com/docs/fields).
- `shared`: in a translated root, keep the same value in every locale, see below.
- `overview` (deprecated): `true` shows the field as a column in the list of the parent's children. It's only used when the parent's [`overview`](https://v2.alineacms.com/docs/overviews) defines no `columns`, for up to five fields. Define `overview.columns` on the parent instead.
```tsx
import {Field} from 'alinea'
Field.text('Discount code', {
help: 'Uppercase letters and digits only',
width: 0.5,
required: true,
validate(value) {
if (!/^[A-Z0-9]*$/.test(value)) return 'Use uppercase letters and digits'
}
})
```
Required fields and validation messages are shown on the field while an editor works on the entry. An entry can't be published while a field fails these checks: the dashboard lists the invalid fields, and publishing through the API or the MCP server is rejected with the field paths and messages. Drafts can be saved with errors, and hidden or read-only fields are not checked.
### Shared fields
In a root with [i18n](https://v2.alineacms.com/docs/internationalization), set `shared: true` on fields that should be the same in every language, such as a price, a date or a product image. When the entry is published, its shared values are copied to the other translations, and new translations start with them.
```tsx
import {Config, Field} from 'alinea'
export const Product = Config.document('Product', {
fields: {
// Translated per locale
description: Field.richText('Description'),
// The same in every locale
price: Field.number('Price', {shared: true}),
image: Field.image('Image', {shared: true})
}
})
```
`shared` only works on fields at the top level of a type, not on fields inside an object, list or rich text block. It isn't supported on path fields. To translate a single value while sharing the rest of an entry, use `Field.localiser` instead.
## Conditional options
Options can depend on the values of other fields. Wrap a field in `Config.track.options` with a function that reads values through `get` and returns the options to change. It runs again whenever a value it reads changes, and has to return synchronously.
```tsx
import {Config, Field} from 'alinea'
const linkType = Field.select('Link to', {
initialValue: 'page',
options: {page: 'A page', external: 'An external url'}
})
export const CallToAction = Config.type('Call to action', {
fields: {
label: Field.text('Label'),
linkType,
page: Config.track.options(Field.entry('Page'), get => ({
hidden: get(linkType) !== 'page'
})),
url: Config.track.options(Field.url('Url'), get => ({
hidden: get(linkType) !== 'external'
}))
}
})
```
`get` reads fields of the entry being edited, including top-level fields from inside a list row. Hidden fields keep their value, so check the controlling field when you render the entry too.
```
import {Config, Field} from 'alinea'
const Example = Config.type('Conditional example', {
fields: {
textField: Field.text('Text field'),
readOnly: Field.check('Make read-only'),
hidden: Field.check('Hide field')
}
})
Config.track.options(Example.textField, get => {
const textField = get(Example.textField)
const readOnly = get(Example.readOnly)
const hidden = get(Example.hidden)
return {
readOnly,
hidden,
help: `Text has ${textField.length} characters`
}
})
export default Example
```
## Layout
Fields appear in the order you define them. Besides `width` and [tabs](https://v2.alineacms.com/docs/fields/tabs), `Field.view` places a React element between fields, such as a divider or a note for editors. Spread it into `fields` like tabs:
```tsx
import {Config, Field} from 'alinea'
export const Settings = Config.type('Settings', {
fields: {
title: Field.text('Site name'),
...Field.view(),
analyticsId: Field.text('Analytics id', {help: 'Leave empty to disable'})
}
})
```
To hide or lock fields for some editors, set field permissions on a role, see [Roles and permissions](https://v2.alineacms.com/docs/roles-permissions).
### Path (https://v2.alineacms.com/docs/fields/path)
A path field holds the url segment of an entry, its slug. It fills itself with a slug of the title while it's empty, and editors can change it. [`Config.document`](https://v2.alineacms.com/docs/schema/document) adds one for you; add it yourself to types made with `Config.type` that need a url.
```tsx
import {Config, Field} from 'alinea'
export const Author = Config.type('Author', {
fields: {
title: Field.text('Name', {required: true, width: 0.5}),
path: Field.path('Path', {required: true, width: 0.5})
}
})
```
```
import {Config, Field} from 'alinea'
export default Config.type('Path field', {
fields: {
title: Field.text('Title', {
initialValue: 'My first post',
required: true, width: 0.5
}),
path: Field.path('Path', {
required: true, width: 0.5
})
}
})
```
The path of an entry is stored in the field named `path`: Alinea reads it to name the entry's file and build its url.
## Options
- `from`: the name of the text field, next to the path field, whose slug is suggested while the path is empty. Defaults to `'title'`.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly` and `hidden`. `shared` is not supported: every translation has its own path, so urls can be translated.
## How entry paths are generated
- When an entry is created, the slug of its title is saved as its path.
- After that the path no longer follows the title, so fixing a typo in a title doesn't change a published url. Editors change the path by hand when they want a new url.
- Slugs are lowercase. Accents are removed from Latin letters, letters and digits of other scripts are kept, apostrophes are dropped and everything else becomes a dash: `"L'été à Paris!"` becomes `lete-a-paris`.
- Paths are unique among siblings. When a sibling already uses a path, a number is added: `about`, `about-1`, `about-2`.
When the path of a published entry changes, its file and the folder with its children are renamed and its url changes, for its children too. Documents keep the old url in their [metadata aliases](https://v2.alineacms.com/docs/schema/document) so you can redirect it.
## Slugs for other fields
A path field also works as a slug input elsewhere, for example an anchor for a section in a list. Point `from` at the field to suggest a slug from:
```tsx
import {Config, Field} from 'alinea'
export const Section = Config.type('Section', {
fields: {
heading: Field.text('Heading', {width: 0.5}),
anchor: Field.path('Anchor', {from: 'heading', width: 0.5})
}
})
```
```
import {Config, Field} from 'alinea'
export default Config.type('Path field (from)', {
fields: {
heading: Field.text('Heading', {
initialValue: 'Opening hours',
width: 0.5
}),
anchor: Field.path('Anchor', {
from: 'heading',
width: 0.5
})
}
})
```
The suggestion is only displayed: such a field stays empty until an editor types in it, so fall back to the heading when you render it.
## A fixed path for the home page
A path of `index` is left out of the url, so an `index` entry at the top of a root is served on `/`. Seed the home page from the root's [`children`](https://v2.alineacms.com/docs/workspaces/root) with the key `index`, and make its path read-only so editors can't move it:
```tsx
import {Config, Field} from 'alinea'
export const Home = Config.document('Home', {
fields: {
path: Field.path('Path', {readOnly: true, width: 0.5})
}
})
export const pages = Config.root('Pages', {
contains: ['Page'],
children: {
index: Config.page({type: Home, fields: {title: 'Home'}})
}
})
```
Seeded entries are created automatically and can't be moved or deleted in the dashboard.
Entries created in the dashboard always start with the slug of their title as path, an `initialValue` on the path field isn't used for them.
### Text (https://v2.alineacms.com/docs/fields/text)
A text field holds a plain string: a name, a short description, a label. Use [rich text](https://v2.alineacms.com/docs/fields/rich-text) when editors need formatting or links.
```tsx
import {Field} from 'alinea'
Field.text('Summary', {
help: 'Shown on overview pages',
multiline: true,
searchable: true
})
```
```
import {Config, Field} from 'alinea'
export default Config.type('Text field', {
fields: {
title: Field.text('Title', {width: 0.5}),
email: Field.text('Email', {type: 'email', width: 0.5}),
summary: Field.text('Summary', {
help: 'Shown on overview pages',
multiline: true,
initialValue: 'A text value'
}),
city: Field.text('City', {inline: true})
}
})
```
## Options
- `multiline`: allow line breaks, the field is shown as a text area. Default `false`.
- `placeholder`: a short hint shown while the field is empty.
- `type`: the input type, one of `'text'` (default), `'email'`, `'tel'` or `'url'`. It changes the keyboard on mobile devices and is ignored for multiline fields. It doesn't validate the value, use `validate` for that.
- `searchable`: add the value to the search index, so `search` queries also match on it. See [Search](https://v2.alineacms.com/docs/query/filtering).
- `iconLeft` and `iconRight`: an icon component shown inside the input, before or after the text.
- `autoFocus`: focus the input when the entry opens.
- The [common options](https://v2.alineacms.com/docs/fields): `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
## Value
The field stores a string and queries return it as is. New entries start with an empty string (`''`), not `undefined`, unless you set an `initialValue`. Line breaks in a multiline field are stored as `\n`: render them with `white-space: pre-line` or split on them.
Single-line text fields are shown as a column when an entry's children are listed in the dashboard's overview.
### Rich Text (https://v2.alineacms.com/docs/fields/rich-text)
A rich text field holds formatted text: headings, paragraphs, lists, links, quotes and optionally tables and images. Add a `schema` to let editors place your own blocks, such as a call to action or a video, between paragraphs. Render the value with the `RichText` component.
```tsx
import {Config, Field} from 'alinea'
const CallToAction = Config.type('Call to action', {
fields: {
label: Field.text('Label'),
link: Field.link('Link')
}
})
export const Article = Config.document('Article', {
fields: {
body: Field.richText('Body', {
searchable: true,
enableTables: true,
schema: {CallToAction}
})
}
})
```
```
import {Config, Field} from 'alinea'
const ImageBlock = Config.type('Image', {
fields: {image: Field.image('Image', {inline: true})}
})
export default Config.type('Rich Text field', {
fields: {
basic: Field.richText('Rich Text', {
initialValue: [
{_type: 'heading', level: 1, content: [
{_type: 'text', text: "Hello world"}
]},
{_type: 'paragraph', content: [
{_type: 'text', text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit."}
]}
]
}),
richText: Field.richText('Extended with inline schema(s)', {
schema: {
ImageBlock
},
initialValue: [
{_type: 'paragraph', content: [
{_type: 'text', text: "The “Insert block” option appears when you press Enter to create a new line."}
]}
]
})
}
})
```
## Options
- `schema`: block types editors can insert between text, keyed by name. Block names must start with an uppercase letter.
- `enableTables`: allow inserting and editing tables. Default `false`.
- `enableImages`: allow inserting images from the media library inline. Default `false`.
- `searchable`: add the text to the search index, together with searchable fields inside its blocks, see [Search](https://v2.alineacms.com/docs/query/filtering).
- `placeholder`: text shown while the editor is empty.
- `link`: limit which entries the link picker offers, with the same `condition`, `location`, `pickChildren` and `limitLocations` options as the [Entry field](https://v2.alineacms.com/docs/fields/entry).
- `toolbar`: choose the toolbar buttons, see below.
- `extensions`: a function that receives the default [Tiptap](https://tiptap.dev) extensions and returns the ones to use.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
## Value
The value is an array of nodes. Text nodes carry marks such as bold or links, element nodes have a lowercase `_type` and their own `content`, and blocks from the `schema` have their type name and `_id` plus the values of their fields.
Variant: JSON
```tsx
[
{
"_type": "heading",
"level": 1,
"content": [
{
"_type": "text",
"text": "Hello world"
}
]
},
{
"_type": "paragraph",
"content": [
{
"_type": "text",
"text": "A paragraph follows"
}
]
}
]
```
Variant: Types
```tsx
type TextDoc = Array
// Text, optionally with marks such as bold, italic or link
interface TextNode {
_type: 'text'
text?: string
marks?: Array<{_type: string; [attr: string]: string | undefined}>
}
// Elements such as paragraph, heading or bulletList
interface ElementNode {
_type: string
content?: TextDoc
[attr: string]: unknown
}
// Blocks from the schema option, e.g. an ImageBlock
interface BlockNode {
_id: string
_type: string
[field: string]: unknown
}
```
When you query the field, links to entries and files get an up-to-date `href` (so they keep working when the target moves), inline images get their `src` and alt text, and link and image fields inside blocks are resolved like everywhere else. Queries return the value as `TextDoc`, import that type from `alinea` to type your props.
## Rendering
The `RichText` component from `alinea/ui` renders the value with plain HTML tags: `h1`–`h6`, `p`, `ul`, `ol`, `li`, `a`, `b`, `i`, `blockquote`, `table` and so on. Pass a tag name as a prop to change how that tag renders: a React element to add props such as a class name, or a component to take over completely.
```tsx
import type {TextDoc} from 'alinea'
import {RichText} from 'alinea/ui'
import Link from 'next/link'
import type {ComponentProps} from 'react'
function TextLink({href = '', ...props}: ComponentProps<'a'>) {
return
}
function Heading2(props: ComponentProps<'h2'>) {
return
}
export function Prose({doc}: {doc: TextDoc}) {
return (
}
// Render h2 headings with your own component
h2={Heading2}
// Route links through next/link
a={TextLink}
/>
)
}
```
The overridable tags are `h1`–`h6`, `p`, `b`, `i`, `ul`, `ol`, `li`, `blockquote`, `hr`, `img`, `br`, `small`, `sub`, `sup`, `a`, `table`, `tr`, `td` and `th`. Pass `text` to wrap every piece of text in a component. Headings get an `id` (the anchor an editor set, or a slug of the heading text), so you can link to sections.
### Blocks
Blocks are rendered only when you pass a component for their type name. The component receives the block's fields as props:
```tsx
import {Config, Field, type Infer, type TextDoc} from 'alinea'
import {RichText} from 'alinea/ui'
const Quote = Config.type('Quote', {
fields: {
text: Field.text('Text', {multiline: true}),
author: Field.text('Author')
}
})
const blocks = {Quote}
export const Article = Config.document('Article', {
fields: {
body: Field.richText('Body', {schema: blocks})
}
})
function QuoteView({text, author}: Infer) {
return (
{text}
{author}
)
}
export function Body({doc}: {doc: TextDoc}) {
return
}
```
Type the document as `TextDoc` (a queried rich text field already has this type) and `` checks the props of your block components. `RichText` is only needed when the document is typed as plain `TextDoc`.
### HTML strings
To get HTML instead of React elements, for example for an RSS feed, render the component to a string. Import `react-dom/server` dynamically: Next.js refuses static imports of it in the app router.
```tsx
import type {TextDoc} from 'alinea'
import {RichText} from 'alinea/ui'
export async function toHtml(doc: TextDoc) {
const {renderToString} = await import('react-dom/server')
return renderToString()
}
```
## Toolbar
By default the toolbar has heading styles, formatting, alignment, lists, links, anchors, quotes and a horizontal rule, plus tables and images when you enable them. Build your own from the presets in `alinea/field/richtext`: each is a group of buttons, and groups can be picked apart.
```tsx
import {Field} from 'alinea'
import {formatting, links, lists} from 'alinea/field/richtext'
const {bold, italic, clear} = formatting.group
// A small editor for short texts: no headings, tables or images
export const summary = Field.richText('Summary', {
toolbar: {
formatting: {group: {bold, italic, clear}},
lists,
links
}
})
```
The presets are `headings`, `formatting`, `alignment`, `lists`, `links`, `anchors`, `images`, `tables`, `quotes` and `inserts`. `defaultToolbar({enableTables, enableImages})` returns the default layout, so you can extend it. Type your own buttons with `ToolbarButton`. The same module exports the built-in Tiptap extensions as `extensions`, to extend one in your `extensions` option, for example `extensions.BulletList.extend({...})`. The toolbar only controls buttons: pasted content can still contain other formatting, so trim the `extensions` too if an editor must not produce it.
### List (https://v2.alineacms.com/docs/fields/list)
A list field holds an ordered list of rows, and every row has one of the types in its `schema`. Editors add, reorder and remove rows. Lists are how you build pages out of blocks: a hero, a text section, a gallery, in any order.
```tsx
import {Config, Field} from 'alinea'
const TextBlock = Config.type('Text', {
fields: {
title: Field.text('Title'),
text: Field.richText('Text')
}
})
const ImageBlock = Config.type('Image', {
fields: {
image: Field.image('Image'),
caption: Field.text('Caption')
}
})
export const Page = Config.document('Page', {
fields: {
blocks: Field.list('Blocks', {
schema: {TextBlock, ImageBlock}
})
}
})
```
```
import {Config, Field} from 'alinea'
export default Config.type("List field", {
fields: {
list: Field.list("List", {
schema: {
Item: Config.type("Item", {
fields: {
title: Field.text("Title"),
text: Field.richText("Text"),
}
})
}
}),
listMixed: Field.list("List mixed", {
schema: {
Text: Config.type("Text", {
fields: {
title: Field.text("Title"),
text: Field.richText("Text"),
}
}),
Image: Config.type("Image", {
fields: {
image: Field.image("Image")
}
})
}
})
}
})
```
## Options
- `schema` (required): the row types, keyed by name. The key is stored in each row's `_type`, so renaming it orphans existing rows.
- `min`: mark the field as invalid while it has fewer rows.
- `max`: hide the add buttons once the list has this many rows, and mark the field as invalid when it has more.
- `initialValue`: rows to start with, without `_id` or `_index`: they're generated.
- `validate`: receives the rows, including their `_id` and `_type`.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `required`, `readOnly` and `hidden`.
Row types are ordinary [Types](https://v2.alineacms.com/docs/schema/type): they can have tabs, an `icon` that is shown in the add menu and nested lists. They don't need a title or path and don't have to be in the schema.
## Value
The value is an array of rows. Next to its fields, every row has:
- `_type`: the key of its type in `schema`.
- `_id`: a unique id, handy as React `key`.
- `_index`: a sort key. Rows are already sorted when you get them.
- `_label` and `_anchor`: optional. Editors can give a row a custom label (shown in the dashboard) and an anchor, so links can point to that row (`/page#anchor`). Anchors are unique within an entry.
When you query a list, link and image fields inside rows are resolved like fields at the top level.
## Rendering
Switch on `_type` to render each row. `Infer` turns the list's schema into a union type, so TypeScript knows the fields of every branch:
```tsx
import {Config, Field, type Infer} from 'alinea'
const TextBlock = Config.type('Text', {
fields: {text: Field.text('Text', {multiline: true})}
})
const ImageBlock = Config.type('Image', {
fields: {image: Field.image('Image')}
})
export const blocks = {TextBlock, ImageBlock}
export const Page = Config.document('Page', {
fields: {blocks: Field.list('Blocks', {schema: blocks})}
})
type Block = Infer['blocks'][number]
export function Blocks({blocks}: {blocks: Array}) {
return blocks.map(block => {
switch (block._type) {
case 'TextBlock':
return
{block.text}
case 'ImageBlock':
return
}
})
}
```
## Good to know
- Different pages often allow different blocks. Keep block types in shared modules and compose a `schema` per page type, instead of one list that allows everything everywhere.
- Types used in a list are registered by the list, not by the schema. The same type object can be used in several lists.
- Filter entries on list contents with [`includes`](https://v2.alineacms.com/docs/query/filtering), for example all pages that contain a video block: `filter: {blocks: {includes: {_type: 'VideoBlock'}}}`.
### Tabs (https://v2.alineacms.com/docs/fields/tabs)
Tabs split a long form into sections. They only change the layout in the dashboard: the fields on a tab are stored on the entry as if they were defined without tabs, so moving a field to another tab doesn't change your content or queries.
```tsx
import {Config, Field} from 'alinea'
export const Product = Config.document('Product', {
fields: {
...Field.tabs(
Field.tab('Content', {
fields: {
intro: Field.text('Intro', {multiline: true}),
body: Field.richText('Body')
}
}),
Field.tab('Pricing', {
fields: {
price: Field.number('Price', {width: 0.5}),
currency: Field.select('Currency', {
width: 0.5,
initialValue: 'eur',
options: {eur: 'EUR', usd: 'USD'}
})
}
})
)
}
})
```
```
import {Config, Field} from "alinea"
export default Config.type("Tabs field", {
fields: {
...Field.tabs(
Field.tab("Tab A", {
fields: {
fieldA: Field.text("Field in Tab A")
}
}),
Field.tab("Tab B", {
fields: {
fieldB: Field.text("Field in Tab B")
}
})
)
}
})
```
## Usage
- `Field.tabs(...tabs)` groups tabs. Spread its result into `fields`, because it adds the fields of every tab to the type.
- `Field.tab(label, {fields, icon})` defines one tab. `icon` is an optional React component shown before the label.
- Fields before or after the tabs are shown above or below them, on every tab.
- Field names must be unique across all tabs of a type: they end up side by side in the same entry.
- Tabs work in list rows and blocks too, and a tab can contain another set of tabs.
With [`Config.document`](https://v2.alineacms.com/docs/schema/document), your fields already sit on a "Document" tab next to "Metadata". Tabs you add there appear inside the Document tab.
## Reusing tabs
Because `Field.tab` returns a type, a common set of fields can be shared as a function that creates a new tab each time (fields [must be unique](https://v2.alineacms.com/docs/schema/type), so don't share one instance):
```tsx
import {Config, Field} from 'alinea'
function settingsTab() {
return Field.tab('Settings', {
fields: {
hideFromNavigation: Field.check('Hide from navigation'),
noIndex: Field.check('Hide from search engines')
}
})
}
export const Page = Config.document('Page', {
fields: {
...Field.tabs(
Field.tab('Content', {fields: {body: Field.richText('Body')}}),
settingsTab()
)
}
})
```
### Select (https://v2.alineacms.com/docs/fields/select)
A select field lets editors pick from a fixed set of options. It stores the key of the chosen option, so you can rename labels without touching content. Use `Field.select.multiple` to allow picking several.
```tsx
import {Field} from 'alinea'
Field.select('Category', {
options: {
news: 'News',
release: 'Release notes',
tutorial: 'Tutorial'
}
})
```
```
import {Config, Field} from 'alinea'
export default Config.type('Select field', {
fields: {
category: Field.select('Category', {
options: {
news: 'News',
release: 'Release notes',
tutorial: 'Tutorial'
}
}),
alignment: Field.select('Alignment', {
initialValue: 'left',
options: {
left: 'Left',
center: 'Center',
right: 'Right'
}
}),
tags: Field.select.multiple('Tags', {
options: {
design: 'Design',
development: 'Development',
marketing: 'Marketing'
}
})
}
})
```
## Options
- `options` (required): an object of option keys and their labels, shown in this order.
- `placeholder`: text shown while nothing is selected, for example "Choose a category".
- `initialValue`: the key selected for new entries.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
## Value
- `Field.select` stores the selected key, or `null` when nothing is selected. The TypeScript type is the union of your keys (`'news' | 'release' | 'tutorial'`), plus `null` when there's no `initialValue`.
- `Field.select.multiple` stores an array of keys, `[]` by default.
Labels aren't stored. Keep the options in a constant if you need them on your site too, and don't rename keys that are in use: entries keep the old key.
```tsx
import {Config, Field} from 'alinea'
export const categories = {
news: 'News',
release: 'Release notes',
tutorial: 'Tutorial'
}
export const Post = Config.document('Post', {
fields: {
category: Field.select('Category', {options: categories}),
tags: Field.select.multiple('Tags', {
options: {design: 'Design', development: 'Development'}
})
}
})
export function categoryLabel(key: keyof typeof categories | null) {
return key ? categories[key] : ''
}
```
## Filtering
Filter on the key. For multiple selects, use `includes`:
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
const releases = await cms.find({
type: BlogPost,
filter: {tags: {includes: 'release'}}
})
```
See [Filtering](https://v2.alineacms.com/docs/query/filtering) for all operators.
### Number (https://v2.alineacms.com/docs/fields/number)
A number field holds a number: a price, a quantity, a percentage. It has increment and decrement buttons and keeps the value between the bounds you set.
```tsx
import {Field} from 'alinea'
Field.number('Quantity', {
minValue: 0,
maxValue: 10,
step: 1
})
```
```
import {Field} from 'alinea'
export default Field.number('Quantity', {
minValue: 0,
maxValue: 10,
step: 1
})
```
## Options
- `minValue` and `maxValue`: the allowed range. Values outside it are clamped when the editor leaves the field.
- `step`: the amount the buttons and arrow keys add or subtract. Default `1`. Typed values snap to a multiple of the step too, so set a smaller step for decimals.
- `placeholder`: text shown while the field is empty.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
## Value
The field stores a number, or `null` when it's empty. Without an `initialValue` new entries have no value, so handle `null` when you render it, or set `required: true`.
## Decimals
With the default step of `1`, a typed `9.99` becomes `10`. Use a step that matches the precision you need:
```tsx
import {Field} from 'alinea'
Field.number('Price', {
minValue: 0,
step: 0.01
})
```
```
import {Field} from 'alinea'
export default Field.number('Price', {
help: 'The buttons change the value by 0.01',
initialValue: 9.99,
minValue: 0,
step: 0.01
})
```
### Check (https://v2.alineacms.com/docs/fields/check)
A check field is a checkbox that stores `true` or `false`, for settings such as "Featured" or "Hide from navigation".
```tsx
import {Field} from 'alinea'
// The label is shown next to the checkbox
Field.check('Featured')
// With a description, the label becomes a field label above it
Field.check('Navigation', {
description: 'Hide this page from the menu'
})
```
```
import {Config, Field} from 'alinea'
export default Config.type('Check field', {
fields: {
featured: Field.check('Featured'),
hideFromNavigation: Field.check('Navigation', {
description: 'Hide this page from the menu'
})
}
})
```
## Options
- `description`: text next to the checkbox. When set, the label is shown above the checkbox as a regular field label.
- `autoFocus`: focus the input when the entry opens.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
## Value
A boolean. Without an `initialValue`, the value stays unset until an editor ticks the box, so compare with `true` (or use `Boolean(value)`) rather than checking for `false`. The same goes for filters: `{featured: true}` finds featured entries, but `{featured: false}` and `{featured: {isNot: true}}` skip entries where the value was never set. Use `{featured: {in: [false, null]}}` to find everything that isn't featured.
Set `initialValue: true` for options that should be on by default.
### Date & time (https://v2.alineacms.com/docs/fields/date)
`Field.date` holds a calendar date and `Field.time` a time of day. Both store plain strings without a timezone, which makes them easy to compare, filter and sort.
```tsx
import {Field} from 'alinea'
Field.date('Publish date')
Field.time('Start time', {minValue: '08:00', maxValue: '18:00'})
```
```
import {Config, Field} from 'alinea'
export default Config.type('Date/time field', {
fields: {
date: Field.date('Date', {width: 0.5}),
time: Field.time('Start time', {
width: 0.5,
minValue: '08:00',
maxValue: '18:00'
})
}
})
```
## Options
- `minValue` and `maxValue` (time only): the earliest and latest time an editor can enter, as `HH:mm`.
- `autoFocus`: focus the input when the entry opens.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
## Value
- A date is stored as an ISO date string, `'2026-09-23'`. The dashboard shows it in day-month-year order.
- A time is stored as `'HH:mm'` on a 24-hour clock, `'14:30'`.
- Both are empty until an editor picks a value, unless you set an `initialValue`.
Because the format sorts alphabetically in date order, you can filter and sort on these fields directly:
```tsx
import {cms} from '@/cms'
import {Event} from '@/schema'
const today = new Date().toISOString().slice(0, 10)
const upcoming = await cms.find({
type: Event,
filter: {date: {gte: today}},
orderBy: {asc: Event.date}
})
```
## Default to today
`initialValue` takes a fixed string. Compute today's date in your config: it's evaluated when the dashboard loads, so new entries get the date the editor opened the dashboard.
```tsx
import {Field} from 'alinea'
const today = new Date().toISOString().slice(0, 10)
Field.date('Publish date', {initialValue: today})
```
```
import {Config, Field} from 'alinea'
const today = new Date().toISOString().slice(0, 10)
export default Config.type('Date (initialValue)', {
fields: {
date: Field.date('Publish date', {initialValue: today})
}
})
```
If the creation date matters, entries made with [`Config.document`](https://v2.alineacms.com/docs/schema/document) already record it: query `Query.createdAt`.
## Formatting
`new Date('2026-09-23')` is midnight UTC, which is still the previous day in timezones west of UTC. Format stored dates in UTC to show the date the editor picked:
```tsx
export function formatDate(date: string, locale = 'en-US') {
return new Date(date).toLocaleDateString(locale, {
dateStyle: 'long',
timeZone: 'UTC'
})
}
```
### Code (https://v2.alineacms.com/docs/fields/code)
A code field is a monospace editor with syntax highlighting, for snippets, embed codes or small bits of markup that editors paste in. It stores the text as is.
```tsx
import {Field} from 'alinea'
Field.code('Embed code', {
help: 'Paste the embed code from your video provider'
})
```
```
import {Config, Field} from 'alinea'
export default Config.type('Code field', {
fields: {
code: Field.code('Embed code', {
help: 'Paste the embed code from your video provider',
initialValue: ''
})
}
})
```
## Options
- `language`: the language of the code, such as `'ts'` or `'css'`. The editor highlights code with a language-agnostic tokenizer and exposes the language as a `data-language` attribute; use `'text'` (or `'plaintext'`) to turn highlighting off.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
## Value
A string, including line breaks and indentation. It's empty until an editor enters something, unless you set an `initialValue`.
Alinea doesn't sanitize the value. If you render it as HTML (`dangerouslySetInnerHTML`), anyone who can edit the entry can add scripts to your site: limit the field to trusted editors with [roles](https://v2.alineacms.com/docs/roles-permissions), or validate what's allowed.
### Object (https://v2.alineacms.com/docs/fields/object)
An object field groups fields and stores their values together as one object. Use it for data that belongs together, like an address or a call to action, and to reuse such a group in several types.
```tsx
import {Field} from 'alinea'
Field.object('Address', {
fields: {
street: Field.text('Street'),
zip: Field.text('Zip code', {width: 0.5}),
city: Field.text('City', {width: 0.5})
}
})
```
```
import {Config, Field} from 'alinea'
export default Config.type('Object field', {
fields: {
object: Field.object('Address', {
fields: {
street: Field.text('Street'),
zip: Field.text('Zip code', {width: 0.5}),
city: Field.text('City', {width: 0.5})
}
})
}
})
```
## Options
- `fields` (required): the fields of the object, keyed by name. Any field type works, including lists and other objects.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
## Value
An object with a key per field: `{street: '', zip: '', city: ''}`. New entries start with the initial values of the inner fields. Link and image fields inside the object are resolved when you query it, just like at the top level.
## Querying
Select and sort on the object as a whole: the inner fields aren't registered in the schema, so `Venue.address.city` can't be used in `select` or `orderBy`. To filter on an inner value, use `has`:
```tsx
import {cms} from '@/cms'
import {Config, Field} from 'alinea'
export const Venue = Config.document('Venue', {
fields: {
address: Field.object('Address', {
fields: {
street: Field.text('Street'),
city: Field.text('City')
}
})
}
})
const venuesInGhent = await cms.find({
type: Venue,
filter: {address: {has: {city: 'Ghent'}}},
select: {address: Venue.address}
})
```
## Reusing a group of fields
Like any field, an object instance can only be used once. Wrap it in a function to add the same group to several types:
```tsx
import {Config, Field} from 'alinea'
function cta(label: string) {
return Field.object(label, {
fields: {
label: Field.text('Label', {width: 0.5}),
link: Field.link('Link', {width: 0.5})
}
})
}
export const Hero = Config.type('Hero', {
fields: {
title: Field.text('Title'),
primary: cta('Primary button'),
secondary: cta('Secondary button')
}
})
```
### Link (https://v2.alineacms.com/docs/fields/link)
A link field lets editors link to a page in the CMS, an external url or an uploaded file, whichever they need. Use it for buttons and calls to action. When a link can only be one kind, use the [Entry](https://v2.alineacms.com/docs/fields/entry), [Url](https://v2.alineacms.com/docs/fields/url), [File](https://v2.alineacms.com/docs/fields/file) or [Image](https://v2.alineacms.com/docs/fields/image) field instead: they have a simpler picker and a narrower type.
```tsx
import {Field} from 'alinea'
Field.link('Button link')
Field.link.multiple('Related links', {max: 5})
```
```
import {Config, Field} from 'alinea'
export default Config.type('Link field', {
fields: {
link: Field.link('Single link'),
linkMultiple: Field.link.multiple('Multiple links'),
linkWithLabel: Field.link('Link with label', {
fields: {label: Field.text('Label')}
})
}
})
```
## Options
- `fields`: extra fields stored on each link, such as a label or an "open in new tab" toggle. Pass an object of fields or a type.
- `max` (multiple only): the maximum number of links.
- `allowDuplicates` (multiple only): allow the same link more than once. Default `true` for `Field.link.multiple`.
- `condition` and `location`: limit which entries the page picker offers and where it opens, see the [Entry field](https://v2.alineacms.com/docs/fields/entry). They don't affect the file picker.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
## Allowing only some kinds of links
`Field.link` accepts all three kinds. To accept a single kind, use the field made for it:
- Pages, external urls and files: `Field.link`
- Only pages (internal links): [`Field.entry`](https://v2.alineacms.com/docs/fields/entry)
- Only external urls: [`Field.url`](https://v2.alineacms.com/docs/fields/url)
- Only files from the media library: [`Field.file`](https://v2.alineacms.com/docs/fields/file)
- Only images: [`Field.image`](https://v2.alineacms.com/docs/fields/image)
Each has the same `.multiple` variant, a narrower value type and a picker without the tabs for the other kinds.
`Field.link` has no option to hide one of its kinds. To accept two of them, such as a page or a url but no file, reject the third in `validate`. The stored link's `_type` is `'entry'`, `'url'` or `'file'`:
```tsx
import {Field} from 'alinea'
// A page or an external url, but no file
Field.link('Button link', {
validate(link) {
if (link?._type === 'file') return 'Link to a page or a url'
}
})
```
To offer only some pages, pass a `condition`. It applies to the page picker, the url and file pickers are unaffected:
```tsx
import {Field} from 'alinea'
Field.link('Button link', {
// Only offer pages of these types in the page picker
condition: {_type: {in: ['Page', 'BlogPost']}}
})
```
## Value
When you query a link field, every link is resolved to what you need to render it. Check `_type` to see which kind it is:
- `'entry'`: a page. `href` and `url` hold its current url (so the link survives moves and renames), plus `title`, `path`, `entryId` and `entryType`.
- `'url'`: an external link with the `url` and `href` (the same value), `title` and `target` (`'_blank'` or `'_self'`) the editor entered.
- `'file'`: an uploaded file with its `url` and `href` (the same value), `title`, `extension` and `size` in bytes.
The values of the extra `fields` are on `link.fields`. A single link is `null` while it's empty, a multiple link field is an array.
```tsx
import type {Link as LinkValue} from 'alinea'
import Link from 'next/link'
interface ButtonProps {
link: LinkValue<{label: string}> | null
}
export function Button({link}: ButtonProps) {
if (!link?.href) return null
const label = link.fields.label || link.title
if (link._type === 'url')
return (
{label}
)
return {label}
}
```
Links to entries that were deleted, or that aren't published, are dropped from multiple link fields. A single link to such an entry keeps its stored reference but has no `href`, so always check `href` before rendering.
## Extra fields
```tsx
import {Field} from 'alinea'
Field.link('Call to action', {
fields: {
label: Field.text('Label', {width: 0.5}),
style: Field.select('Style', {
width: 0.5,
initialValue: 'primary',
options: {primary: 'Primary', secondary: 'Secondary'}
})
}
})
```
Editors fill in these fields in the link's row after picking the target. `LinkValue<{label: string}>` in the example above types them; see [Infer](https://v2.alineacms.com/docs/typescript) to derive the type from the fields instead.
### Entry (https://v2.alineacms.com/docs/fields/entry)
An entry field links to other entries in the CMS: the author of a post, related articles, the categories of a product. Editors pick entries in a dialog, and you can limit what they can choose.
```tsx
import {Field} from 'alinea'
Field.entry('Author', {
condition: {_type: 'Author'}
})
Field.entry.multiple('Related posts', {
condition: {_type: 'BlogPost'},
max: 3
})
```
## Options
- `condition`: only offer entries that match this [filter](https://v2.alineacms.com/docs/query/filtering). The picker then shows all matches in one flat list, across locations. Can be a function, see below.
- `location`: the workspace and root (and optionally `parentId` and `locale`) the picker opens in, for example `{workspace: 'main', root: 'pages'}`. Can be a function.
- `limitLocations`: an array of `{workspace, root}` locations editors can browse. Others are hidden in the picker.
- `pickChildren`: offer only the direct children of the entry being edited, in a flat list.
- `defaultView`: show the picker's results as `'row'` (default) or `'thumb'`.
- `fields`: extra fields stored on each link, like the [Link field](https://v2.alineacms.com/docs/fields/link).
- `max` (multiple only): the maximum number of entries.
- `allowDuplicates` (multiple only): allow the same entry more than once. Default `false`.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
## Conditions
A condition is a [filter](https://v2.alineacms.com/docs/query/filtering) on the entries that can be picked, written with the `_` prefixed entry properties: `_type`, `_workspace`, `_root`, `_parentId`, `_status`, `_locale`, `_path` and `_url`. It can also filter on the fields of those entries, such as `{_type: 'BlogPost', category: 'news'}`. All operators work, such as `in` and `startsWith`:
```tsx
import {Field} from 'alinea'
// One type
Field.entry('Author', {condition: {_type: 'Author'}})
// Several types
Field.entry('Parent page', {
condition: {_type: {in: ['Page', 'Blog']}}
})
// Only posts below /blog in the pages root
Field.entry.multiple('Highlights', {
condition: {_type: 'BlogPost', _root: 'pages', _url: {startsWith: '/blog/'}}
})
```
## Where the picker opens
Without a `condition`, editors browse the content tree in the picker, starting in the root of the entry being edited. `location` sets another starting point: a workspace and root, optionally with a `parentId` to open inside a specific entry, and a `locale`. `limitLocations` lists the workspace and root pairs editors can switch to. Without it, they can browse every workspace and root.
```tsx
import {Field} from 'alinea'
declare const blogId: string
// Open the picker below the blog entry
Field.entry('Related post', {
location: {workspace: 'main', root: 'pages', parentId: blogId}
})
// Only let editors browse the pages roots of two workspaces
Field.entry('Page', {
location: {workspace: 'main', root: 'pages'},
limitLocations: [
{workspace: 'main', root: 'pages'},
{workspace: 'docs', root: 'pages'}
]
})
```
With a `condition`, the picker shows a flat list of the matching entries instead of the tree. Without a `location`, that list covers every workspace and root. With a `location`, it starts in that workspace and root, and when you set a `parentId` it only lists entries below that entry.
A `location` outside `limitLocations` isn't used: the picker opens in the first allowed location of the same workspace instead, or else the first one in the list.
## Combining the options
Each option restricts one thing, so combine them to get exactly the entries you want:
- Types: `condition: {_type: 'BlogPost'}`, or `{_type: {in: [...]}}` for several.
- Workspaces: `condition: {_workspace: 'main'}` limits what can be selected, `limitLocations` limits what editors can browse.
- Roots: `condition: {_root: 'pages'}`, or `limitLocations` to hide other roots.
- Starting point: `location`, with a `parentId` to start inside an entry. `pickChildren` uses the entry being edited as the parent.
- Number of entries: `max` on `Field.entry.multiple`, `required` to demand at least one.
This field accepts up to three blog posts, picked from below the blog entry:
```tsx
import {Field} from 'alinea'
declare const blogId: string
Field.entry.multiple('Related posts', {
// Only blog posts can be selected
condition: {_type: 'BlogPost'},
// List the posts below the blog entry in the pages root
location: {workspace: 'main', root: 'pages', parentId: blogId},
// Don't offer other workspaces and roots
limitLocations: [{workspace: 'main', root: 'pages'}],
// Pick up to three
max: 3
})
```
`condition` and `location` also work on the [Link field](https://v2.alineacms.com/docs/fields/link), where they apply to its page picker.
## Dynamic options
`condition` and `location` also accept a function. It receives the entry being edited (`id`, `type`, `workspace`, `root`, `parentId` and `locale`) and a `graph` to run queries with, and can be async. That's useful when the same type lives in several workspaces:
```tsx
import {Field} from 'alinea'
Field.entry('Author', {
// Open the picker in the authors root of the workspace being edited
location: ({entry}) => ({workspace: entry.workspace, root: 'authors'}),
// And only offer authors from that workspace
condition: ({entry}) => ({_type: 'Author', _workspace: entry.workspace})
})
```
## Picking children
With `pickChildren` the picker lists the children of the entry being edited, for example to choose a featured item among the entries of a collection:
```tsx
import {Field} from 'alinea'
Field.entry('Featured book', {
condition: {_type: 'Book'},
pickChildren: true
})
```
## Value
When you query an entry field, each link is resolved to the target's current data: `entryId`, `entryType`, `title`, `path`, and its `url` (also as `href`), with the values of any extra `fields` on `link.fields`. A single entry field is `null` while it's empty, a multiple one is an array.
Targets are looked up in the locale of the entry you queried (or entries without a locale) and with the same status. Links to entries that were deleted or aren't published are dropped from multiple fields; a single link to one has no `url`.
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
const post = await cms.get({type: BlogPost, path: 'hello-world'})
post.author?.title // "Jane Doe"
post.author?.url // "/authors/jane-doe"
post.categories.map(category => category.title)
```
## Querying fields of the linked entries
The resolved link only holds the basics. To get other fields of the target, query through the field: `.first()` on a single entry field, `.find()`, `.first()` or `.count()` on a multiple one. They take the same options as a query, including `select`, `filter` and `orderBy`:
```tsx
import {cms} from '@/cms'
import {Author, BlogPost} from '@/schema'
import {Query} from 'alinea'
const post = await cms.get({
type: BlogPost,
path: 'hello-world',
select: {
title: Query.title,
author: BlogPost.author.first({
select: {name: Query.title, avatar: Author.avatar, bio: Author.bio}
}),
categories: BlogPost.categories.find({
select: {title: Query.title, url: Query.url},
orderBy: {asc: Query.title}
})
}
})
```
To go the other way, from an author to their posts, filter on the field. See [Related content](https://v2.alineacms.com/docs/query/related).
### Url (https://v2.alineacms.com/docs/fields/url)
A url field holds a link to an external resource: a website, a `mailto:` or `tel:` link, a social profile. Editors enter the url, an optional title and whether it opens in a new tab. For links that can also point to pages in the CMS, use the [Link field](https://v2.alineacms.com/docs/fields/link).
```tsx
import {Field} from 'alinea'
Field.url('Website')
Field.url.multiple('Social profiles', {max: 5})
```
```
import {Config, Field} from 'alinea'
export default Config.type('Url field', {
fields: {
url: Field.url('Website'),
urlMultiple: Field.url.multiple('Social profiles', {max: 5})
}
})
```
## Options
- `fields`: extra fields stored on each link, such as a label or an icon choice. Pass an object of fields or a type.
- `max` (multiple only): the maximum number of links.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
## Value
Each link is resolved to `{url, href, title, target, fields}`: `url` and `href` both hold the url as entered, `target` is `'_blank'` when the editor chose a new tab and `'_self'` otherwise, and `fields` holds your extra fields. A single url field is `null` while it's empty, a multiple one is an array.
```tsx
import type {UrlLink} from 'alinea'
export function ExternalLink({link}: {link: UrlLink | null}) {
if (!link?.href) return null
return (
{link.title || link.href}
)
}
```
The picker only checks that the url can be parsed, so relative urls such as `/contact` are accepted too. Add a `validate` function if you need a specific format, for example urls that start with `https://`.
### File (https://v2.alineacms.com/docs/fields/file)
A file field links to a file in the [media library](https://v2.alineacms.com/docs/workspaces/media): a PDF brochure, a price list, a download. Editors pick it from the files uploaded to the media library. For images you want to display, use the [Image field](https://v2.alineacms.com/docs/fields/image): it returns dimensions and a blur placeholder too.
```tsx
import {Field} from 'alinea'
Field.file('Brochure')
Field.file.multiple('Downloads', {max: 10})
```
```
import {Config, Field} from 'alinea'
export default Config.type('File field', {
fields: {
file: Field.file('Brochure'),
fileMultiple: Field.file.multiple('Downloads', {
fields: {label: Field.text('Label')}
})
}
})
```
## Options
- `fields`: extra fields stored on each link, such as a label or description.
- `max` (multiple only): the maximum number of files.
- `location` and `limitLocations`: open the picker in, or limit it to, specific media roots or folders, like the [Entry field](https://v2.alineacms.com/docs/fields/entry).
- `defaultView`: show the picker as `'thumb'` (default) or `'row'`.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
The picker offers every file in the media library, images included.
## Value
Each file is resolved to:
- `url` and `href`: the url of the file on your site (both hold the same value), such as `/admin/file/brochure.pdf`. It's served through your Alinea handler and keeps working when the file is moved or renamed.
- `title`: the file's title in the media library.
- `extension`: the file extension including the dot, `'.pdf'`.
- `size`: the size in bytes.
- `fields`: the values of your extra fields.
A single file field is `null` while it's empty, a multiple one is an array.
```tsx
import type {FileLink} from 'alinea'
export function Download({file}: {file: FileLink | null}) {
if (!file?.href) return null
const kb = Math.round(file.size / 1024)
return (
{file.title} ({file.extension.slice(1).toUpperCase()}, {kb} KB)
)
}
```
### Image (https://v2.alineacms.com/docs/fields/image)
An image field links to an image in the [media library](https://v2.alineacms.com/docs/workspaces/media). Queries return everything you need to render it well: the url, dimensions, alt text, the focal point the editor set and a tiny placeholder to show while it loads.
```tsx
import {Field} from 'alinea'
Field.image('Cover image')
Field.image.multiple('Gallery')
```
```
import {Config, Field} from 'alinea'
export default Config.type('Image field', {
fields: {
image: Field.image('Cover image'),
imageMultiple: Field.image.multiple('Gallery')
}
})
```
## Options
- `fields`: extra fields stored on each image, such as a caption or a credit.
- `max` (multiple only): the maximum number of images.
- `location` and `limitLocations`: open the picker in, or limit it to, specific media roots or folders, like the [Entry field](https://v2.alineacms.com/docs/fields/entry).
- `defaultView`: show the picker as `'thumb'` (default) or `'row'`.
- The [common options](https://v2.alineacms.com/docs/fields) `help`, `width`, `inline`, `initialValue`, `required`, `validate`, `readOnly`, `hidden` and `shared`.
The picker only offers images: files with an extension such as `.jpg`, `.png`, `.webp`, `.gif`, `.avif` or `.svg`.
## Value
Each image is resolved to an `ImageLink`:
- `src`: the url of the image on your site, such as `/admin/file/landscape.jpg?v=3f2a…`. It's served through your Alinea handler, and the `v` parameter changes when the file is replaced, so caches never serve an outdated copy.
- `url`: the same as `src`.
- `width` and `height`: the dimensions in pixels.
- `alt`: the alt text editors enter in the media library, in the locale of the entry you queried when the media root is [translated](https://v2.alineacms.com/docs/workspaces/media).
- `focus`: the focal point as `{x, y}`, each between 0 and 1. Use it as `object-position` when you crop the image.
- `thumbHash` and `averageColor`: a compact placeholder and the average color (`'#aabbcc'`) to show while the image loads.
- `title`, `extension`, `size` (bytes) and `hash`.
- `fields`: the values of your extra fields.
A single image field is `null` while it's empty, a multiple one is an array.
## Rendering with next/image
`withAlinea` allows the image urls in your Next.js image config, so you can pass them to `next/image` directly. `imageBlurUrl` from `alinea/ui` turns the thumbhash into a data url for the blur placeholder:
File: components/CmsImage.tsx
```tsx
import type {ImageLink} from 'alinea'
import {imageBlurUrl} from 'alinea/ui'
import Image from 'next/image'
interface CmsImageProps {
image: ImageLink | null
sizes?: string
}
export function CmsImage({image, sizes}: CmsImageProps) {
if (!image?.src) return null
const blurDataURL = imageBlurUrl(image) || undefined
return (
)
}
```
## Alt text and captions
`image.alt` is the alt text editors enter once, in the media library. When an image needs a description that depends on where it's used, or a caption or credit, add extra fields with the `fields` option. Editors fill them in next to the image they picked, and the values are stored with the link in your entry, not on the media file:
```tsx
import {Config, Field} from 'alinea'
export const Article = Config.document('Article', {
fields: {
image: Field.image('Image', {
fields: {
alt: Field.text('Alt text', {required: true}),
caption: Field.text('Caption'),
credit: Field.text('Credit', {width: 0.5})
}
})
}
})
```
Options like `required`, `help` and `width` work on these fields as usual: an entry with an image but without its alt text can't be published.
Query results keep the extra values on `image.fields`, next to the properties of the image. An extra field named `alt` does not replace the alt text of the media library: `image.alt` still holds that one, and `image.fields.alt` the one entered on this entry. `ImageLink` takes the type of the extra fields as its type parameter:
```tsx
import type {ImageLink} from 'alinea'
interface FigureProps {
image: ImageLink<{alt: string; caption: string; credit: string}> | null
}
export function Figure({image}: FigureProps) {
if (!image?.src) return null
const {alt, caption, credit} = image.fields
return (
{caption && (
{caption} {credit && {credit}}
)}
)
}
```
In a multiple image field every image gets its own values. The [Link](https://v2.alineacms.com/docs/fields/link), [Entry](https://v2.alineacms.com/docs/fields/entry), File and Url fields take the same `fields` option and return the values on `link.fields` too.
### Workspaces and roots (https://v2.alineacms.com/docs/workspaces)
A workspace is a separate set of content with its own content directory and [roots](https://v2.alineacms.com/docs/workspaces/root). Most sites need one. Add more to manage several websites, or clearly separate areas such as a website and an app, from one dashboard: editors switch between them in the sidebar.
File: cms.ts
```tsx
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'
import * as schema from './schema'
export const cms = createCMS({
schema,
workspaces: {
main: Config.workspace('My site', {
source: 'content',
mediaDir: 'public/media',
color: '#3F61E8',
roots: {
// Website pages
pages: Config.root('Pages', {contains: ['Page', 'Blog']}),
// Uploaded images and files
media: Config.media()
}
})
},
handlerUrl: '/api/cms'
})
```
## Options
`Config.workspace(label, options)` takes the label shown in the dashboard and:
- `source` (required): the directory, relative to your project, where the workspace's content files are stored.
- `roots` (required): the [roots](https://v2.alineacms.com/docs/workspaces/root) of the workspace, keyed by name. Add a [media root](https://v2.alineacms.com/docs/workspaces/media) to allow uploads.
- `mediaDir`: the directory where uploaded files are stored, such as `public/media`. Keep it inside your public folder (`public`, or the [`publicDir`](https://v2.alineacms.com/docs/configuration) you configured): media urls point to `/admin/file/...`, and the handler serves them from the public copy of the file.
- `mediaUrl`: a prefix for the workspace's media urls, for example `'shop'` gives `/admin/file/shop/logo.png`. Useful when several workspaces have files with the same name.
- `color`: the accent color of the workspace in the dashboard. By default a color is derived from the label.
- `icon`: a React component shown next to the workspace name.
- `preview`: turn [live previews](https://v2.alineacms.com/docs/live-previews) on or off for everything in this workspace, or pass a component that renders the preview.
The key of a workspace (`main`) is its name in content and queries. Like type names, it has to start with a letter and contain only letters, digits and underscores.
Don't change the `mediaDir` of a workspace that already has uploads: media entries store the location of their file relative to it.
## Several workspaces
With more than one workspace, every `source` directory must be named after its workspace key, and they must all be in the same parent directory:
File: cms.ts
```tsx
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'
import * as schema from './schema'
export const cms = createCMS({
schema,
workspaces: {
main: Config.workspace('Website', {
source: 'content/main',
mediaDir: 'public/media/main',
roots: {
pages: Config.root('Pages'),
media: Config.media()
}
}),
docs: Config.workspace('Documentation', {
source: 'content/docs',
mediaDir: 'public/media/docs',
roots: {
pages: Config.root('Pages'),
media: Config.media()
}
})
},
handlerUrl: '/api/cms'
})
```
All workspaces share the schema. Limit which types can be created where with the `contains` option of each root, and which editors can access a workspace with [roles](https://v2.alineacms.com/docs/roles-permissions).
Entry urls don't include the workspace, so give each workspace its own routes, or use [`entryUrl`](https://v2.alineacms.com/docs/schema/type) to add a prefix.
## Querying a workspace
Workspaces and roots are available on your CMS instance, so you can scope queries without repeating names:
```tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
const pages = await cms.find({
workspace: cms.workspaces.main,
select: {title: Query.title, url: Query.url}
})
```
Pass a root such as `cms.workspaces.main.pages` to `root` or `location` to narrow it down further, see [Querying](https://v2.alineacms.com/docs/query/structural).
### Roots (https://v2.alineacms.com/docs/workspaces/root)
A root is a top-level section of a [workspace](https://v2.alineacms.com/docs/workspaces), shown in the dashboard sidebar with its own tree of entries. Entries can have children, those children can have children, and so on. Use separate roots for content that's managed differently: pages, reusable settings, a collection of authors, [media](https://v2.alineacms.com/docs/workspaces/media).
```tsx
import {Config} from 'alinea'
import {Home, Settings} from './schema'
export const pages = Config.root('Pages', {
contains: ['Page', 'Blog'],
i18n: {locales: ['en', 'fr']},
children: {
index: Config.page({type: Home, fields: {title: 'Home'}})
}
})
export const settings = Config.root('Settings', {
contains: [],
children: {
settings: Config.page({type: Settings, fields: {title: 'Site settings'}})
}
})
```
## Options
`Config.root(label, options)` takes the label shown in the sidebar and:
- `contains`: the types that can be created at the top level of the root. Entries below them follow the `contains` of their parent's type.
- `children`: entries that always exist in this root, see "Seeded entries" below.
- `i18n`: make the root translatable with `{locales: ['en', 'fr']}`. The first locale is the default in the dashboard.
- `overview`: how the dashboard lists the top-level entries: columns, the default order (`sort`), the layout and actions, see below.
- `icon`: a React component shown next to the root in the sidebar.
- `preview`: turn [live previews](https://v2.alineacms.com/docs/live-previews) on or off for entries in this root, or pass a component that renders the preview. A type's own `preview` takes precedence.
- `view`: a React component, or a path to one, that replaces the dashboard view of the root. It receives `{root}`. Use it to add a [custom page](https://v2.alineacms.com/docs/overviews#custom-dashboard-pages) to the dashboard.
- `openByDefault`: open this root when the workspace is opened without a specific root. Otherwise the first root opens.
- `orderChildrenBy` (deprecated): sort the top-level entries by one or more fields. Use `overview.sort`.
The key of a root (`pages`) is its name in content files and queries. It must start with a letter and contain only letters, digits and underscores. The first root of a workspace is the one the dashboard opens by default, unless another root sets `openByDefault`.
## Overview
Opening a root lists its top-level entries. `overview` adds columns and sets the default order, which the sidebar tree follows too:
File: schema/authors.ts
```tsx
import {Config, Field} from 'alinea'
export const Author = Config.document('Author', {
fields: {
avatar: Field.image('Avatar'),
role: Field.text('Role')
}
})
export const authors = Config.root('Authors', {
contains: ['Author'],
overview: {
columns: {
role: Config.column({header: 'Role', select: Author.role})
},
sort: {asc: Author.title},
layout: 'cards'
}
})
```
See [Overviews](https://v2.alineacms.com/docs/overviews) for all options. Types configure the list of their children the same way.
## Seeded entries
`children` creates entries from your config. Alinea adds them when it finds them missing, in every locale of the root, and editors can't move or delete them. That's the way to make sure a home page, a settings entry or a fixed overview page always exists:
```tsx
import {Config} from 'alinea'
import {Blog, Home} from './schema'
export const pages = Config.root('Pages', {
contains: ['Page'],
children: {
// The key is the path: "index" becomes the home page at /
index: Config.page({type: Home, fields: {title: 'Home'}}),
blog: Config.page({type: Blog, fields: {title: 'Blog'}})
}
})
```
`Config.page` takes:
- `type` (required): the type of the entry. It must be in your schema.
- `fields`: default field values. They apply until an editor saves the entry. Without a `title`, the key is used.
- `children`: seeded entries below this one, in the same form.
Removing a page from `children` doesn't delete its entry: it stays in your content and becomes a regular entry that editors can move and delete.
To query a seeded settings entry, filter by its type: `cms.get({type: Settings})`. If you created the entry by hand before seeding it, Alinea adopts the existing file with the same path.
## Translations
With `i18n`, editors can translate every entry of the root into each locale, and each translation is a separate version with its own path and content. Urls start with the locale (`/en/about`, `/fr/about`), and content files are stored in a folder per locale (`content/pages/en/about.json`). Query a translation with the `locale` option, or all of them with `Query.translations`.
Leave `i18n` out of roots that aren't translated, such as a list of authors. See [Internationalization](https://v2.alineacms.com/docs/internationalization) for translating pages, per-field translations and dropping the locale from urls.
```tsx
import {Config} from 'alinea'
export const pages = Config.root('Pages', {
contains: ['Page'],
i18n: {locales: ['en', 'fr', 'nl']}
})
```
## Roots in queries
Roots are available on the CMS instance under their workspace, `cms.workspaces.main.pages`, and can be passed to the `root` or `location` query options:
```tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
const navigation = await cms.find({
root: cms.workspaces.main.pages,
locale: 'en',
level: 0,
select: {title: Query.title, url: Query.url}
})
```
### Media (https://v2.alineacms.com/docs/workspaces/media)
A media root holds the images and files editors upload. Add one to a [workspace](https://v2.alineacms.com/docs/workspaces) with `Config.media()` and set the workspace's `mediaDir` to the folder the files are written to. Link to uploads with the [Image](https://v2.alineacms.com/docs/fields/image), [File](https://v2.alineacms.com/docs/fields/file) and [Link](https://v2.alineacms.com/docs/fields/link) fields.
```tsx
import {Config} from 'alinea'
export const main = Config.workspace('My site', {
source: 'content',
mediaDir: 'public/media',
roots: {
pages: Config.root('Pages'),
media: Config.media()
}
})
```
Editors upload files in the media root of the dashboard and organize them in folders.
## What's stored
Every upload is saved in two places:
- The file itself goes to `mediaDir`, named after the original file plus the id of its entry: `public/media/landscape.2V4c3kRS.jpg`.
- A media entry is stored with your content, in the media root: `content/media/landscape.json`. It holds the title, the file location, the extension and size, a content hash and, for images, the width and height, the average color, a thumbhash placeholder, the focal point and the alt text.
Folders are entries too. Links store the id of the media entry, so moving or renaming a file in the dashboard doesn't break them.
## Urls
Your site serves uploads at `/admin/file//.`, below the `adminPath` of your config. `withAlinea` forwards those requests to your Alinea handler, which serves the public copy of the file. When a file is moved or renamed, its old url keeps working. Set `mediaUrl` on the workspace to add a prefix, for example when two workspaces could have files with the same name.
So keep `mediaDir` inside your public folder, and don't change it once there are uploads: entries store file locations relative to it.
## Options
`Config.media(options)` takes the same options as [`Config.root`](https://v2.alineacms.com/docs/workspaces/root), with media-specific behaviour:
- `i18n`: translate the alt text of files. Files themselves aren't localised: every locale uses the same file, and editors enter an alt text per locale. Image links return the alt text in the locale of the entry you queried. Add a `fallback` function to choose which locales to try when a translation is missing: `fallback: locale => ['en']`.
- `children`: folders that always exist.
- `icon`, `orderChildrenBy` and `view`, as for other roots.
The label of a media root is always "Media". A workspace can have several media roots; uploads that don't target a specific folder go to the first one.
## Limits and production
- Set `maxUploadSize` in your [config](https://v2.alineacms.com/docs/configuration) (in bytes) to refuse larger uploads.
- Committing uploads to Git works well for images and documents, less so for large videos. When you deploy with your own backend, uploads can go to S3-compatible storage instead, see [Self-hosted](https://v2.alineacms.com/docs/deploy/self-host).
### Querying content (https://v2.alineacms.com/docs/query)
Read content with the methods on your CMS instance. Queries are plain objects, typed from your schema, and run on the server: in server components, route handlers, `generateMetadata` and `generateStaticParams`. They read from a local copy of your content that ships with your deploy and is kept in sync when the content changes, see [Instant publishing](https://v2.alineacms.com/docs/instant-publishing).
File: app/page.tsx
```tsx
import {cms} from '@/cms'
import {HomePage} from '@/schema'
export default async function Page() {
const home = await cms.get({type: HomePage})
return
{home.title}
}
```
## Methods
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
// All matches, as an array (possibly empty)
const posts = await cms.find({type: BlogPost})
// The first match, or null
const latest = await cms.first({
type: BlogPost,
orderBy: {desc: BlogPost.publishDate}
})
// The first match, or throws "Entry not found"
const post = await cms.get({type: BlogPost, path: 'hello-world'})
// The number of matches
const total = await cms.count({type: BlogPost})
```
Use `first` when a missing entry is a normal case, such as a url that may not exist (call `notFound()` when it returns `null`), and `get` for entries that must exist, like a seeded home page.
## What a query returns
Without a `select`, you get the fields of the type you passed as `type`, plus the entry's own properties, prefixed with an underscore. A query without `type` returns only those properties.
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
const post = await cms.get({type: BlogPost, path: 'hello-world'})
post.title // a field of the type
post.publishDate // "2026-09-23"
post.author // link fields are resolved: {title, url, ...} or null
post._id // the entry id
post._url // "/blog/hello-world"
post._locale // "en", or null in roots without i18n
```
The entry properties are `_id`, `_type`, `_index`, `_workspace`, `_root`, `_status`, `_parentId`, `_locale`, `_path`, `_url`, `_createdAt` and `_updatedAt`.
With `select` you get exactly what you ask for, in the shape you ask for. Select fields through the type (`BlogPost.publishDate`) and entry properties through `Query`:
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'
const cards = await cms.find({
type: BlogPost,
select: {
title: Query.title,
url: Query.url,
date: BlogPost.publishDate,
cover: BlogPost.cover
}
})
// Array<{title: string, url: string, date: string, cover: ImageLink | null}>
// A single expression returns plain values
const urls = await cms.find({type: BlogPost, select: Query.url})
// Array
```
`Query` has `id`, `title`, `type`, `index`, `workspace`, `root`, `status`, `parentId`, `locale`, `path`, `url`, `aliases`, `createdAt` and `updatedAt`, plus the relation helpers such as `Query.children` described in [Related content](https://v2.alineacms.com/docs/query/related) and `Query.snippet` for [search results](https://v2.alineacms.com/docs/query/filtering).
Selecting only what a page needs keeps the data you pass to client components small, and TypeScript infers the result either way. To name the type of a result, use `Awaited>`.
## Query options
- Which entries: `type`, `id`, `path`, `url`, `parentId`, `workspace`, `root`, `location`, `level`, `locale`, `status` and more, see [Structural queries](https://v2.alineacms.com/docs/query/structural).
- Matching content: `filter` on field values and `search` for full text, see [Filtering](https://v2.alineacms.com/docs/query/filtering).
- Shape and order: `select`, `include`, `orderBy`, `groupBy`, `skip` and `take`, see [Structural queries](https://v2.alineacms.com/docs/query/structural).
- Related entries: parents, children, siblings, translations and linked entries in the same query, see [Related content](https://v2.alineacms.com/docs/query/related).
Options you pass as `undefined` are ignored, which makes it easy to build a query from optional parameters.
## Drafts and previews
Queries return published content by default. When Next.js draft mode is on, for example while an editor looks at a [live preview](https://v2.alineacms.com/docs/live-previews), Alinea switches to drafts where they exist and applies the unsaved changes of the preview, without any change to your queries. Pass `status` to override this.
## Example schema
The examples in this chapter use this schema:
File: schema.ts
```tsx
import {Config, Field} from 'alinea'
export const Author = Config.document('Author', {
fields: {
avatar: Field.image('Avatar'),
bio: Field.text('Bio', {multiline: true})
}
})
export const Category = Config.document('Category', {fields: {}})
export const Blog = Config.document('Blog', {
contains: ['BlogPost'],
fields: {}
})
export const BlogPost = Config.document('Blog post', {
fields: {
publishDate: Field.date('Publish date'),
intro: Field.text('Intro', {multiline: true, searchable: true}),
featured: Field.check('Featured'),
cover: Field.image('Cover image'),
author: Field.entry('Author', {condition: {_type: 'Author'}}),
categories: Field.entry.multiple('Categories', {
condition: {_type: 'Category'}
}),
tags: Field.select.multiple('Tags', {
options: {news: 'News', release: 'Release', tutorial: 'Tutorial'}
}),
body: Field.richText('Body', {searchable: true})
}
})
```
Structural (https://v2.alineacms.com/docs/query/structural)
### Structural (https://v2.alineacms.com/docs/query/structural)
Most queries start by saying which entries you want: of a type, at a url, in a root, in a locale. Then you choose what to get back and in which order. This page covers both; to match on field values see [Filtering](https://v2.alineacms.com/docs/query/filtering).
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'
const posts = await cms.find({
type: BlogPost,
root: cms.workspaces.main.pages,
locale: 'en',
select: {title: Query.title, url: Query.url},
orderBy: {desc: BlogPost.publishDate},
take: 10
})
```
## Selecting entries
- `type`: a type, or an array of types to match any of them: `type: [Page, BlogPost]`. It also narrows the result type.
- `id`: the entry id.
- `url`: the entry's url, for example `'/blog/hello-world'`. Handy in a catch-all route.
- `path`: the entry's path, the last segment of its url.
- `parentId`: the id of the parent entry. `null` matches entries at the top of a root.
- `level`: the depth in the tree. `0` is the top of a root, `1` their children, and so on.
- `workspace` and `root`: a name, or the object from your CMS instance: `cms.workspaces.main`, `cms.workspaces.main.pages`.
- `location`: a workspace, a root or a seeded page. With a seeded page, such as `cms.workspaces.main.pages.blog`, it matches the entries below it.
- `alias`: a previous url stored in the entry's [metadata aliases](https://v2.alineacms.com/docs/schema/document), to redirect old urls.
- `createdAt` and `updatedAt`: the audit timestamps of documents, in Unix seconds.
`id`, `url`, `path`, `parentId`, `level`, `alias`, `createdAt`, `updatedAt`, `workspace` and `root` take a value or a condition with the [operators](https://v2.alineacms.com/docs/query/filtering) from filters:
```tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
// Several ids
await cms.find({id: {in: ['2g8FtR', '2g8FtS']}})
// Everything below /docs
await cms.find({url: {startsWith: '/docs/'}})
// Top-level entries of a root, for a main menu
await cms.find({
root: cms.workspaces.main.pages,
level: 0,
select: {title: Query.title, url: Query.url}
})
// Entries below a seeded page
await cms.find({location: cms.workspaces.main.pages.blog})
```
## Locales
In a root with [i18n](https://v2.alineacms.com/docs/internationalization), every translation is a separate entry version. Without a `locale`, a query matches all of them, so `find` returns an entry once per language and `first` returns whichever comes first.
- `locale`: only entries in this locale (compared case-insensitively). `null` matches entries in roots without i18n.
- `preferredLocale`: entries in this locale plus entries without a locale. Use it when a query spans translated and untranslated roots.
```tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
export async function pageByUrl(locale: string, slug: Array) {
return cms.first({
locale,
url: `/${[locale, ...slug].join('/')}`,
select: {title: Query.title, type: Query.type}
})
}
```
## Status
`status` chooses between published, draft and archived versions:
- `'published'` (default): only published entries.
- `'draft'` or `'archived'`: only drafts, or only archived entries.
- `'preferDraft'`: one version per entry: the draft if there is one, then the published version, then the archived one. This is the default in Next.js draft mode.
- `'preferPublished'`: one version per entry: the published one if there is one, then the archived one, then the draft.
- `'all'`: every version, so an entry can appear more than once.
Relations inside the query use the same status.
## Choosing what to return
- `select`: the fields and properties to return, in any shape. A single expression, such as `select: Query.url`, returns plain values. See [What a query returns](https://v2.alineacms.com/docs/query).
- `include`: extra values to add to the full entry, typically [related entries](https://v2.alineacms.com/docs/query/related). It only applies when there's no `select`; with a `select`, put everything in the select.
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'
// All fields of the post, plus its parent
const post = await cms.get({
type: BlogPost,
path: 'hello-world',
include: {
blog: Query.parent({select: {title: Query.title, url: Query.url}})
}
})
// Only what the card needs
const card = await cms.get({
type: BlogPost,
path: 'hello-world',
select: {
title: Query.title,
intro: BlogPost.intro,
blog: Query.parent({select: {title: Query.title, url: Query.url}})
}
})
```
## Sorting and pagination
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'
const page = 2
const perPage = 10
const posts = await cms.find({
type: BlogPost,
select: {title: Query.title, url: Query.url},
orderBy: [
// Newest first
{desc: BlogPost.publishDate},
// Same date: alphabetical
{asc: Query.title}
],
skip: (page - 1) * perPage,
take: perPage
})
```
- `orderBy`: one `{asc: ...}` or `{desc: ...}`, or an array to break ties. Pass a field or a `Query` property. Text is compared case-insensitively unless you add `caseSensitive: true`, and entries without a value come last in both directions.
- Without `orderBy`, entries come in their stored order: the order of siblings in the sidebar. Queries with `search` are ordered by relevance.
- `skip` and `take`: skip a number of results and return at most `take`. Both must be whole numbers of 0 or more. For the total, run `cms.count` with the same options but without `skip` and `take`.
## One entry per value
`groupBy` keeps one entry for every distinct value of a field or property: the first match in stored order, or the most relevant one when searching. Sorting and pagination apply afterwards, to the remaining entries.
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'
// The first post of every blog
const firstPosts = await cms.find({
type: BlogPost,
groupBy: Query.parentId,
select: {title: Query.title, url: Query.url}
})
```
`groupBy` takes a single field. Because the entry for each value is picked before sorting, it can't give you "the newest post per blog", and it doesn't return lists of entries per value: for those, query the entries and group them in your own code. Group on plain values such as text, select or date fields; link fields store a unique id per link, so they don't group.
### Filtering (https://v2.alineacms.com/docs/query/filtering)
Use `filter` to match entries on the values of their fields, and `search` to find entries by the words they contain. Both combine with the [structural options](https://v2.alineacms.com/docs/query/structural) such as `type` and `locale`.
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
const featured = await cms.find({
type: BlogPost,
filter: {
featured: true,
publishDate: {gte: '2026-01-01'}
}
})
```
## Filter keys
A filter is an object with a condition per key. The keys are:
- the fields of the queried `type`, such as `featured` or `publishDate`. TypeScript checks them when you pass a `type`.
- the entry's own properties with an underscore: `_id`, `_type`, `_parentId`, `_path`, `_url`, `_status`, `_locale`, `_workspace`, `_root`, `_index`, `_createdAt` and `_updatedAt`.
Every key has to match, so several keys in one filter mean "and".
## Operators
A plain value checks for equality. For anything else, pass an object with one or more operators:
- `is` / `isNot`: equal or not equal. `{category: 'news'}` is short for `{category: {is: 'news'}}`.
- `in` / `notIn`: the value is one of (or none of) a list.
- `gt`, `gte`, `lt`, `lte`: greater than, greater or equal, less than, less or equal. Numbers compare as numbers, text compares alphabetically, which works for ISO dates such as `'2026-09-23'`.
- `startsWith`: text that starts with a prefix. It's case-sensitive.
- `or`: any of several conditions on the same value.
Operators in one object must all match:
```tsx
import {cms} from '@/cms'
import {Event} from '@/schema'
// Events in 2026 that cost less than 20, or have no price set
const events = await cms.find({
type: Event,
filter: {
date: {gte: '2026-01-01', lt: '2027-01-01'},
price: {or: [{lt: 20}, null]}
}
})
```
## Missing values
Entries that never got a value for a field, and entries created before you added it, have no value rather than an empty one:
- `null` matches missing values and `null`: `{cover: null}` finds posts without a cover image.
- `isNot`, `notIn` and the comparisons never match a missing value. `{featured: {isNot: true}}` skips entries where `featured` was never set; use `{featured: {in: [false, null]}}` to include them.
## Objects and lists
Some fields store an object or an array. Filter inside them with:
- `has`: a filter on the keys of an object, for [object fields](https://v2.alineacms.com/docs/fields/object) and single links.
- `includes`: at least one item of an array matches. Pass a value for arrays of plain values, such as a multiple [select](https://v2.alineacms.com/docs/fields/select), or a filter for arrays of objects, such as [lists](https://v2.alineacms.com/docs/fields/list) and multiple links.
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
const authorId = '2g8FtRVKqrzBhqNzjRvCTJlTWkn'
const categoryIds = ['2g8Fu0PUfDUNMH4KNvqHYCcRCyX', '2g8Fu4lyGY6m5sN4YhAp8ag0WcY']
// Posts by one author: a single entry link stores {_entry: id}
await cms.find({type: BlogPost, filter: {author: {has: {_entry: authorId}}}})
// Posts in any of these categories
await cms.find({
type: BlogPost,
filter: {categories: {includes: {_entry: {in: categoryIds}}}}
})
// Posts tagged "release"
await cms.find({type: BlogPost, filter: {tags: {includes: 'release'}}})
```
Links store the id of their target in `_entry`, which is why link filters look like this. See [Related content](https://v2.alineacms.com/docs/query/related) for more patterns.
## and / or
To combine whole filters, use `and` or `or` with an array of filters. They must be the only key of their object:
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
// Featured, or tagged as a release
await cms.find({
type: BlogPost,
filter: {or: [{featured: true}, {tags: {includes: 'release'}}]}
})
// Posts in every one of these categories
const categoryIds = ['2g8Fu0PUfDUNMH4KNvqHYCcRCyX', '2g8Fu4lyGY6m5sN4YhAp8ag0WcY']
await cms.find({
type: BlogPost,
filter: {
and: categoryIds.map(id => ({categories: {includes: {_entry: id}}}))
}
})
```
Filter keys with an `undefined` value are skipped, and so are `undefined` items in `and` and `or`. That makes optional filters easy:
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
export async function listPosts(tag?: 'news' | 'release', from?: string) {
return cms.find({
type: BlogPost,
filter: {
tags: tag ? {includes: tag} : undefined,
publishDate: from ? {gte: from} : undefined
}
})
}
```
## Search
`search` finds entries by words. Pass a string or an array of words:
```tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
export async function searchSite(terms: string, locale: string) {
return cms.find({
search: terms,
locale,
take: 20,
select: {
title: Query.title,
url: Query.url,
snippet: Query.snippet('', '', '…', 24)
}
})
}
```
How it matches:
- The input is split into words, and an entry must match every word.
- Words match as prefixes, so `alin` finds "Alinea".
- Small typos are tolerated: one wrong letter for words of 5 to 14 characters, more for longer words. Case and accents don't matter.
- Entry titles are always searched. Other fields only when you mark them `searchable: true`: [text](https://v2.alineacms.com/docs/fields/text) and [rich text](https://v2.alineacms.com/docs/fields/rich-text) fields support it, also inside lists, objects and rich text blocks.
- Results are ordered by relevance: entries whose title starts with the first word come first, and title matches weigh more than body matches. Add an `orderBy` to sort differently.
`Query.snippet(start, end, cutOff, limit)` returns a short excerpt of the searchable text around the matches, with matches wrapped in `start` and `end`. The defaults are `''`, `''`, `'...'` and 64, and `limit` (the length in words) can be at most 64. It can only be used together with `search`. The excerpt is plain text from your content: escape it, or render the markers yourself instead of using `dangerouslySetInnerHTML`.
### Related (https://v2.alineacms.com/docs/query/related)
A single query can also fetch entries related to the ones it finds: their parents, children and siblings, their translations, and the entries they link to. Put a relation in `select` (or in `include` when you don't select), and it's resolved in the same query.
```tsx
import {cms} from '@/cms'
import {Blog, BlogPost} from '@/schema'
import {Query} from 'alinea'
const blog = await cms.get({
type: Blog,
locale: 'en',
select: {
title: Query.title,
posts: Query.children({
type: BlogPost,
select: {title: Query.title, url: Query.url, date: BlogPost.publishDate},
orderBy: {desc: BlogPost.publishDate},
take: 5
})
}
})
```
Every relation takes the same options as a query, such as `type`, `filter`, `select`, `orderBy`, `skip` and `take`. `parent`, `previous` and `next` return a single entry, the others a list. Add `count: true` to get the number of matches instead, as in `Query.children({count: true})`, or `first: true` to get a single entry (or `null`). Relations can be nested, for example children with their own children.
## Tree relations
- `Query.parent(...)`: the parent entry, or nothing for entries at the top of a root.
- `Query.parents(...)`: all ancestors, starting at the top of the root. Pass `depth` to get only the nearest ones: `depth: 1` is the parent.
- `Query.children(...)`: the direct children. Pass `depth` to include deeper descendants too, returned as one flat list.
- `Query.siblings(...)`: the other children of the same parent. Add `includeSelf: true` to include the entry itself.
- `Query.previous(...)` and `Query.next(...)`: the sibling right before or after the entry, in sidebar order, or nothing at the ends.
- `Query.translations(...)`: the same entry in the other locales of the root. Add `includeSelf: true` to include the current locale, listed first.
Tree relations stay in the locale of the entry they start from (except translations, of course) and use the status of the outer query.
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'
export async function postPage(url: string) {
return cms.first({
type: BlogPost,
url,
select: {
title: Query.title,
body: BlogPost.body,
// Breadcrumbs, from the top of the root down
breadcrumbs: Query.parents({
select: {title: Query.title, url: Query.url}
}),
// Pagination at the bottom of the post
previous: Query.previous({
type: BlogPost,
select: {title: Query.title, url: Query.url}
}),
next: Query.next({
type: BlogPost,
select: {title: Query.title, url: Query.url}
}),
// A language switcher
translations: Query.translations({
select: {locale: Query.locale, url: Query.url}
})
}
})
}
```
`siblings`, `previous` and `next` find entries that share a parent. For an entry at the top level of a root, they find the other top-level entries of the same root (and locale).
## Linked entries
[Entry](https://v2.alineacms.com/docs/fields/entry), [Link](https://v2.alineacms.com/docs/fields/link), [Image](https://v2.alineacms.com/docs/fields/image) and [File](https://v2.alineacms.com/docs/fields/file) fields return the basics of what they link to. To get other fields of the linked entries, query through the field:
- a single link field has `.first(query)`.
- a multiple link field has `.find(query)`, `.first(query)` and `.count(query)`. Results keep the order the editor gave the links.
```tsx
import {cms} from '@/cms'
import {Author, BlogPost} from '@/schema'
import {Query} from 'alinea'
const post = await cms.get({
type: BlogPost,
path: 'hello-world',
select: {
title: Query.title,
author: BlogPost.author.first({
select: {name: Query.title, bio: Author.bio, avatar: Author.avatar}
}),
categories: BlogPost.categories.find({
select: {title: Query.title, url: Query.url}
})
}
})
```
Linked entries are looked up in the locale of the entry you queried, falling back to entries without a locale, so a link to an untranslated author keeps working from every translation of a post.
## The other direction
Links are stored on the entry that links, so "all posts by this author" is a filter on the posts. Filter on the id of the target, stored as `_entry`:
```tsx
import {cms} from '@/cms'
import {Author, BlogPost} from '@/schema'
import {Query} from 'alinea'
export async function authorPage(path: string) {
const author = await cms.get({
type: Author,
path,
select: {id: Query.id, name: Query.title, bio: Author.bio}
})
const posts = await cms.find({
type: BlogPost,
// Single link: has, multiple links: includes
filter: {author: {has: {_entry: author.id}}},
select: {title: Query.title, url: Query.url},
orderBy: {desc: BlogPost.publishDate}
})
return {author, posts}
}
```
For multiple links, such as categories, use `{categories: {includes: {_entry: id}}}`. More combinations are in [Filtering](https://v2.alineacms.com/docs/query/filtering) and the [examples](https://v2.alineacms.com/docs/query/examples).
### Advanced (https://v2.alineacms.com/docs/query/advanced)
Patterns for larger sites: nesting relations, querying several types at once, reusing parts of queries and controlling how fresh the content is.
## Nested relations
Relations can contain relations. The whole query, however deep, is resolved at once, so a navigation tree or a page with its linked content takes one call:
```tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
const menu = await cms.find({
root: cms.workspaces.main.pages,
locale: 'en',
level: 0,
select: {
title: Query.title,
url: Query.url,
children: Query.children({
select: {
title: Query.title,
url: Query.url,
children: Query.children({
select: {title: Query.title, url: Query.url}
})
}
})
}
})
```
Relations work inside link queries too, for example the url of each linked category's parent: `BlogPost.categories.find({select: {title: Query.title, parent: Query.parent({select: {url: Query.url}})}})`.
## Several types at once
Pass an array to `type` to match any of them. Select the properties they share, and `Query.type` to tell them apart:
```tsx
import {cms} from '@/cms'
import {BlogPost, Event} from '@/schema'
import {Query} from 'alinea'
const latest = await cms.find({
type: [BlogPost, Event],
select: {type: Query.type, title: Query.title, url: Query.url},
orderBy: {desc: Query.createdAt},
take: 10
})
```
Without a `select`, each result has the fields of its own type, and the result type is a union of the types.
## Reusing selections
Selections are plain objects, so you can define them once and share them between queries. TypeScript infers the result from wherever you use them:
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'
// Everything a post card needs
export const postCard = {
title: Query.title,
url: Query.url,
date: BlogPost.publishDate,
cover: BlogPost.cover
}
export type PostCard = Awaited>[number]
export function latestPosts(take = 3) {
return cms.find({
type: BlogPost,
select: postCard,
orderBy: {desc: BlogPost.publishDate},
take
})
}
export function featuredPosts() {
return cms.find({
type: BlogPost,
filter: {featured: true},
select: {...postCard, intro: BlogPost.intro}
})
}
```
## Freshness
Deployed sites keep their copy of the content in sync with the repository: a query first checks whether the content changed and syncs when it did, see [Instant publishing](https://v2.alineacms.com/docs/instant-publishing). As a fallback it also syncs at most once per `syncInterval` seconds from your [config](https://v2.alineacms.com/docs/configuration), 60 by default. Two query options change this per query:
- `syncInterval`: the fallback interval for this query, in seconds. `0` syncs every time.
- `disableSync: true`: read the local copy as it is, without checking for changes. Use it for queries that run very often and can be a little behind, such as search suggestions.
```tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
export function suggest(term: string) {
return cms.find({
search: term,
disableSync: true,
take: 5,
select: {title: Query.title, url: Query.url}
})
}
```
## Previews
The `preview` option carries the unsaved changes an editor is previewing. Alinea sets it for you when Next.js draft mode is on, so you don't pass it yourself: the same queries render published content for visitors and drafts in the [live preview](https://v2.alineacms.com/docs/live-previews).
## Performance tips
- Select only what a page needs. Rich text and list fields can be large, and everything you return from a server component to a client component is serialized.
- Count with `cms.count` instead of fetching entries and taking the length.
- Put related data in the same query instead of looping over results and querying for each of them.
- Search queries use a full text index that's built the first time you search, so the first search after a deploy can take a little longer.
Examples (https://v2.alineacms.com/docs/query/examples)
### Examples (https://v2.alineacms.com/docs/query/examples)
Complete queries for pages most sites have. They use the [example schema](https://v2.alineacms.com/docs/query) and the Next.js App Router, and assume a pages root without i18n unless noted.
## A page for every url
A catch-all route that renders whatever entry lives at the requested url, and prerenders all of them at build time:
File: app/[[...slug]]/page.tsx
```tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
import {notFound} from 'next/navigation'
interface PageProps {
params: Promise<{slug?: Array}>
}
export async function generateStaticParams() {
const urls = await cms.find({
root: cms.workspaces.main.pages,
select: Query.url
})
return urls.map(url => ({slug: url.split('/').filter(Boolean)}))
}
export default async function Page({params}: PageProps) {
const {slug = []} = await params
const page = await cms.first({
root: cms.workspaces.main.pages,
url: `/${slug.join('/')}`,
select: {type: Query.type, title: Query.title}
})
if (!page) notFound()
return
{page.title}
}
```
Render a different component per `page.type`, each fetching the data it needs by id or url.
## Blog overview with pagination
File: app/blog/page.tsx
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'
const perPage = 10
interface BlogPageProps {
searchParams: Promise<{page?: string}>
}
export default async function BlogPage({searchParams}: BlogPageProps) {
const page = Math.max(1, Number((await searchParams).page) || 1)
const [posts, total] = await Promise.all([
cms.find({
type: BlogPost,
select: {
title: Query.title,
url: Query.url,
date: BlogPost.publishDate,
intro: BlogPost.intro
},
orderBy: {desc: BlogPost.publishDate},
skip: (page - 1) * perPage,
take: perPage
}),
cms.count({type: BlogPost})
])
const pageCount = Math.ceil(total / perPage)
return (
{posts.map(post => (
)
}
```
## Related posts
Posts that share at least one category with the current post, newest first, without the post itself:
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'
export async function relatedPosts(postId: string) {
const post = await cms.get({
type: BlogPost,
id: postId,
select: {categories: BlogPost.categories}
})
const categoryIds = post.categories.map(category => category.entryId)
if (categoryIds.length === 0) return []
return cms.find({
type: BlogPost,
id: {isNot: postId},
filter: {categories: {includes: {_entry: {in: categoryIds}}}},
select: {title: Query.title, url: Query.url},
orderBy: {desc: BlogPost.publishDate},
take: 3
})
}
```
To require all categories instead of any, combine one `includes` per category with `and`:
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
export function postsInAllCategories(categoryIds: Array) {
return cms.find({
type: BlogPost,
filter: {
and: categoryIds.map(id => ({categories: {includes: {_entry: id}}}))
}
})
}
```
## Filtering a listing from the url
Optional filters from search params, skipped when they're empty:
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema'
import {Query} from 'alinea'
interface Filters {
category?: string
q?: string
}
export function filteredPosts({category, q}: Filters) {
return cms.find({
type: BlogPost,
search: q || undefined,
filter: {
categories: category ? {includes: {_entry: category}} : undefined
},
select: {title: Query.title, url: Query.url},
// Sort by date unless the visitor searched, then by relevance
orderBy: q ? undefined : {desc: BlogPost.publishDate}
})
}
```
## Redirect old urls
When an editor changes the path of a published document, or moves it, its previous url is kept in its metadata aliases. Look it up when nothing matches, and redirect:
File: app/[[...slug]]/page.tsx
```tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
import {notFound, permanentRedirect} from 'next/navigation'
export async function findPage(url: string) {
const page = await cms.first({url, select: {title: Query.title}})
if (page) return page
const moved = await cms.first({alias: url, select: Query.url})
if (moved) permanentRedirect(moved)
notFound()
}
```
## Sitemap
Every page with its translations, for `app/sitemap.ts`. This one uses a translated pages root:
File: app/sitemap.ts
```tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
import type {MetadataRoute} from 'next'
const baseUrl = 'https://example.com'
export default async function sitemap(): Promise {
const pages = await cms.find({
root: cms.workspaces.main.pages,
locale: 'en',
select: {
url: Query.url,
updatedAt: Query.updatedAt,
translations: Query.translations({
includeSelf: true,
select: {locale: Query.locale, url: Query.url}
})
}
})
return pages.map(page => ({
url: baseUrl + page.url,
lastModified: page.updatedAt ? new Date(page.updatedAt * 1000) : undefined,
alternates: {
languages: Object.fromEntries(
page.translations.map(t => [t.locale, baseUrl + t.url])
)
}
}))
}
```
## Site settings
Settings that appear on every page, like the footer, fit in a seeded entry in their own root (see [Roots](https://v2.alineacms.com/docs/workspaces/root)). There's exactly one, so query it by type with `get`:
```tsx
import {cms} from '@/cms'
import {Settings} from '@/schema'
export async function Footer() {
const settings = await cms.get({type: Settings})
return
}
```
In a translated settings root, pass the `locale` too, otherwise `get` returns whichever translation comes first.
### Guides (https://v2.alineacms.com/docs/guides)
Guides for the features you reach for after the basics: previews, publishing, editing, translations, permissions and deploying to production.
### Live previews (https://v2.alineacms.com/docs/live-previews)
Live previews show the page you are editing next to the form in the dashboard. The page updates as the editor types, before anything is saved or published.
Image: Editing a product in the Oak & Loom demo, with the page previewed live beside the form. (https://v2.alineacms.com/admin/file/screenshots/dashboard-product.webp)
## Set up
Turn on previews in your [CMS config](https://v2.alineacms.com/docs/configuration). The dashboard loads your site from `baseUrl`, so set the URL for every environment you edit in:
File: cms.ts
```tsx
import {createCMS} from 'alinea/next'
export const cms = createCMS({
// schema and workspaces ...
baseUrl: {
development: 'http://localhost:3000',
production: 'https://example.com'
},
handlerUrl: '/api/cms',
preview: true
})
```
Then render `` in your root layout:
File: app/layout.tsx
```tsx
import {cms} from '@/cms'
import type {PropsWithChildren} from 'react'
export default function RootLayout({children}: PropsWithChildren) {
return (
{children}
)
}
```
Open an entry in the dashboard and the preview panel shows its page. `cms.previews` renders nothing unless the page is being previewed, so it is safe to leave in your production layout.
## How it works
1. The dashboard asks your handler for a short-lived preview token and loads `/api/cms?preview=&returnTo=` in the preview panel.
2. The handler checks the token, turns on Next.js [draft mode](https://nextjs.org/docs/app/guides/draft-mode) and redirects to the entry's URL.
3. On every edit the dashboard sends the unsaved entry to the page. `cms.previews` stores it in a cookie and refreshes the route, so your server components render again with the edited content.
While draft mode is on, queries use `status: 'preferDraft'`, so saved drafts show instead of the published version. Pass `status` to a query yourself to override this.
The panel loads the entry's `url`: by default the locale, the parent paths and the entry's own path, like `/en/blog/my-post`. If your routes differ, set `entryUrl` on the [Type](https://v2.alineacms.com/docs/schema/type) so the preview (and `Query.url`) point to the right page.
## The preview widget
`` adds a small floating toolbar to previewed pages. It shows whether the page is connected to the dashboard and has buttons to open the dashboard and to edit the current page. Leave out `widget` to get live previews without the toolbar.
The props of `cms.previews`:
- `widget`: show the floating toolbar, off by default.
- `workspace`, `root`: the workspace and root the edit button looks in when it finds the entry by the current URL. Set them when several workspaces or roots can contain the same URL, for example in a layout per site.
## Choose which entries have a preview
`preview` can be set on the config, a workspace, a root or a type. The most specific setting wins: type, then root, then workspace, then config. Turn it off for entries that have no page of their own, such as settings:
File: schema/Settings.ts
```tsx
import {Config, Field} from 'alinea'
export const Settings = Config.document('Settings', {
preview: false,
fields: {
siteName: Field.text('Site name')
}
})
```
Media files never show a preview.
## Preview entries without a page
Content that is only used inside other pages, such as reusable snippets, has no URL of its own to preview. Give its type an `entryUrl` that points to a route you only use for previews:
File: schema/Snippet.ts
```tsx
import {Config, Field} from 'alinea'
export const Snippet = Config.document('Snippet', {
entryUrl: ({path}) => `/preview/snippets/${path}`,
fields: {
body: Field.richText('Body')
}
})
```
File: app/preview/snippets/[slug]/page.tsx
```tsx
import {cms} from '@/cms'
import {Snippet} from '@/schema/Snippet'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
interface SnippetPreviewProps {
params: Promise<{slug: string}>
}
export const metadata: Metadata = {robots: {index: false}}
export default async function SnippetPreview({params}: SnippetPreviewProps) {
const {slug} = await params
const snippet = await cms.first({
type: Snippet,
url: `/preview/snippets/${slug}`
})
if (!snippet) notFound()
return {/* render the snippet like it appears on your pages */}
}
```
## Render a preview component
Instead of `true`, `preview` accepts a React component. The dashboard renders it in the preview panel instead of loading your site, and passes the entry with its current, unsaved field values in `entry.data`. Values are in their stored form: links are not resolved.
File: schema/Announcement.tsx
```tsx
import {Config, Field} from 'alinea'
export const Announcement = Config.document('Announcement', {
preview({entry}) {
const {message} = entry.data as {message?: string}
return
{message || entry.title}
},
fields: {
message: Field.text('Message')
}
})
```
The component is part of your schema, which your site imports too. Keep it small and free of server-only imports.
## Good to know
- The metadata field shows a search and social preview built from the ``, description and Open Graph tags the previewed page renders. Render them with `generateMetadata` to fill it.
- Previews need your handler: in production the dashboard is served from your own site, so the preview panel and your pages share the same origin.
- Pages render dynamically in draft mode. Statically generated pages still serve their built version to visitors.
Instant publishing (https://v2.alineacms.com/docs/instant-publishing)
### Instant publishing (https://v2.alineacms.com/docs/instant-publishing)
Content goes live on every commit, without a rebuild. When an editor publishes, Alinea commits the change to your repository and your site starts serving it right away.
## How it works
Every publish in the dashboard is a git commit. After the commit, the site syncs the new content sha from its own handler, so the next request that renders content reads the latest version. No build or redeploy has to finish before editors see their changes on the live site.
## Skip builds for content-only commits
Because content is synced at runtime, a commit that only changes content does not need a new deployment. On Vercel you can skip those builds with an `ignoreCommand` in `vercel.json`. The command exits successfully, which tells Vercel to skip the build, when nothing outside the `content` directory changed:
File: vercel.json
```json
{
"ignoreCommand": "git diff --quiet HEAD^ HEAD -- . ':!content'"
}
```
Note (info): Adjust `content` if your content lives in a different directory.
## Revalidate pages after a commit
Pages that are rendered at request time pick up new content immediately. Static pages were rendered ahead of time and keep serving the old content until they are revalidated. Use the handler's `afterCommit` hook to revalidate them whenever content is published:
File: app/api/cms/route.ts
```
import {cms} from '@/cms'
import {createHandler} from 'alinea/next'
import {revalidatePath} from 'next/cache'
const handler = createHandler({
cms,
afterCommit() {
revalidatePath('/', 'layout')
}
})
export const GET = handler
export const POST = handler
```
`revalidatePath('/', 'layout')` marks every page as stale, so the next visit to any page renders it with the new content. Narrow it down to specific paths if you only want to revalidate the pages that changed.
### Editing content (https://v2.alineacms.com/docs/editing-content)
Your `cms` instance can create, update, publish and delete entries from code, for example to import content from another system or to seed a new project. Changes go through your handler, like saves in the dashboard: during development they are written to your content files.
## Run a script
Scripts that edit content need a running dashboard to send their changes to. Run them as the command of `alinea dev`, which starts the dashboard, runs your script with the right environment and stops when the script exits:
```shellscript
npx alinea dev -- npx tsx scripts/import.ts
```
File: scripts/import.ts
```tsx
import {cms} from '../cms'
import {BlogPost} from '../schema/BlogPost'
const post = await cms.create({
type: BlogPost,
set: {title: 'Hello world', intro: 'My first post'}
})
console.log(`Created ${post._url}`)
```
[tsx](https://tsx.is) runs the TypeScript file and resolves the path aliases of your `tsconfig.json`. With Bun, run `bun alinea dev -- bun scripts/import.ts`. The script writes to your content files like an editor would: review the result with `git diff` and commit it.
Note (info): In production your handler only accepts changes from users signed in to the dashboard, so `cms.create` and friends fail in a deployed site unless the request comes from a signed-in editor. Run imports locally and push the result, or have a coding agent do the work through the [MCP server](https://v2.alineacms.com/docs/mcp).
## Create entries
`cms.create` creates an entry, saves it and returns it with all its fields:
```tsx
const post = await cms.create({
type: BlogPost,
root: 'pages',
locale: 'en',
set: {title: 'Hello world', intro: 'My first post'}
})
```
- `type`: the type of the new entry. Required.
- `set`: the field values. `title` is required, `path` is derived from it when you leave it out and gets a numeric suffix when a sibling already uses it.
- `workspace`, `root`: where to create the entry, defaults to the first workspace and its first root.
- `parentId`: create the entry as a child of another entry. Pass `root` (and `workspace`) as well when the parent is not in the default root: they are not taken from the parent.
- `locale`: required in translated roots, one of the root's locales. Creating an entry with an existing `id` and a new locale adds a translation.
- `status`: `'published'` (default), `'draft'` or `'archived'`.
- `insertOrder`: `'last'` (default) or `'first'` among its siblings.
- `id`: use your own id instead of a generated one.
## Update entries
`cms.update` changes fields of an existing entry and returns the updated entry. Only the fields in `set` change, each one is replaced as a whole:
```tsx
await cms.update({
type: BlogPost,
id: post._id,
locale: 'en',
set: {intro: 'An updated intro'}
})
```
`update` edits the published version unless you pass `status: 'draft'` or `'archived'`. In a translated root, pass the `locale` of the translation to edit: it defaults to `null`, the locale of untranslated entries.
## Create translations
In a translated root, all translations of an entry share its id. To translate an entry, call `cms.create` with the `id` of the existing entry and the new `locale`:
```tsx
const post = await cms.create({
type: BlogPost,
root: 'pages',
locale: 'en',
set: {title: 'Hello world', intro: 'My first post'}
})
await cms.create({
type: BlogPost,
id: post._id,
locale: 'fr',
set: {title: 'Bonjour le monde', intro: 'Mon premier article'}
})
```
- `type` and `set` are required as for any new entry, including a `title`. The path is derived from the translated title unless you set one.
- The translation is created in the workspace and root of the entry's other locales, so you can leave out `workspace` and `root`.
- Pass the `parentId` of a child entry as well. Its parent needs a translation in that locale first.
- When the translation is published, fields with `shared: true` that you leave out of `set` are copied from a published translation.
- A locale that already has a translation can't be created again: change it with `cms.update` and its `locale` instead.
## Commit several changes at once
The `Edit` namespace builds operations without running them. Pass any number of them to `cms.commit` to save them together, in one commit. An operation knows the id of its entry before it is committed, so you can link or nest entries you create in the same commit:
```tsx
import {Edit} from 'alinea'
const blog = Edit.create({
type: Blog,
root: 'pages',
set: {title: 'Blog'}
})
const posts = postData.map(data =>
Edit.create({
type: BlogPost,
root: 'pages',
parentId: blog.id,
set: {title: data.title}
})
)
await cms.commit(blog, ...posts)
```
The operations:
- `Edit.create(query)` and `Edit.update(query)`: take the same options as `cms.create` and `cms.update`.
- `Edit.publish({id, status, locale})`: publish the `'draft'` or `'archived'` version of an entry.
- `Edit.archive({id, locale})`: archive the published version.
- `Edit.move({id, target, dropPosition})`: move an entry `'before'` or `'after'` a sibling, or `'on'` another entry to make it a child. Add `targetType: 'root'` to move it to the top level of a root.
- `Edit.remove(...ids)`: delete entries with all their translations and children. Seeded entries can't be removed.
- `Edit.upload(query)`: upload a file, see below.
`cms` has a method for each of them that commits right away: `cms.publish`, `cms.archive`, `cms.move`, `cms.remove`, `cms.upload`, plus `cms.unpublish` and `cms.discard` (remove the draft or archived version of an entry).
## Build field values
Most fields store plain values. Rich text, lists and links store structured JSON with ids and ordering keys. The `Edit` helpers build those values for you:
```tsx
import {Config, Edit, Field} from 'alinea'
const body = Field.richText('Body')
const sections = Field.list('Sections', {
schema: {
Text: Config.type('Text', {
fields: {
title: Field.text('Title'),
text: body
}
})
}
})
const text = Edit.richText(body)
.addHtml('
Main heading
Parsed from HTML.
')
.value()
const rows = Edit.list(sections)
.add('Text', {title: 'The row title', text})
.value()
const author = Edit.link(BlogPost.author).addEntry(authorId).value()
```
- `Edit.richText(field)`: `add(type, block)` appends a rich text block, `addHtml(html)` parses HTML into rich text, `value()` returns the result.
- `Edit.list(field)`: `add(type, row)` appends a row, `insertAt(index, type, row)` and `removeAt(index)` edit an existing list passed as second argument.
- `Edit.link(field)`: `addEntry(id)`, `addImage(id)`, `addFile(id)` or `addUrl({url, title, target})`. `Edit.links(field)` does the same for fields with multiple links.
## Upload files
`cms.upload` uploads a file to the media library and returns the new media file entry. Use the id to link the file from an image or file field:
```tsx
import {Edit} from 'alinea'
import {createPreview} from 'alinea/core/media/CreatePreview'
import {readFile} from 'node:fs/promises'
const file = new File([await readFile('./photo.jpg')], 'photo.jpg', {
type: 'image/jpeg'
})
const image = await cms.upload({file, createPreview})
await cms.update({
type: Page,
id: pageId,
set: {hero: Edit.link(Page.hero).addImage(image._id).value()}
})
```
- `file`: a `File`, or a `[name, bytes]` tuple.
- `workspace`, `root`, `parentId`: where to store it, defaults to the media root of the first workspace. Pass the id of a media folder as `parentId` to upload into it.
- `createPreview`: pass it for images to store their dimensions, average color, focus point and a small preview. It uses the `sharp` package on the server, which you need to install yourself.
- `replaceId`: replace the file of an existing media entry.
Uploads count against the `maxUploadSize` of your config.
## Good to know
- Every commit is checked against the roles of the user who makes it, see [Roles and permissions](https://v2.alineacms.com/docs/roles-permissions). In development that is the local admin.
- Validation from your schema (`required`, `validate`) also runs when you publish from code: `cms.create` and `cms.update` throw when a published version has invalid fields. Drafts (`status: 'draft'`) are not validated.
- To normalize content files after bulk edits, run `alinea build --fix`.
### Internationalization (https://v2.alineacms.com/docs/internationalization)
Alinea translates content per root: every entry in a translated root has a version per locale, each with its own fields, path and URL. For a few values that should be edited side by side for all languages, use a localised field instead (see [Localised fields](#localised-fields) below).
Image: The Dutch translation of a product, with the language menu open. Shared fields hold the same value in every language, all other fields are translated. (https://v2.alineacms.com/admin/file/screenshots/dashboard-localiser.webp)
## Translate a root
Add `i18n` with the list of locales to a [Root](https://v2.alineacms.com/docs/workspaces/root). The first locale is the default: new entries start in it.
File: cms.ts
```tsx
import {Config} from 'alinea'
Config.root('Pages', {
i18n: {
locales: ['en', 'fr', 'nl']
},
contains: ['Page', 'Blog']
})
```
Every locale gets its own folder, so translations of an entry are separate files that share one entry id:
```text
content/pages/en/about.json
content/pages/fr/a-propos.json
content/pages/nl/over-ons.json
```
Each translation has its own path, so the URLs above are `/en/about`, `/fr/a-propos` and `/nl/over-ons`. By default an entry's URL is the locale followed by the paths of its parents and its own path, and `index` entries are left out: the home page of the French site is `/fr`.
Roots without `i18n` hold content that is the same in every language, such as authors or settings. Entries in one workspace can mix both kinds.
## Translate in the dashboard
Translated roots show a language menu in the dashboard. When you open an entry in a locale it doesn't exist in yet, the dashboard offers to create the translation, empty or copied from an existing translation. A translation can only be created once its parent entry exists in that locale.
Mark fields that should be identical in every language with `shared: true`, for example images, prices or dates. Editors see them with a shared badge, and saving the entry writes the value to every translation. Only fields of the entry type itself can be shared, not fields inside list rows or objects.
File: schema/Product.ts
```tsx
import {Config, Field} from 'alinea'
export const Product = Config.document('Product', {
fields: {
description: Field.richText('Description'),
price: Field.number('Price', {shared: true}),
gallery: Field.image.multiple('Gallery', {shared: true})
}
})
```
## Query translated content
Pass `locale` to get the entries of one language. Without it, a query returns every translation.
```tsx
const posts = await cms.find({
type: BlogPost,
locale: 'fr'
})
```
Links follow the locale of the entry they are stored on: an entry link on the French page resolves to the French translation of the linked entry, or to the linked entry itself when it lives in a root without `i18n`.
`Query.translations` selects the other translations of an entry. Add `includeSelf: true` to include the current one. That is all you need for a language switcher and for `hreflang` alternates.
## Routing in Next.js
Put your pages below a `[locale]` segment and look entries up by their full URL:
File: app/[locale]/[[...slug]]/page.tsx
```tsx
import {cms} from '@/cms'
import {Query} from 'alinea'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
interface PageProps {
params: Promise<{locale: string; slug?: Array}>
}
function fetchPage(locale: string, slug: Array) {
return cms.first({
root: cms.workspaces.main.pages,
locale,
url: `/${[locale, ...slug].join('/')}`,
select: {
title: Query.title,
translations: Query.translations({
includeSelf: true,
select: {locale: Query.locale, url: Query.url}
})
}
})
}
export async function generateStaticParams() {
const pages = await cms.find({
root: cms.workspaces.main.pages,
select: {locale: Query.locale, url: Query.url}
})
return pages.map(({locale, url}) => ({
locale: locale!,
slug: url.split('/').filter(Boolean).slice(1)
}))
}
export async function generateMetadata({params}: PageProps): Promise {
const {locale, slug = []} = await params
const page = await fetchPage(locale, slug)
if (!page) return {}
return {
title: page.title,
alternates: {
languages: Object.fromEntries(
page.translations.map(t => [t.locale!, t.url])
)
}
}
}
export default async function Page({params}: PageProps) {
const {locale, slug = []} = await params
const page = await fetchPage(locale, slug)
if (!page) notFound()
return (
{page.title}
)
}
```
The same `Query.translations` selection gives you the `alternates.languages` of each URL in `app/sitemap.ts`.
### Change the URL structure
Set `entryUrl` on a [Type](https://v2.alineacms.com/docs/schema/type) to change its URLs. It receives the locale, the paths and the `defaultUrl` described above. This one leaves the prefix out for English:
File: schema/Page.ts
```tsx
import {Config, Field} from 'alinea'
export const Page = Config.document('Page', {
entryUrl: ({locale, defaultUrl}) =>
locale === 'en' ? defaultUrl.replace(/^\/en(?=\/|$)/, '') || '/' : defaultUrl,
fields: {
body: Field.richText('Body')
}
})
```
`entryUrl` is set per type, so share the function between the types of a translated root. Your routes have to match: here English pages are served without a `[locale]` segment, for example by rewriting them in middleware.
## Localised fields
`Field.localiser` wraps a field so that it stores a value for every locale in one entry. Editors fill in all languages next to each other. Queries return the plain value for the locale of the entry you query, so the result has the type of the wrapped field.
File: schema/Product.ts
```tsx
import {Config, Field} from 'alinea'
const localise = Field.localiser({
locales: ['en', 'fr', 'nl'],
// Used when the requested locale has no value
fallback: locale => (locale === 'en' ? [] : ['en'])
})
export const Product = Config.document('Product', {
fields: {
description: Field.richText('Description'),
badge: localise(Field.text('Badge', {shared: true}))
}
})
```
The stored value is an object keyed by locale, such as `{"en": "New", "fr": "Nouveau", "nl": "Nieuw"}`. Combined with `shared: true`, as above, short labels are managed in one place while the rest of the entry is translated per locale.
- `locales`: the locales to store, at least one.
- `fallback`: returns the locales to try, in order, when the value for the requested locale is empty (missing, `null` or `""`).
An entry without a locale, in a root without `i18n`, gets the value for the locale you ask for with `preferredLocale`, or for the locale of the entry that links to it. Without either it gets the first locale. Localised fields also work inside objects, list rows and rich text blocks.
## Media
Media files are shared between languages. Their alt text can be translated: add `i18n` to the media root with `Config.media({i18n: {locales: ['en', 'fr', 'nl']}})`.
## Good to know
- Locale folders and default URLs use lowercase: `en-US` is stored in `en-us` and served on `/en-us`. The `locale` of a query is matched case-insensitively.
- Adding a locale to `locales` doesn't create content: editors translate entries when they need them, starting from the parents.
- `cms.first({url})` needs the full URL including the locale prefix, which also makes it unique across languages.
### Roles and permissions (https://v2.alineacms.com/docs/roles-permissions)
Roles decide what a signed-in user can see and change in the dashboard. Every project has an `admin` role with full access to all content and to user management. Add your own roles to give editors less than that.
Image: The users screen of the demo, with a role for each user: Admin, Editor, Translator and Viewer. (https://v2.alineacms.com/admin/file/screenshots/dashboard-roles.webp)
## Define a role
Create a role with `Config.role` and register it under `roles` in your [CMS config](https://v2.alineacms.com/docs/configuration). The `permissions` function receives a policy to fill in:
File: cms.ts
```tsx
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'
const editor = Config.role('Editor', {
description: 'Edits and publishes content, cannot change settings',
permissions(policy) {
policy.set(
{
allow: {
read: true,
create: true,
update: true,
publish: true,
archive: true,
reorder: true,
move: true,
upload: true
}
},
{
root: cms.workspaces.main.settings,
deny: {create: true, update: true, publish: true, archive: true}
}
)
}
})
export const cms = createCMS({
roles: {editor},
// schema, workspaces ...
})
```
The key in `roles` (`editor`) is the name stored on users. `label` and `description` are shown in the dashboard. The permissions function runs when a user's policy is built, so it can refer to `cms.workspaces` even though `cms` is defined below it.
## Assign roles to users
- Local development: you are signed in as a local admin. Open the profile menu at the bottom of the sidebar and pick one or more roles under Role to see the dashboard as that user would.
- Self-hosted: with a [database](https://v2.alineacms.com/docs/deploy/self-host) in your backend, users with the `manageMembers` permission (admins) get a Manage users screen in the profile menu to add users by email and give them roles. Users that are not in that list keep the roles from their sign-in: from the token claims with OAuth2, or from what your `auth.basic` function returns (returning `true` signs the user in as admin).
- Alinea Cloud: users and their access are managed in [Alinea Cloud](https://v2.alineacms.com/docs/deploy/alinea-cloud).
A user can have several roles. Their policy combines the rules of all of them, see [Combining roles](#combining-roles).
## Write rules
`policy.set` takes one or more rules. A rule has a target, the actions it allows or denies, and optionally a `grant` mode:
```tsx
policy.set(
// No target: applies to everything
{allow: {read: true}},
// A workspace or root, as defined in your config
{root: cms.workspaces.main.pages, allow: {update: true}},
// Every entry of a type, or one field of a type
{type: BlogPost, allow: {create: true}},
{field: BlogPost.price, deny: {update: true}},
// One entry (and everything below it)
{id: entryId, allow: {all: true}},
// Every entry in a locale, null for untranslated entries
{locale: 'fr', allow: {update: true}}
)
```
A rule has one target. Setting a rule for the same target again replaces the earlier one. `policy.allowAll()` grants everything, like the admin role.
### Actions
- `read`: see the workspace, root, entry or field in the dashboard.
- `create`: create entries.
- `update`: edit entries. Denying `update` on a field makes it read-only.
- `publish`: publish drafts and archived entries.
- `archive`: archive entries.
- `delete`: delete entries and media files.
- `reorder`: change the order of entries within their parent.
- `move`: move entries to another parent or root.
- `upload`: upload media files. Uploads are checked against rules without a target, so allow it at the top level.
- `manageMembers`: manage users and their roles.
- `explore`: deprecated and never checked. What a role can browse in the entry tree and media library follows `read`.
- `all`: every action above.
## How rules combine
For an entry, the policy walks from broad to specific: the rules without a target, then the workspace, the root, the parent entries, the type (and field), the locale and finally the entry itself. Each level adds its allows to what it inherited.
- Deny wins. A denied action stays denied for everything below it, and an allow further down can't undo it. Put denies on the most specific target you can.
- Explicit grants. With `grant: 'explicit'`, the allows of a level and the levels above it apply to that level only, not to what is below it. Children, and the entries of a root, then need their own allow. Denies still flow down.
Note (info): Creating an entry checks `create` twice: on the new entry's workspace, root and type, and on its parent entry. To let a role add posts to a blog, allow `create` on the post type and on the blog entry.
### Combining roles
A user with several roles gets the union of their rules: an action allowed by any role is allowed. Denies are combined the same way, so a deny in one role also blocks what another role allows. Keep denies in roles that are meant to restrict.
## What permissions protect
Your handler checks the policy on every save, so a user can't create, update, publish, archive, move, reorder, delete or upload beyond their roles, even outside the dashboard. `read` hides workspaces, roots, entries and fields in the dashboard.
Permissions don't make content secret. Your content lives in your git repository and the dashboard syncs all of it to the browser, so treat `read` as a way to keep the dashboard focused, not as access control for confidential data.
## Examples
### Read-only access
```tsx
const viewer = Config.role('Viewer', {
permissions(policy) {
policy.set({allow: {read: true}})
}
})
```
### Translators
Allow editing in one locale. The rest of the content stays visible for reference:
```tsx
const translator = Config.role('Translator', {
description: 'Translates pages into French',
permissions(policy) {
policy.set(
{allow: {read: true}},
{locale: 'fr', allow: {create: true, update: true, publish: true}}
)
}
})
```
Rules for a locale apply to creating entries too: with `create` allowed in `fr`, this role can start the French translation of a page. Leave it out to limit translators to French versions that already exist.
### Read-only fields
Editors see the price but can't change it:
```tsx
const editor = Config.role('Editor', {
permissions(policy) {
policy.set(
{allow: {read: true, update: true, publish: true}},
{field: BlogPost.price, deny: {update: true}}
)
}
})
```
Deny `read` on a field to hide it from the role.
### Access to specific entries
Use `grant: 'explicit'` to show a workspace and root without opening up everything inside them, then allow the entries the role works on:
```tsx
const landingPageEditor = Config.role('Landing page editor', {
permissions(policy) {
policy.set(
{
workspace: cms.workspaces.main,
allow: {read: true},
grant: 'explicit'
},
{
root: cms.workspaces.main.pages,
allow: {read: true},
grant: 'explicit'
},
{
id: landingPageId,
allow: {all: true}
}
)
}
})
```
The dashboard's content tree only shows entries the role can read. If the entry is nested, allow `read` on its parents too, or the editor can't navigate to it.
### Permissions from your content
The second argument of `permissions` is a graph to query your content, so rules can follow your data instead of hardcoded ids. This role lets authors edit the posts that link to them, and picks up new posts automatically:
```tsx
import {Config, Query} from 'alinea'
const guestAuthor = Config.role('Guest author', {
async permissions(policy, graph) {
const posts = await graph.find({
type: BlogPost,
filter: {author: {has: {_entry: guestAuthorId}}},
select: Query.id
})
policy.set(
{workspace: cms.workspaces.main, allow: {read: true}, grant: 'explicit'},
{root: cms.workspaces.main.pages, allow: {read: true}, grant: 'explicit'}
)
for (const id of posts) policy.set({id, allow: {read: true, update: true}})
}
})
```
The query runs whenever a policy is built, so keep it small.
## Test your roles
`cms.createPolicy` builds the policy for a list of role names, which makes roles easy to unit test:
File: roles.test.ts
```tsx
import {expect, test} from 'bun:test'
import {cms} from '@/cms'
test('editors cannot change settings', async () => {
const policy = await cms.createPolicy(['editor'])
expect(policy.canUpdate({workspace: 'main', root: 'pages'})).toBe(true)
expect(policy.canUpdate({workspace: 'main', root: 'settings'})).toBe(false)
})
```
The policy has a `can*` method for every action (`canRead`, `canCreate`, `canUpdate`, ...), which takes the workspace, root, type, field, entry id, parent ids and locale to check.
## Good to know
- Defining your own role named `admin` replaces the built-in one.
- Users without any role can sign in but can't see or change anything.
### TypeScript (https://v2.alineacms.com/docs/typescript)
Alinea infers TypeScript types from your schema, so there are no types to generate or keep in sync. Query results are typed automatically, and `Infer` gives you the types to use in your components.
## Query results
The result of a query follows from its `type` and `select`:
File: app/blog/page.tsx
```tsx
import {cms} from '@/cms'
import {BlogPost} from '@/schema/BlogPost'
import {Query} from 'alinea'
const posts = await cms.find({
type: BlogPost,
select: {
title: Query.title,
url: Query.url,
publishDate: BlogPost.publishDate
}
})
// Array<{title: string, url: string, publishDate: string}>
```
Without `select` you get all fields of the type plus the entry fields: `_id`, `_type`, `_url`, `_locale`, `_parentId` and so on.
To pass a result to a component, derive its type from the function that fetches it:
```tsx
async function fetchPosts() {
return cms.find({
type: BlogPost,
select: {title: Query.title, url: Query.url}
})
}
type PostSummary = Awaited>[number]
```
## Infer types from the schema
`Infer` turns a type, a list schema or a field into the shape queries return:
File: schema/BlogPost.ts
```tsx
import {Config, Field, type Infer} from 'alinea'
export const TextBlock = Config.type('Text', {
fields: {
body: Field.richText('Body')
}
})
export const ImageBlock = Config.type('Image', {
fields: {
image: Field.image('Image')
}
})
export const BlogPost = Config.document('Blog post', {
fields: {
publishDate: Field.date('Publish date'),
author: Field.entry('Author'),
blocks: Field.list('Blocks', {
schema: {Text: TextBlock, Image: ImageBlock}
})
}
})
// The fields of a blog post, as a query returns them
export type BlogPost = Infer
// The same, plus the entry fields (_id, _type, _url, ...)
export type BlogPostEntry = Infer.Entry
// A row of the list field, with _id, _index and _type
export type TextBlock = Infer.ListItem
// The value of one field
export type Blocks = BlogPost['blocks']
```
Giving the type and the constant the same name, as above, lets you import both with one name: `BlogPost` is the schema in queries and the inferred type in annotations.
- `Infer`: the query value of a type, a field or a list schema (`{Text: TextBlock, ...}`, which becomes a union of rows).
- `Infer.Entry`: `Infer` plus the entry fields. Pass the type name to narrow `_type`.
- `Infer.ListItem`: `Infer` plus the list row fields `_id`, `_index` and `_type`.
- `Infer.Stored`: the stored value, before links are resolved. This is what `Edit.create`, `Edit.update` and custom field views work with.
## Narrow list rows by `_type`
List rows carry their type name in `_type`, so a `switch` narrows each row to its own fields:
File: components/Blocks.tsx
```tsx
import type {Blocks} from '@/schema/BlogPost'
export function BlocksView({blocks}: {blocks: Blocks}) {
return (
<>
{blocks.map(block => {
switch (block._type) {
case 'Text':
return
case 'Image':
return
}
})}
>
)
}
```
## Good to know
- Types describe your current schema. Content files written before you added a field don't have it yet: `alinea build --fix` fills in the defaults of missing fields in every file.
- Infer types from the schema constants you query with. Types written by hand drift from the schema without the compiler noticing.
### Deploying (https://v2.alineacms.com/docs/deploy)
Deploying an Alinea site means deploying your Next.js app. In production the dashboard is served by your site on `/admin`, and your handler route saves the editors' changes as commits to your git repository. To do that it needs a backend that signs users in and talks to your repository: [Alinea Cloud](https://v2.alineacms.com/docs/deploy/alinea-cloud) or [your own](https://v2.alineacms.com/docs/deploy/self-host).
## Prepare your project
### Config
Tell Alinea where your site lives, where the handler is and on which path the dashboard is served, in your [CMS config](https://v2.alineacms.com/docs/configuration):
File: cms.ts
```tsx
import {createCMS} from 'alinea/next'
export const cms = createCMS({
// schema and workspaces ...
baseUrl: {
development: 'http://localhost:3000',
production: 'https://example.com'
},
handlerUrl: '/api/cms',
adminPath: '/admin'
})
```
`baseUrl.production` is required: the handler URL is resolved against it. Your config is also bundled into the dashboard, where only environment variables starting with `NEXT_PUBLIC_` or `PUBLIC_` exist. Use one of those if you read the URL from the environment, for example `production: process.env.NEXT_PUBLIC_SITE_URL`.
### Build scripts
Run Next.js through the Alinea CLI. `alinea build` bundles your content, generates the dashboard and then runs the command after `--`:
File: package.json
```json
{
"scripts": {
"dev": "alinea dev -- next dev",
"build": "alinea build -- next build",
"start": "next start"
}
}
```
Wrap your Next.js config in `withAlinea` from `alinea/next`. It serves the dashboard on `adminPath`, routes media URLs (`/admin/file/...`) to your handler and allows them in `next/image`:
File: next.config.ts
```tsx
import {withAlinea} from 'alinea/next'
export default withAlinea({
// your Next.js config
})
```
The build writes the dashboard to your public folder: an `admin.html` file and an `admin` folder with its assets, named after `adminPath`. They are generated on every build, so leave them out of git (`alinea init` adds these lines to `.gitignore`):
File: .gitignore
```text
/public/admin.html
/public/admin/
```
### The handler route
The route you created with `alinea init` handles every request of the dashboard:
File: app/(alinea)/api/cms/route.ts
```tsx
import {cms} from '@/cms'
import {createHandler} from 'alinea/next'
const handler = createHandler({cms})
export const GET = handler
export const POST = handler
```
Without a `backend` option the handler connects to Alinea Cloud. Pass `backend` to [host it yourself](https://v2.alineacms.com/docs/deploy/self-host). Use the `afterCommit` hook to revalidate pages after editors publish, see [Instant publishing](https://v2.alineacms.com/docs/instant-publishing).
### Commit hooks
Two hooks on the handler run for every commit, whatever it contains. `beforeCommit` receives the changes before they are committed: inspect or rewrite them and return the mutations to commit, or return nothing to keep them as they are. `afterCommit` runs once the commit is made, with the mutations and the `sha` of the new commit: revalidate pages, notify your team or trigger a deploy.
File: app/(alinea)/api/cms/route.ts
```tsx
import {cms} from '@/cms'
import {createHandler} from 'alinea/next'
import {revalidatePath} from 'next/cache'
const handler = createHandler({
cms,
beforeCommit({mutations}) {
// Inspect or rewrite the changes, return the mutations to commit
return mutations
},
afterCommit({sha}) {
revalidatePath('/', 'layout')
}
})
export const GET = handler
export const POST = handler
```
Both hooks can be async. Errors thrown in `afterCommit` are logged and don't fail the commit.
## Hosting requirements
- Node.js 24 or higher where your site runs, not only where it builds: the bundled content database is opened with Node's built-in SQLite.
- The Node.js runtime for the handler route. The handler refuses to run on the Edge runtime.
- Server rendering. The handler is an API route, so a static export (`output: 'export'`) can't run the dashboard or serve media.
## Deploy
Push your code and deploy it as usual. Then open `/admin` on your deployed site:
- Without a backend, the dashboard walks you through connecting the site to Alinea Cloud.
- With a self-hosted backend, editors sign in with the method you configured.
After the first publish, check that the change shows up on your site and that a commit appeared in your repository.
Alinea Cloud (https://v2.alineacms.com/docs/deploy/alinea-cloud)
Self-Hosted (https://v2.alineacms.com/docs/deploy/self-host)
### Self-Hosted (https://v2.alineacms.com/docs/deploy/self-host)
Run the backend yourself instead of using Alinea Cloud. Your handler route then signs editors in with your own auth, commits their changes to GitHub through the GitHub API and keeps users and uploads in your database. It runs wherever your Next.js app runs, on any host with Node.js 24 or higher.
## What you need
- A GitHub repository and a token that can write to it. With a fine-grained personal access token, give it access to the repository with Contents: Read and write.
- A way to sign in: basic authentication or an OAuth2 provider that issues JWT access tokens.
- A database for the users and roles you manage in the dashboard, and for uploaded files until they are committed. PostgreSQL, MySQL, SQLite (libSQL/Turso, Cloudflare D1) and PGlite are supported.
## Set up the backend
Compose a backend from parts exported by `alinea/backend` and pass it to `createHandler`:
File: app/(alinea)/api/cms/route.ts
```tsx
import {cms} from '@/cms'
import {auth, createBackend, database, github} from 'alinea/backend'
import {createHandler} from 'alinea/next'
import {Pool} from 'pg'
const backend = createBackend(
auth.basic(
(username, password) =>
username === process.env.ALINEA_USERNAME &&
password === process.env.ALINEA_PASSWORD
),
database({
driver: 'pg',
client: new Pool({connectionString: process.env.DATABASE_URL})
}),
github({
authToken: process.env.GITHUB_TOKEN!,
owner: 'my-org',
repo: 'my-site',
branch: 'main',
rootDir: '',
contentDir: 'content'
})
)
const handler = createHandler({cms, backend})
export const GET = handler
export const POST = handler
```
The backend is only used in production. During `alinea dev` the dashboard saves to your local files and you are signed in as a local admin.
When two parts implement the same thing, the one listed last wins. That is how an `uploads` part takes over file uploads from the database.
## GitHub
`github(options)` commits every change through the GitHub API. All options are required:
- `authToken`: the GitHub token.
- `owner`, `repo`: the owner (user or organization) and name of the repository.
- `branch`: the branch to commit to, usually the one your production site deploys from.
- `rootDir`: the folder of your project inside the repository: `''` when it is the repository root, `'apps/web'` in a monorepo.
- `contentDir`: your content folder relative to `rootDir`: the `source` of your workspace, or the folder that contains the workspace folders when you have several workspaces.
Commits are made by the owner of the token. The editor who published is added as a `Co-authored-by` trailer with their name and email. When the branch moved since the dashboard last synced, the handler syncs and retries the commit up to three times.
## Authentication
### Basic authentication
`auth.basic(verify)` signs editors in with a username and password, checked by your function. Return `true` to sign the user in as an admin, `false` to refuse, or a user object to choose their roles:
```tsx
import {auth} from 'alinea/backend'
auth.basic((username, password) => {
const user = users[username]
if (!user || user.password !== password) return false
return {sub: username, email: username, name: user.name, roles: user.roles}
})
```
The function can be async. Keep passwords in environment variables or a secret store, not in your repository.
### OAuth2
`auth.oauth2(options)` signs editors in with an OAuth2 provider, using the authorization code flow with PKCE:
```tsx
import {auth} from 'alinea/backend'
const issuer = 'https://auth.example.com'
auth.oauth2({
clientId: process.env.OAUTH_CLIENT_ID!,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
authorizationEndpoint: `${issuer}/oauth/authorize`,
tokenEndpoint: `${issuer}/oauth/token`,
jwksUri: `${issuer}/.well-known/jwks.json`,
validateClaims(claims) {
if (claims.iss !== issuer) throw new Error('Invalid issuer')
if (claims.aud !== 'alinea') throw new Error('Invalid audience')
}
})
```
- `clientId`, `clientSecret`: the credentials of the client you registered with your provider.
- `authorizationEndpoint`, `tokenEndpoint`: the provider's authorize and token URLs.
- `jwksUri`: the URL of the provider's JSON Web Key Set, used to verify access tokens.
- `validateClaims(claims)`: required. Throw when the token is not meant for your site: check at least the issuer (`iss`) and audience (`aud`).
- `revocationEndpoint`: optional, revokes the tokens when an editor signs out.
Register `https://?auth=login` as the redirect URL with your provider, for example `https://example.com/api/cms?auth=login`. The provider has to return a refresh token and JWT access tokens. The user is read from the token's claims: `sub`, `email`, `name` and, if present, `roles`.
## Database
`database({driver, client})` stores the users and roles you manage on the Manage users screen of the dashboard, see [Roles and permissions](https://v2.alineacms.com/docs/roles-permissions). Without an `uploads` part it also stores uploaded files until they are committed. Alinea creates its tables (`alinea_user`, `alinea_user_role` and `alinea_upload`) on first use. On PostgreSQL it enables row level security on them, so they are not exposed through Supabase's API.
Pass the client of one of these packages as `client`, with its name as `driver`:
- PostgreSQL: `pg`, `@neondatabase/serverless`, `@vercel/postgres`
- MySQL: `mysql2`
- SQLite: `@libsql/client`, `d1` (Cloudflare D1), `sql.js`
- `@electric-sql/pglite`
```tsx
import {database} from 'alinea/backend'
import {Pool} from '@neondatabase/serverless'
database({
driver: '@neondatabase/serverless',
client: new Pool({connectionString: process.env.DATABASE_URL})
})
```
## Uploads
When an editor uploads a file, the dashboard first sends it to a temporary location, then the handler commits the file to your repository next to your content. Until your site is rebuilt, the handler serves the file from that temporary location.
By default the database part is that temporary location, and uploads pass through your handler. Hosts that limit the size of request bodies limit your uploads too. Add an `uploads` part to let the browser upload directly to object storage instead:
```tsx
import {uploads} from 'alinea/backend'
uploads.s3({
bucket: 'my-site-media',
region: 'eu-west-1',
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!
})
```
`uploads.s3(options)` works with Amazon S3 and S3-compatible storage:
- `bucket`, `region`, `accessKeyId`, `secretAccessKey`: required. `sessionToken` for temporary credentials.
- `endpoint`: the URL of an S3-compatible service, such as Cloudflare R2 or MinIO. Path-style URLs are used when it is set, override with `forcePathStyle`.
- `prefix`: a folder inside the bucket for the uploads.
- `publicUrl`: a base URL, or a function of the object key, when the bucket is publicly readable. Without it, files are read through signed URLs.
- `uploadExpiresIn`: seconds the signed upload URL is valid, defaults to 900.
- `previewExpiresIn`: seconds the signed read URL is valid, defaults to 7 days (the maximum).
`uploads.supabase(bucket, {prefix})` uses Supabase Storage. Pass a bucket from a Supabase client created with the service role key, for example `supabase.storage.from('media')`. Files are read through the bucket's public URL, so make the bucket public.
The browser uploads with a `PUT` request to the bucket, so allow `PUT` requests from your site's origin in the bucket's CORS settings.
`uploads.custom(api)` plugs in your own storage: an object with a `prepareUpload` method that returns where the browser should upload to.
`maxUploadSize` in your [config](https://v2.alineacms.com/docs/configuration) limits the size of uploads for every storage.
## Options object
`backend` also accepts a plain object, the format of Alinea 1.x. It needs a `database`, `github` and `auth` or `oauth2`, and takes `uploads: {s3}`:
```tsx
const handler = createHandler({
cms,
backend: {
auth: (username, password) => password === process.env.ALINEA_PASSWORD,
database: {driver: 'pg', client: new Pool()},
github: {
authToken: process.env.GITHUB_TOKEN!,
owner: 'my-org',
repo: 'my-site',
branch: 'main',
rootDir: '',
contentDir: 'content'
}
}
})
```
## Good to know
- Content is committed through the GitHub API only. Other git hosts aren't supported by the self-hosted backend.
- `ALINEA_API_KEY` signs preview tokens and authorizes your site's own requests to the handler. Without it, Alinea uses an id generated at build time. Set it to a long random secret in your hosting environment if it should stay the same across deployments.
- The backend needs a `baseUrl.production` in your config: it builds the OAuth2 redirect and upload URLs from it.
### Alinea Cloud (https://v2.alineacms.com/docs/deploy/alinea-cloud)
[Alinea Cloud](https://www.alinea.cloud/app) is a hosted backend for your handler. It signs editors in, lets you invite users and give them roles, stores uploads and commits changes to your git repository. You don't need a database or auth provider of your own.
## Connect your site
1. [Prepare and deploy your site](https://v2.alineacms.com/docs/deploy) with a handler that has no `backend` option: `createHandler({cms})`.
2. Open the dashboard on your deployed site (`/admin`, or your `adminPath`). It shows Ready to deploy? because no backend is configured yet.
3. Choose Continue with alinea.cloud and follow the setup on Alinea Cloud. It gives you an API key for your site.
4. Add the key to your hosting environment as `ALINEA_API_KEY` and redeploy.
Alinea Cloud then verifies the connection with a handshake request to your handler. From there on editors sign in on `/admin` with their Alinea Cloud account.
```shellscript
ALINEA_API_KEY=alineapk_...
```
## Users and roles
Invite editors from Alinea Cloud. During the handshake your handler reports the [roles](https://v2.alineacms.com/docs/roles-permissions) defined in your config, with their labels and descriptions, so you can assign them to users there. The Manage users screen of the self-hosted dashboard isn't used with Alinea Cloud.
## Good to know
- `ALINEA_API_KEY` identifies your site, keep it secret. Without it the dashboard keeps showing the setup screen.
- Your content stays in your repository. Alinea Cloud commits to it for you, so you can move to a [self-hosted backend](https://v2.alineacms.com/docs/deploy/self-host) later without migrating content.
- Local development doesn't use Alinea Cloud: `alinea dev` saves to your local files.
### Working with AI agents (https://v2.alineacms.com/docs/ai-agents)
Coding agents can change Alinea content in two ways: through the MCP server of `alinea dev`, or by writing the content JSON files themselves. This page is the playbook for both: connect the MCP server first, and use the rules below when an agent has to edit files by hand.
## Edit content through the MCP server
While `alinea dev` runs it also serves an MCP server that coding agents such as Claude Code, Cursor or Codex use to read the schema and to create, edit, publish and delete entries through the same save path as the dashboard. It fills in internal fields such as `_id`, `_index`, list row ids, rich text nodes, link objects and media metadata, so the result is always valid and an open dashboard updates live. Connect your agent to it with:
```shellscript
claude mcp add --transport http alinea http://localhost:4500/mcp
```
Prefer it over editing JSON files by hand. The [MCP server](https://v2.alineacms.com/docs/mcp) page covers the setup for other agents, every tool and the value formats. The rules on the rest of this page remain the reference for manual edits.
## Scope and source priority
When documenting or generating Alinea content, use source of truth in this order:
- The docs that ship with the package: `node_modules/alinea/docs/` holds a Markdown file per docs page for the installed version, start at `index.md`. Online, [llms.txt](https://v2.alineacms.com/llms.txt) lists every page as Markdown and [llms-full.txt](https://v2.alineacms.com/llms-full.txt) holds all documentation in one file (including this playbook). Use these as primary source of truth.
- In case of ambiguity or missing documentation, consult the alinea core source code. - Projects using alinea will typically have alinea as a bundled node_modules dependency, which means the (compiled) source code can be accessed directly. - In case the source code can not be retrieved, or the compiled code is unclear, consult the source code on https://github.com/alineacms/alinea
- Consult the schema and existing content files in the target project (for example `content/**`) for examples, and follow the same structure when suggesting changes.
- The source code of the Alinea website is publicly available in the `apps/web` directory of https://github.com/alineacms/alinea. The website, including these docs, is itself built with Alinea and Next.js and can serve as a useful example.
`alinea init` adds a short Alinea section to the project's `AGENTS.md` that points agents to the bundled docs and the MCP server, so agents find them without being told.
## Project structure conventions
In new Next.js projects, agents should follow the tutorial file structure as closely as possible to keep the codebase readable and maintainable.
In existing codebases, first scan the current structure and then align new files and changes to the established coding guidelines and architectural principles.
## Entry metadata rules
Top-level entries are JSON records with required meta fields. In Alinea core this is defined by `EntryMeta` in `src/core/EntryRecord.ts`.
```json
{
"_id": "",
"_type": "",
"_index": "",
"_root": "pages",
"_seeded": "/index.json",
"title": "..."
}
```
- `_id`: unique entry id from `createId()` (`alinea/core/Id`).
- `_type`: exact schema type key.
- `_index`: fractional ordering key. Generate with `generateKeyBetween` from `alinea/core/util/FractionalIndexing`.
- `_root`: written on root-level entries (entries without a parent), include it when you create one. Value is the root key, for example `pages` or `media`.
- `_seeded`: only on entries created from a seeded page in the config (`Config.page(...)` in the `children` of a root or page). Keep the value unchanged.
- The file name is the entry's path (url slug): `docs/reference/cli.json` has path `cli`. Published entry files don't store a `path` field, draft files (`*.draft.json`) do.
- Translations of an entry share the same `_id`, one file per locale folder.
## ID and index generation
```shellscript
# New id
node --input-type=module -e "import {createId} from 'alinea/core/Id'; console.log(createId())"
# Index between two siblings
node --input-type=module -e "import {generateKeyBetween} from 'alinea/core/util/FractionalIndexing'; console.log(generateKeyBetween('a0', 'a1'))"
# Append after last sibling
node --input-type=module -e "import {generateKeyBetween} from 'alinea/core/util/FractionalIndexing'; console.log(generateKeyBetween('a0', null))"
```
Alinea sorts sibling entries by `_index` ascending. Never hand-pick `_index` by eye when inserting between entries.
## Internal and external links
Links appear in two places: rich text marks and link fields (`Field.link` / `Field.link.multiple`).
### Rich text link marks
```json
[
{
"_type": "paragraph",
"content": [
{
"_type": "text",
"text": "Internal doc",
"marks": [
{
"_type": "link",
"_id": "",
"_link": "entry",
"_entry": ""
}
]
},
{
"_type": "text",
"text": " and external site",
"marks": [
{
"_type": "link",
"_id": "",
"_link": "url",
"href": "https://example.com",
"target": "_blank",
"title": ""
}
]
}
]
}
]
```
Rich text link mark shape is defined by `LinkMark` in `src/core/TextDoc.ts`: `_type: link`, `_id`, `_link` (`entry` | `file` | `url`), `_entry` for entry and file links, and `href`, `target` and `title` for URLs. Optional `_anchor` links to an anchor on the target page.
### Link field objects
```javascript
// Field.link('Link') -> single entry link
{
"_id": "",
"_type": "entry",
"_entry": "",
"label": "Optional extra field"
}
// Field.link('Link') -> single external url
{
"_id": "",
"_type": "url",
"_url": "https://example.com",
"_title": "Example",
"_target": "_blank",
"label": "Optional extra field"
}
// Field.link.multiple('Links') row
{
"_id": "",
"_index": "",
"_type": "entry",
"_entry": "",
"label": "Optional extra field"
}
```
For `Field.link.multiple`, each row is also a list row, so `_index` is required.
## Lists and union/list row metadata
List rows are defined by `ListRow` in `src/core/shape/ListShape.ts`. Every row must include `_id`, `_type`, `_index`.
```json
{
"items": [
{
"_id": "",
"_index": "a0",
"_type": "Item",
"title": "First"
},
{
"_id": "",
"_index": "a1",
"_type": "Item",
"title": "Second"
}
]
}
```
Union values (from `UnionShape`) require `_id` and `_type`. If a union is inside a list, it still needs list row `_index` as well.
## Rich text JSON format
Alinea rich text is a `TextDoc` array (`src/core/TextDoc.ts`). Common nodes are `heading`, `paragraph`, `text`, `bulletList`, `orderedList`, `listItem` and `hardBreak`. Blocks defined in the field's `schema` appear as nodes with the block's type name as `_type` (for example `CodeBlock`) and an `_id`.
```json
[
{
"_type": "heading",
"level": 2,
"content": [{"_type": "text", "text": "Heading"}]
},
{
"_type": "paragraph",
"textAlign": "left",
"content": [
{"_type": "text", "text": "Normal text "},
{
"_type": "text",
"text": "bold",
"marks": [{"_type": "bold"}]
},
{"_type": "text", "text": " "},
{
"_type": "text",
"text": "italic",
"marks": [{"_type": "italic"}]
},
{"_type": "hardBreak"},
{
"_type": "text",
"text": "anchor",
"marks": [
{
"_type": "link",
"_id": "",
"_link": "url",
"href": "https://example.com",
"target": "_blank",
"title": ""
}
]
}
]
},
{
"_type": "bulletList",
"content": [
{
"_type": "listItem",
"content": [
{
"_type": "paragraph",
"content": [{"_type": "text", "text": "Bullet item"}]
}
]
}
]
},
{
"_type": "orderedList",
"start": 1,
"content": [
{
"_type": "listItem",
"content": [
{
"_type": "paragraph",
"content": [{"_type": "text", "text": "Ordered item"}]
}
]
}
]
},
{
"_type": "CodeBlock",
"_id": "",
"code": "console.log('block nodes need _id')",
"language": "javascript",
"fileName": "",
"compact": false
}
]
```
If generating from HTML, Alinea's parser maps common tags to these node/mark types (`src/core/field/RichTextField.ts`), for example `