# 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 && ( {page.cover.alt )}
) } ``` `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 ( ) } ``` 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<typeof TextBlock>` 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<typeof TextBlock> function Link({href, ...props}: {href?: string; [key: string]: any}) { if (!href) return <a {...props} /> return <NextLink href={href!} {...props} /> } export function TextBlockView({block}: {block: TextBlockData}) { return <RichText doc={block.body} a={Link} /> } ``` ## 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<typeof ImageBlock> export function ImageBlockView({block}: {block: ImageBlockData}) { if (!block.image) return null const {src, width, height} = block.image return ( <Image src={src} width={width} height={height} alt={block.alt || ''} style={{width: '300px', height: 'auto'}} /> ) } ``` ## 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<typeof WeatherBlock> 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<number, string> = { 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 ( <section> <h2>{block.title}</h2> {!weather ? ( <p>Could not load weather for {block.region}.</p> ) : ( <p> {weather.location}: {weather.temperature} {weather.unit} ({weather.summary}) </p> )} </section> ) } ``` `'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 ( <main> <h1>{page.title}</h1> {page.blocks.map(block => { if (block._type === 'TextBlock') return <TextBlockView key={block._id} block={block} /> if (block._type === 'ImageBlock') return <ImageBlockView key={block._id} block={block} /> if (block._type === 'WeatherBlock') return <WeatherBlockView key={block._id} block={block} /> return null })} </main> ) } export async function generateMetadata(): Promise<Metadata> { 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<any> | string | undefined): string { if (!value) return '' if (typeof value === 'string') return value if (!Array.isArray(value)) return '' const result = value .reduce((acc, node) => { return acc + textOf(node) }, '') .trim() return result.replace(/ +(?= )/g, '') } function textOf(node: Node): string { if (node._type === 'hardBreak') return '\n' if (Node.isText(node)) { return node.text ? ' ' + node.text : '' } else if (Node.isElement(node) && node.content) { return node.content.reduce((acc, node) => { return acc + textOf(node) }, '') } return '' } ``` 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<typeof SiteLayout>` 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<typeof SiteLayoutEntry> export function SiteHeader({settings}: {settings: SiteLayoutProps}) { return <header>{settings.headerText}</header> } export function SiteFooter({settings}: {settings: SiteLayoutProps}) { return <footer>{settings.footerText}</footer> } ``` ## Add the Settings root Add a `settings` root next to `pages`. It only accepts `SiteLayout` entries, and the seeded `settings` page makes sure the one entry the layout needs always exists. The `icon` option takes any React component that renders an SVG and shows it next to the root in the dashboard. 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<SVGSVGElement>) { return ( <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props} > {/* Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE */} <path fill="currentColor" d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zM11 20h1.975l.35-2.65q.775-.2 1.438-.587t1.212-.938l2.475 1.025l.975-1.7l-2.15-1.625q.125-.35.175-.737T17.5 12t-.05-.787t-.175-.738l2.15-1.625l-.975-1.7l-2.475 1.05q-.55-.575-1.212-.962t-1.438-.588L13 4h-1.975l-.35 2.65q-.775.2-1.437.588t-1.213.937L5.55 7.15l-.975 1.7l2.15 1.6q-.125.375-.175.75t-.05.8q0 .4.05.775t.175.75l-2.15 1.625l.975 1.7l2.475-1.05q.55.575 1.213.963t1.437.587zm1.05-4.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5M12 12" /> </svg> ) } ``` ## Render it in the layout Query the settings in the root layout so the header and footer wrap every page. `cms.workspaces.main.settings` refers to the root, and `cms.get` returns the one entry of this type in it. Unlike `cms.first` it throws when nothing matches, which is what you want for content the site can't render without. 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 ( <html lang="en"> <body> <SiteHeader settings={settings} /> {children} <SiteFooter settings={settings} /> <cms.previews widget /> </body> </html> ) } ``` 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/<slug>` with links to the previous and next post. Posts are child entries of the blog overview, so editors create them in the content tree under Blog, and their URL follows from that: a post with path `hello-world` is at `/blog/hello-world`. 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 ( <main> <h1>{page.title}</h1> {page.intro && <p>{page.intro}</p>} <ul> {page.posts.map((post: PostLink) => ( <li key={post.id}> <Link href={post.url}>{post.title}</Link> </li> ))} </ul> </main> ) } export async function generateMetadata(): Promise<Metadata> { const page = await cms.first({url: '/blog', type: Blog}) if (!page) return {} return { title: page.metadata.title || page.title, description: page.metadata?.description || page.intro, openGraph: { title: page.metadata.openGraph.title || page.metadata.title || page.title, description: page.metadata.openGraph.description || page.metadata?.description || page.intro, images: page.metadata?.openGraph.image ? [page.metadata?.openGraph.image.src] : undefined } } } ``` ## Blog posts Posts have an editable path, which is the slug in the URL. The dashboard fills it in from the title as you type. 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 ( <article> <h1>{post.title}</h1> {typeof post.body === 'string' ? <p>{post.body}</p> : <RichText doc={post.body} />} <p> <Link href="/blog">← Back to the full blog archive</Link> </p> {(previousPost || nextPost) && ( <nav aria-label="Post navigation"> <h2>Next/Previous blogpost</h2> <ul> {previousPost && ( <li> <Link href={previousPost.url}> Previous: {previousPost.title} </Link> </li> )} {nextPost && ( <li> <Link href={nextPost.url}>Next: {nextPost.title}</Link> </li> )} </ul> </nav> )} </article> ) } export async function generatePostMetadata(slug: string): Promise<Metadata> { const post = await cms.first({url: `/blog/${slug}`, type: Post}) if (!post) return {} const bodyText = plainText(post.body) return { title: post.metadata.title || post.title, description: post.metadata?.description || post.excerpt || bodyText, openGraph: { title: post.metadata.openGraph.title || post.metadata.title || post.title, description: post.metadata.openGraph.description || post.metadata?.description || post.excerpt || bodyText, images: post.metadata?.openGraph.image ? [post.metadata?.openGraph.image.src] : undefined } } } function plainText(value: TextDoc<any> | string | undefined): string { if (!value) return '' if (typeof value === 'string') return value if (!Array.isArray(value)) return '' const result = value .reduce((acc, node) => { return acc + textOf(node) }, '') .trim() return result.replace(/ +(?= )/g, '') } function textOf(node: Node): string { if (node._type === 'hardBreak') return '\n' if (Node.isText(node)) { return node.text ? ' ' + node.text : '' } else if (Node.isElement(node) && node.content) { return node.content.reduce((acc, node) => { return acc + textOf(node) }, '') } return '' } ``` Loading all posts is fine for a small blog. For a long list you can select the neighbors in the same query as the post instead: `Query.previous` and `Query.next` return the sibling before and after the entry in content tree order, or `null`. 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 <BlogView /> } ``` 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<Metadata> { const {slug} = await params return generatePostMetadata(slug) } export default async function BlogPostRoute({params}: PostRouteProps) { const {slug} = await params return <PostView slug={slug} /> } ``` 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<typeof Page> export function PageView({page}: {page: PageData}) { return ( <main> <h1>{page.title}</h1> {page.blocks.map(block => { if (block._type === 'TextBlock') return <TextBlockView key={block._id} block={block} /> if (block._type === 'ImageBlock') return <ImageBlockView key={block._id} block={block} /> if (block._type === 'WeatherBlock') return <WeatherBlockView key={block._id} block={block} /> return null })} </main> ) } ``` ## 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<string>}> } 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<Metadata> { 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 <BlogView /> } if (page._type === 'Post') { const postSlug = slug[slug.length - 1] if (!postSlug) notFound() return <PostView slug={postSlug} /> } const regularPage = await cms.first({url, type: Page}) if (!regularPage) notFound() return <PageView page={regularPage} /> } ``` 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 `<tbody>`, 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 `<path>.draft.json` and `<path>.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/<path>, 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<string>}> } export async function generateMetadata({params}: PageProps): Promise<Metadata> { 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(<hr />), 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<TextNode | ElementNode | BlockNode> // 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 <Link href={href} {...props} /> } function Heading2(props: ComponentProps<'h2'>) { return <h2 className="prose-h2" {...props} /> } export function Prose({doc}: {doc: TextDoc}) { return ( <RichText doc={doc} // Add a class to every paragraph p={<p className="prose-p" />} // 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<typeof Quote>) { return ( <figure> <blockquote>{text}</blockquote> <figcaption>{author}</figcaption> </figure> ) } export function Body({doc}: {doc: TextDoc<typeof blocks>}) { return <RichText doc={doc} Quote={QuoteView} /> } ``` Type the document as `TextDoc<typeof blocks>` (a queried rich text field already has this type) and `<RichText>` checks the props of your block components. `RichText<typeof blocks>` 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(<RichText doc={doc} />) } ``` ## 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<typeof Page>['blocks'][number] export function Blocks({blocks}: {blocks: Array<Block>}) { return blocks.map(block => { switch (block._type) { case 'TextBlock': return <p key={block._id} id={block._anchor}>{block.text}</p> case 'ImageBlock': return <img key={block._id} src={block.image.src} alt={block.image.alt} /> } }) } ``` ## 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: '<iframe src="https://example.com/embed"></iframe>' }) } }) ``` ## 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 ( <a href={link.href} target={link.target || undefined}> {label} </a> ) return <Link href={link.href}>{label}</Link> } ``` 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 ( <a href={link.href} target={link.target || undefined} rel={link.target === '_blank' ? 'noopener noreferrer' : undefined} > {link.title || link.href} </a> ) } ``` 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 ( <a href={file.href} download> {file.title} ({file.extension.slice(1).toUpperCase()}, {kb} KB) </a> ) } ``` ### 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 ( <Image src={image.src} width={image.width} height={image.height} alt={image.alt ?? ''} sizes={sizes} placeholder={blurDataURL ? 'blur' : 'empty'} blurDataURL={blurDataURL} style={{ objectFit: 'cover', objectPosition: image.focus ? `${image.focus.x * 100}% ${image.focus.y * 100}%` : undefined }} /> ) } ``` ## 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 ( <figure> <img src={image.src} width={image.width} height={image.height} alt={alt || image.alt || ''} /> {caption && ( <figcaption> {caption} {credit && <small>{credit}</small>} </figcaption> )} </figure> ) } ``` 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/<folders>/<name>.<extension>`, 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 <h1>{home.title}</h1> } ``` ## 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<string> ``` `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<ReturnType<typeof yourQueryFunction>>`. ## 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<string>) { 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('<mark>', '</mark>', '…', 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 `'<mark>'`, `'</mark>'`, `'...'` 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<ReturnType<typeof latestPosts>>[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<string>}> } 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 <h1>{page.title}</h1> } ``` 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 ( <main> {posts.map(post => ( <a key={post.url} href={post.url}> <h2>{post.title}</h2> <p>{post.intro}</p> </a> ))} <p> Page {page} of {pageCount} </p> </main> ) } ``` ## 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<string>) { 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<MetadataRoute.Sitemap> { 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 <footer>{settings.title}</footer> } ``` 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 `<cms.previews />` 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 ( <html lang="en"> <body> {children} <cms.previews widget /> </body> </html> ) } ``` 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=<token>&returnTo=<entry url>` 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 `<cms.previews 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 <main>{/* render the snippet like it appears on your pages */}</main> } ``` ## 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 <div style={{padding: 16}}>{message || entry.title}</div> }, 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 `<title>`, 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('<h2>Main heading</h2><p>Parsed from <strong>HTML</strong>.</p>') .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<string>}> } function fetchPage(locale: string, slug: Array<string>) { 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<Metadata> { 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 ( <main> <nav> {page.translations.map(t => ( <a key={t.locale} href={t.url} hrefLang={t.locale!}> {t.locale} </a> ))} </nav> <h1>{page.title}</h1> </main> ) } ``` 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<ReturnType<typeof fetchPosts>>[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<typeof BlogPost> // The same, plus the entry fields (_id, _type, _url, ...) export type BlogPostEntry = Infer.Entry<typeof BlogPost> // A row of the list field, with _id, _index and _type export type TextBlock = Infer.ListItem<typeof TextBlock, 'Text'> // 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<T>`: the query value of a type, a field or a list schema (`{Text: TextBlock, ...}`, which becomes a union of rows). - `Infer.Entry<T, Name>`: `Infer<T>` plus the entry fields. Pass the type name to narrow `_type`. - `Infer.ListItem<T, Name>`: `Infer<T>` plus the list row fields `_id`, `_index` and `_type`. - `Infer.Stored<T>`: 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 <TextBlockView key={block._id} block={block} /> case 'Image': return <img key={block._id} src={block.image?.src} alt="" /> } })} </> ) } ``` ## 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://<your site><handlerUrl>?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": "<createId()>", "_type": "<schema type name>", "_index": "<fractional 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": "<createId()>", "_link": "entry", "_entry": "<target entry id>" } ] }, { "_type": "text", "text": " and external site", "marks": [ { "_type": "link", "_id": "<createId()>", "_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": "<createId()>", "_type": "entry", "_entry": "<target entry id>", "label": "Optional extra field" } // Field.link('Link') -> single external url { "_id": "<createId()>", "_type": "url", "_url": "https://example.com", "_title": "Example", "_target": "_blank", "label": "Optional extra field" } // Field.link.multiple('Links') row { "_id": "<createId()>", "_index": "<fractional index>", "_type": "entry", "_entry": "<target entry id>", "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": "<createId()>", "_index": "a0", "_type": "Item", "title": "First" }, { "_id": "<createId()>", "_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": "<createId()>", "_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": "<createId()>", "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 `<p>` -> `paragraph`, `<a>` -> `link`, `<ul>/<ol>/<li>` -> list nodes, `<strong>` -> `bold`. ## Roots and workspaces `Config.workspace` and `Config.root` define where content is stored and which root key each entry belongs to (`src/core/Workspace.ts`, `src/core/Root.ts`). ```text // Example: the content of the Alinea website content/ main/ pages/ docs.json docs/ reference/ cli.json media/ dashboard.json demo/ pages/ en/ index.json products.json products/ otto-stool.json nl/ ... authors/ maya-janssens.json media/ ... // Workspace key -> source main -> content/main demo -> content/demo // Root key -> folder under each workspace source pages -> <workspace>/pages media -> <workspace>/media // A translated root has a folder per locale demo pages (en, nl, fr) -> content/demo/pages/en, content/demo/pages/nl, ... ``` Manual generation rules: - When creating a root-level entry file, include `_root` with the matching root key. - Place files under the workspace source directory and root directory that match config. - For seeded pages, keep `_seeded` stable and matching the configured seed path. - Do not remove nested identity fields (`_id`, `_index`, `_type`) from list rows, union values, or rich text block nodes. ## Validation workflow ```shellscript # Fill in missing or incorrect properties with their defaults npx alinea build --fix # Final validation: your build script runs alinea build before next build npm run build ``` Before commit: ensure JSON parses, `_type` matches schema, `_index` order is correct among siblings, and no duplicate `_id` values were introduced in edited scope. ### MCP server (https://v2.alineacms.com/docs/mcp) `alinea dev` includes a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server. Coding agents such as Claude Code, Cursor or Codex connect to it to read your schema and to find, create, edit, publish, move and delete entries and to upload media. This page covers setup, the workflow agents should follow, every tool and the value formats they accept. ## Why use it Every Alinea entry is a JSON file in your repository, so an agent could write those files directly. In practice that is error prone: entries carry internal fields such as `_id`, `_index` and `_root`, list rows and rich text nodes need their own ids, links are stored as objects and media entries hold computed metadata such as dimensions, a thumbhash and a preview. The MCP server takes those details off the agent's hands: - Writes take the same path as the dashboard: the dev server's API handler saves the change, so the result is a valid entry file, laid out exactly like one saved by an editor. - Internal fields are generated. Agents send plain values: Markdown for rich text, entry ids for links, rows without ids for lists. - Input is validated against your schema. Errors name the field path and what is expected, so the agent can correct its input and retry. - An open dashboard updates live, so you can watch the changes come in and review them in the editor. The files the tools write are ordinary content files: review them with `git diff` and commit them as usual. ## When it runs The MCP server is part of `alinea dev` only. It is not included in `alinea build`, in the generated dashboard or in the handler you deploy, so it never runs in production. It only accepts requests from the local machine: requests whose `Host` or `Origin` header is not `localhost`, `127.0.0.1` or `::1` are refused with a 403, which also blocks web pages from reaching it through DNS rebinding. There is no authentication beyond that: anything running on your machine can use it while `alinea dev` runs. Changes are recorded as the local dashboard user. Note (info): Before every call the server reads the content directory again, so it also sees files that changed on disk since the last save, for example after a `git pull` or a manual edit. ## Connect your agent Start your dev script (`alinea dev`, or `alinea dev -- next dev` in a Next.js project). The banner prints the url of the MCP server: ```shellscript ɑ Alinea 2.0.0 ├ dev src/cms.tsx in 690ms (172 records) ╰ MCP server: http://localhost:4500/mcp ``` The server listens on the port of the local dashboard, 4500 by default. When 4500 is taken the dev server moves to 4501 and up, and the printed url changes with it. `alinea dev --port 4600` (or `-p`) changes the port it starts from, but it still moves up when that port is taken, so check the banner when the tools don't connect. The endpoint speaks the Streamable HTTP transport: it answers JSON-RPC messages sent with `POST` and does not keep sessions. ### Claude Code ```shellscript claude mcp add --transport http alinea http://localhost:4500/mcp ``` Add `--scope project` to write the server to the project's `.mcp.json` instead of your local settings, so everyone working on the project gets it. ### Project .mcp.json Claude Code and other clients read MCP servers from a `.mcp.json` file in the project root. Commit it to share the setup with your team: File: .mcp.json ```json { "mcpServers": { "alinea": { "type": "http", "url": "http://localhost:4500/mcp" } } } ``` ### Cursor Cursor reads project servers from `.cursor/mcp.json`, where the `url` alone is enough: File: .cursor/mcp.json ```json { "mcpServers": { "alinea": { "url": "http://localhost:4500/mcp" } } } ``` ### Codex and other clients Any client that supports remote (Streamable HTTP) MCP servers can connect: add an HTTP server named `alinea` with the url from the banner, following your client's documentation. Clients that only launch local stdio servers can reach it through a bridge such as [mcp-remote](https://www.npmjs.com/package/mcp-remote) (`npx mcp-remote http://localhost:4500/mcp`). Most agents connect to MCP servers when a session starts. Start `alinea dev` first, and restart or reload the agent's session if the tools don't show up. ## Recommended workflow The server sends these steps to agents as its instructions when they connect: 1. Call `describe_schema` for the workspaces, roots, types and their fields, and the value format of each field kind. 2. Find content, parents and ids with `find_entries` and `get_entry`. 3. Write with `create_entry` and `update_entry`, with `data` keyed by field name. Rich text is Markdown, links are entry ids. Upload images and files with `upload_file` first to get the media id for image and file fields. 4. Writes publish, pass `publish: false` to save a draft when drafts are enabled. 5. `get_entry` lists the entries linking to an entry, check them before deleting or moving it. After a batch of changes, check them in the dashboard or with `git diff`, and run your build to catch errors in the pages that render the content. ## Tools The examples show the `arguments` of a `tools/call` request and the JSON the tool returns. Ids are shortened for readability. ### describe_schema Describes the workspaces and roots (their locales and the types they accept), every entry type with its fields, and the value format of each field kind. Pass `type` to describe only that type. - Each field reads `kind[details] Label (flags)`. The label is left out when it matches the key, flags are `required`, `shared` and `readOnly`. - `list[...]` and `richText[...]` name the `_type` of each row or block. `object[name]` and `fields: name` refer to `definitions`, which describe nested types once. A row type described under another name reads `Row=Definition`. - `select[a|b]` lists the option keys, `link[entry|url]` the link types (`links` for multiple links) and `localised[en|nl] text` holds a value per locale. ```json {} ``` ```json { "enableDrafts": false, "workspaces": { "main": { "pages": {"contains": ["Page", "Blog"]}, "media": {"media": true, "contains": ["MediaLibrary"]} } }, "types": { "Blog": { "contains": ["BlogPost"], "fields": {"title": "text (required)", "path": "path (required)", "metadata": "object[metadata]"} }, "BlogPost": { "fields": { "title": "text (required)", "path": "path (required)", "publishDate": "date", "author": "link[entry]", "cover": "link[image] Cover image", "body": "richText", "metadata": "object[metadata]" } } }, "definitions": { "metadata": {"title": "text", "description": "text", "openGraph": "object[openGraph]", "...": "..."}, "openGraph": {"image": "link[image]", "title": "text", "description": "text"} }, "valueFormats": "..." } ``` Describe a single type: ```json {"type": "BlogPost"} ``` ### find_entries Lists entries matching filters, with the `total` number of matches. With `parentId` the entries are in their sibling order, otherwise they are ordered by url, and with `search` by relevance. Each entry has a `children` count and the path of its `file`. - `workspace`, `root`, `type`, `locale`: filter by location, type or translation. - `parentId`: only direct children of this entry, `null` for the top-level entries of a root. - `search`: full text search terms. - `status`: which versions to return, one of `preferDraft` (the default, the latest version of each entry), `preferPublished`, `published`, `draft`, `archived` or `all`. - `limit` (default 50) and `offset` for paging. ```json {"type": "BlogPost", "limit": 1} ``` ```json { "total": 12, "entries": [ { "id": "3JmZ4hN9", "type": "BlogPost", "title": "Hello world", "url": "/blog/hello-world", "parentId": "3JmZ1c0Y", "locale": null, "status": "published", "workspace": "main", "root": "pages", "children": 0, "file": "content/pages/blog/hello-world.json" } ] } ``` ### get_entry Reads one entry: its metadata, the path of its file, its versions, the entries linking to it (`referencedBy`) and its field data. Rich text is returned as Markdown, which `create_entry` and `update_entry` accept back. The stored JSON is in the entry's file. - `id` or `url`: the entry to read, for example `{"url": "/blog/hello-world"}`. - `locale`: the translation to read, defaults to the root's default locale. `versions` lists the locale and status of every version. ```json {"id": "3JmZ4hN9"} ``` ```json { "id": "3JmZ4hN9", "type": "BlogPost", "title": "Hello world", "url": "/blog/hello-world", "parentId": "3JmZ1c0Y", "locale": null, "status": "published", "workspace": "main", "root": "pages", "file": "content/pages/blog/hello-world.json", "versions": [{"locale": null, "status": "published"}], "referencedBy": [ {"id": "3JmZ5rE8", "title": "Choosing a CMS", "type": "BlogPost", "locale": null, "field": "body"} ], "data": { "title": "Hello world", "path": "hello-world", "publishDate": "2026-09-23", "author": {"_id": "3JmZ9kT2", "_type": "entry", "_entry": "3JmZ2aP5"}, "body": "Our first post, written by [Ada](entry:3JmZ2aP5).", "metadata": {"title": "", "description": "", "openGraph": {"title": "", "image": {}, "description": ""}} } } ``` ### create_entry Creates an entry, or a translation of an existing entry. - `type`: the type name. It must be allowed in the parent (its `contains`) or, at the top level, in the root. - `parentId`: the parent entry, leave it out to create the entry at the top level of a root. The entry is added after its siblings, unless the parent type sets an insert order. - `workspace` and `root`: default to those of the parent, or to the first workspace and its first root. - `locale`: the locale of the entry in a translated root, defaults to the root's default locale. - `translationOf` with `locale`: create the translation of an existing entry. Fields you don't pass are copied from the existing translation. - `data`: field values keyed by field name. `title` is required, `path` defaults to the slugified title. - `publish`: `true` by default, `false` saves a draft. ```json { "type": "BlogPost", "parentId": "3JmZ1c0Y", "data": { "title": "Choosing a CMS", "publishDate": "2026-09-23", "author": "3JmZ2aP5", "cover": "3JmZ7xC4", "body": "## Why git-based\n\nContent lives next to the code, see [our first post](entry:3JmZ4hN9)." } } ``` ```json { "id": "3JmZ5rE8", "type": "BlogPost", "title": "Choosing a CMS", "url": "/blog/choosing-a-cms", "parentId": "3JmZ1c0Y", "locale": null, "status": "published", "workspace": "main", "root": "pages", "file": "content/pages/blog/choosing-a-cms.json" } ``` Translate an entry into another locale of its root: ```json { "translationOf": "3JmZ5rE8", "locale": "nl", "data": {"title": "Een CMS kiezen", "body": "## Waarom git\n\n..."} } ``` The parent must already exist in that locale. Media files can't be created this way, use `upload_file`. ### update_entry Changes fields of an existing entry. Only the fields in `data` change, everything else stays as it is. Object and localised fields merge key by key, rich text and lists are replaced, and list rows that keep their `_id` keep their stored values (see [Lists](#lists)). - `id`: the entry to change. - `locale`: the translation to change, defaults to the root's default locale. - `data`: the field values to change. - `publish`: `true` by default, `false` saves the change as a draft. ```json {"id": "3JmZ5rE8", "data": {"title": "How to choose a CMS", "path": "how-to-choose-a-cms"}} ``` ```json { "id": "3JmZ5rE8", "type": "BlogPost", "title": "How to choose a CMS", "url": "/blog/how-to-choose-a-cms", "parentId": "3JmZ1c0Y", "locale": null, "status": "published", "workspace": "main", "root": "pages", "file": "content/pages/blog/how-to-choose-a-cms.json" } ``` When the data matches what is stored, nothing is written and the result says `"note": "No changes"`. ### delete_entry Deletes an entry with its children, in all locales. Media files are removed from disk as well. It refuses while other entries link to the entry. - `id`: the entry to delete. - `locale`: only delete this translation. - `status`: only delete this version: `"draft"` discards the draft, `"published"` or `"archived"` remove that version. - `force`: delete even when other entries link to it, see [References and safe deletes](#references-and-safe-deletes). ```json {"id": "3JmZ5rE8"} ``` ```json {"deleted": "3JmZ5rE8"} ``` ### publish_entry Publishes the draft of an entry, or restores an archived entry. With `archive: true` it archives the published entry instead: it is hidden from the site but kept, and publishing restores it. An already published entry is left alone and the result says `"note": "Already published"`. - `id`: the entry. - `locale`: the translation to publish. - `archive`: archive the entry instead of publishing it. ```json {"id": "3JmZ5rE8"} ``` ```json {"id": "3JmZ5rE8", "locale": null, "status": "published"} ``` Archive an entry: ```json {"id": "3JmZ5rE8", "archive": true} ``` ```json {"id": "3JmZ5rE8", "locale": null, "status": "archived"} ``` ### move_entry Moves an entry, with its children and translations. Pass exactly one of: - `after` or `before`: an entry id, places the entry right after or before that sibling, under the same parent. - `parentId`: a new parent, the entry becomes its last child. `null` moves the entry to the top level of its root, or of another root in the same workspace given as `root`. The entry's type must be allowed in its new parent. ```json {"id": "3JmZ5rE8", "before": "3JmZ4hN9"} ``` The result is the entry's summary with its new `url` and `file`. ### upload_file Adds a local file to a workspace's media library. Image dimensions, the average color, a thumbhash and a preview are computed, as in the dashboard. Images larger than the `resizeImages` option are scaled down first. - `path`: a local file, relative to the project directory (where `alinea dev` runs) or absolute inside the enclosing git repository. Hidden files such as `.env`, and files outside these directories, are refused. - `workspace`: the workspace to upload to, defaults to the first one. The file goes to the workspace's media root. - `parentId`: a media folder (`MediaLibrary` entry) to upload into. - `title`: the title of the media entry, defaults to the file name. - `alt`: the alt text, a string, or strings keyed by locale when the media root is translated. - `rotate`: turns a JPEG, PNG or WebP image clockwise by 90, 180 or 270 degrees before it is uploaded. - `crop`: the region of a JPEG, PNG or WebP image to keep, after rotating, as `{"x", "y", "width", "height"}` fractions from 0 to 1 of the image. - `replace`: the id of an existing media entry whose file to replace, see [Media](#media). ```json {"path": "assets/cover.jpg", "title": "Cover", "alt": "A desk with a laptop"} ``` ```json { "id": "3JmZ7xC4", "type": "MediaFile", "title": "Cover", "url": "/admin/file/cover.jpg", "parentId": null, "locale": null, "status": "published", "workspace": "main", "root": "media", "file": "content/media/cover.json", "location": "/cover.3JmZ7yD5.jpg" } ``` Use the returned `id` in image and file fields, or as `![alt](entry:3JmZ7xC4)` in rich text. ## Value formats `describe_schema` returns these formats as `valueFormats`, so agents can look them up while they work. - Text, code, path, date and time fields: a string. Dates are ISO dates (`"2026-09-23"`), times `"14:30"`. A path is the url slug and defaults to the slugified title. - Number: a number. Check: a boolean. - Select: an option key (not its label). Multiple select: an array of option keys. - Object fields, such as metadata: an object of nested fields, only the given keys change. - JSON and hidden fields: any JSON value, stored as is. - Values are checked against the field kind: a value of the wrong type, an unknown option or an unknown field fails with an error naming the field. ### Rich text Rich text fields accept Markdown: headings, `**bold**`, `*italic*`, `~~strike~~`, links, bullet and numbered lists, `>` quotes, `---`, tables, and images on their own line. `get_entry` returns rich text as Markdown too, so an agent can read a field, change it and send it back. - Link to an entry with `[text](entry:ID)`, or to an anchor on its page with `[text](entry:ID#anchor)`. Other urls become external links. - Insert an image from the media library with `![alt](entry:MEDIA_ID)` on its own line. - A fenced code block becomes the first block type of the field that has a `code` field, with the language and other fields in the info string. Keep the `id` of an existing block to keep that block: ```markdown ```ts id=3JmZ6pQ1 fileName=cms.ts export const cms = createCMS({...}) ``` ``` - Other blocks are written as an `alinea-block` fence holding the block's JSON: ```markdown ```alinea-block {"_type": "NoticeBlock", "level": "info", "body": "Drafts are enabled on this site."} ``` ``` - Inline `` `code` `` stays text, including its backticks: rich text has no code mark. You can also pass stored TextDoc JSON, as found in the entry's file. ### Links - Entry, image and file links: the id of the entry or media entry (`entry:ID` works too), or `{"id": "...", ...fields}` to fill the link's extra fields. Images and files are media entries, upload them with `upload_file` first. - Url links: a url, or `{"url": "https://example.com", "title": "Example", "target": "_self"}` (the target defaults to `_blank`). - Multiple links: an array of the above. `null` clears a single link. - The stored link objects that `get_entry` returns are accepted as they are. Every id is checked: linking to an entry that doesn't exist, or to an entry that isn't media where an image or file is expected, fails with an error naming the field. ### Lists Pass an array of rows, which replaces the list. Each row is an object with the `_type` of its row type and its fields; `_type` can be left out when the list has a single row type. A row with the `_id` of a current row updates that row, so only send the fields that change. Rows without an `_id` are added, rows you leave out are removed, and the array order is the new order. This changes the title of the first row, adds a row after it and keeps the third row, any other rows are removed: ```json { "id": "3JmZ1c0Y", "data": { "sections": [ {"_id": "3JmZ8qW1", "title": "New title"}, {"_type": "Cta", "title": "Get started"}, {"_id": "3JmZ8rT4"} ] } } ``` Nested lists take the same form. A row's type can't be changed in place: leave the row out and add a new one. ### Localised values and translations Entries in a translated root exist once per locale. Tools read and write one translation at a time with `locale`, which defaults to the root's default locale, and `create_entry` with `translationOf` adds a translation. Fields declared as shared in the schema are copied to the other locales when they change. Fields wrapped in [Field.localiser](https://v2.alineacms.com/docs/internationalization) hold a value per locale in a single entry. Write them as an object keyed by locale, only the given locales change: ```json {"id": "3JmZ3uB6", "data": {"badge": {"en": "New", "nl": "Nieuw"}}} ``` ### Media Media files are entries of type `MediaFile` in a media root, created by `upload_file`. Their `alt` text is a string, or strings keyed by locale when the media root is translated, and their `focus` point (`{"x": 0.5, "y": 0.3}`, from 0 to 1) decides how images are cropped. To swap the file of an existing media entry, for example a new version of a logo, pass its id as `replace`: ```json {"path": "assets/logo-2026.svg", "replace": "3JmZ7xC4"} ``` The entry keeps its id, so every image field and rich text image that links to it keeps working. The new file gets a new location and the old file is removed. The title, alt text and focus point are kept, unless you pass a new title or alt text. Change the title, alt text or focus point without a new file with `update_entry`: `{"id": "3JmZ7xC4", "data": {"alt": "The new logo", "focus": {"x": 0.5, "y": 0.3}}}`. ## Drafts and publishing Writes are published by default. When the config sets `enableDrafts: true`, pass `publish: false` to `create_entry` or `update_entry` to save a draft instead: the published version stays live until the draft is published, from the dashboard or with `publish_entry`. Without drafts enabled `publish: false` fails. `describe_schema` reports `enableDrafts`, so agents know which to use. Every published change is a file change in your working tree. Nothing is committed or deployed until you commit and push, or until an editor publishes from a deployed dashboard. ## References and safe deletes Entries link to each other through link fields and rich text. Before deleting, moving or replacing content, check `referencedBy` in the result of `get_entry` to see what links to it. `delete_entry` checks this itself: when other entries link to the entry it refuses, and lists every entry and field that holds a link: ```text Entry "3JmZ2aP5" is linked from: - Hello world (3JmZ4hN9) field author - Choosing a CMS (3JmZ5rE8) field author Change those links or pass force: true ``` Update or remove those links first, or pass `force: true` to delete the entry anyway and leave the links pointing to a missing entry. Links from the entry's own children count as well. Deleting only a draft, or a single translation, isn't checked. ## Limitations - The server only runs with `alinea dev` on your machine. To change content on a deployed site, edit in the dashboard, or change the files locally and deploy them. - Changes are written to the working tree: the server doesn't commit, push or open pull requests. - Changes are recorded as the local dashboard user, not as the person who asked the agent. - Uploaded files are stored in the workspace's `mediaDir` in your project, like uploads in the local dashboard. - Schema changes are made in code: the tools read the schema but can't change it. Save `cms.ts`, let the dev server rebuild and call `describe_schema` again. For the shape of content files, when you do need to edit them by hand, see [Working with AI agents](https://v2.alineacms.com/docs/ai-agents). To have an agent set up Alinea in a new project, including this server, see [Set up with an AI agent](https://v2.alineacms.com/docs/ai-setup). ### Dashboard UI (https://v2.alineacms.com/docs/dashboard-ui) Build custom fields and views that look and behave like the rest of the dashboard. ### Components (https://v2.alineacms.com/docs/components) The dashboard is built from a library of React components, exported from `alinea/components`. Use them in [custom fields](https://v2.alineacms.com/docs/custom-fields), `Field.view` sections and custom entry or root views, so your additions look and behave like the rest of the dashboard, in light and dark mode. Components are the React components the dashboard is built from, import them from `alinea/components` to build custom views. To model content, see [Fields](https://v2.alineacms.com/docs/fields). ### Actions and navigation - [Button](https://v2.alineacms.com/docs/components/button): Triggers an action, in solid, outline, ghost and link variants. - [Toggle](https://v2.alineacms.com/docs/components/toggle): A button that stays pressed until it is pressed again. - [ToggleGroup](https://v2.alineacms.com/docs/components/toggle-group): A row of toggles where one or several can be pressed. - [Toolbar](https://v2.alineacms.com/docs/components/toolbar): Groups buttons and toggles, like a text editor toolbar. - [Tabs](https://v2.alineacms.com/docs/components/tabs): Layered sections of content, shown one at a time. - [Breadcrumb](https://v2.alineacms.com/docs/components/breadcrumb): Shows where the current page sits in a hierarchy. - [Link](https://v2.alineacms.com/docs/components/link): An inline link, plain or underlined. - [FileTrigger](https://v2.alineacms.com/docs/components/file-trigger): Opens the file browser from any button. ### Forms - [TextField](https://v2.alineacms.com/docs/components/text-field): A labelled single or multiline text input. - [NumberField](https://v2.alineacms.com/docs/components/number-field): A number input with increment and decrement buttons. - [SearchField](https://v2.alineacms.com/docs/components/search-field): A text input for search queries, with a clear button. - [Select](https://v2.alineacms.com/docs/components/select): Picks a single value from a list of options. - [MultipleSelect](https://v2.alineacms.com/docs/components/multiple-select): Picks several values, shown as tags in the field. - [ComboBox](https://v2.alineacms.com/docs/components/combo-box): A text input that filters a list of options. - [Checkbox](https://v2.alineacms.com/docs/components/checkbox): A single checkbox with a label. - [CheckboxGroup](https://v2.alineacms.com/docs/components/checkbox-group): A labelled group of checkboxes for several values. - [RadioGroup](https://v2.alineacms.com/docs/components/radio-group): Picks one value from a small set of options. - [Switch](https://v2.alineacms.com/docs/components/switch): Turns a setting on or off. - [DateField](https://v2.alineacms.com/docs/components/date-field): Types a date segment by segment. - [TimeField](https://v2.alineacms.com/docs/components/time-field): Types a time of day segment by segment. - [DatePicker](https://v2.alineacms.com/docs/components/date-picker): A date field with a calendar popover. - [DateRangePicker](https://v2.alineacms.com/docs/components/date-range-picker): Picks a start and end date from a calendar. - [Calendar](https://v2.alineacms.com/docs/components/calendar): A month grid to pick a date or a range of dates. - [TagGroup](https://v2.alineacms.com/docs/components/tag-group): A list of tags that can be selected or removed. - [ColorSwatchPicker](https://v2.alineacms.com/docs/components/color-swatch-picker): Picks a color from a set of swatches. - [DropZone](https://v2.alineacms.com/docs/components/drop-zone): A target to drop files or other content on. - [Field](https://v2.alineacms.com/docs/components/field): The label, help text and error around your own input. ### Collections - [Table](https://v2.alineacms.com/docs/components/table): Rows and columns with sorting, selection and dragging. - [List](https://v2.alineacms.com/docs/components/list): A list of items with a visual, title and status. - [SortableList](https://v2.alineacms.com/docs/components/sortable-list): Rows that editors reorder by dragging, like list fields. - [Tree](https://v2.alineacms.com/docs/components/tree): Nested items that expand and collapse. - [DataList](https://v2.alineacms.com/docs/components/data-list): Label and value pairs, such as the details of a file. - [ContentGrid](https://v2.alineacms.com/docs/components/content-grid): A selectable grid of cards, like the media library. - [ContentCard](https://v2.alineacms.com/docs/components/content-card): A card with a preview, title and details. - [MediaPreview](https://v2.alineacms.com/docs/components/media-preview): A thumbnail of an image or an icon for other files. ### Overlays - [Dialog](https://v2.alineacms.com/docs/components/dialog): A modal window that asks for a decision or input. - [DropdownMenu](https://v2.alineacms.com/docs/components/dropdown-menu): A menu of actions that opens from a button. - [Popover](https://v2.alineacms.com/docs/components/popover): Rich content in a panel next to its trigger. - [Tooltip](https://v2.alineacms.com/docs/components/tooltip): A short description shown on hover or focus. - [Command](https://v2.alineacms.com/docs/components/command): A searchable list of commands, like a command palette. ### Feedback and text - [Alert](https://v2.alineacms.com/docs/components/alert): A callout for an important message. - [Badge](https://v2.alineacms.com/docs/components/badge): A small label, colored like an entry status. - [Empty](https://v2.alineacms.com/docs/components/empty): The placeholder for a view without content. - [Spinner](https://v2.alineacms.com/docs/components/spinner): Shows that something is loading. - [Timestamp](https://v2.alineacms.com/docs/components/timestamp): A date shown relative to now, with the full date on hover. - [Heading](https://v2.alineacms.com/docs/components/heading): A title in one of five sizes. - [Text](https://v2.alineacms.com/docs/components/text): Body text with sizes, weights and colors. - [Code](https://v2.alineacms.com/docs/components/code): Inline code, soft, outlined or plain. - [Kbd](https://v2.alineacms.com/docs/components/kbd): A keyboard key or shortcut. - [Blockquote](https://v2.alineacms.com/docs/components/blockquote): A quotation set apart from the text. - [Icon](https://v2.alineacms.com/docs/components/icon): Renders an icon component at the text size. - [FoldIcon](https://v2.alineacms.com/docs/components/fold-icon): A chevron that turns when its section opens. ### Layout - [Surface](https://v2.alineacms.com/docs/components/surface): A bordered panel for a group of content. - [Collapsible](https://v2.alineacms.com/docs/components/collapsible): A section that opens and closes. - [Page](https://v2.alineacms.com/docs/components/page): The header, content and footer of a dashboard page. - [AppShell](https://v2.alineacms.com/docs/components/app-shell): The frame of a full dashboard view. - [Sidebar](https://v2.alineacms.com/docs/components/sidebar): A side panel with groups of navigation. - [NavRail](https://v2.alineacms.com/docs/components/nav-rail): A narrow bar of icon links to switch sections. - [ResizablePanelGroup](https://v2.alineacms.com/docs/components/resizable-panel-group): Panels with handles to resize them. - [PreviewFrame](https://v2.alineacms.com/docs/components/preview-frame): A device frame for a live preview, with its toolbar. ## Entry points Custom dashboard code imports from two entry points: - `alinea/components`: generic UI building blocks, such as buttons, inputs, dialogs and tables. They don't know about your content and work anywhere inside the dashboard. - `alinea/cms`: what is tied to the CMS, such as the hooks that read and write field values, the entry being edited and the signed-in user, `FieldChrome` and the prop types of field, type and root views. See [the alinea/cms API](https://v2.alineacms.com/docs/custom-fields#hooks) in Custom fields. Image: A stock overview panel in the demo, a custom view built with alinea/components. (https://v2.alineacms.com/admin/file/screenshots/dashboard-stock.webp) ## Usage File: fields/Priority.view.tsx ```tsx import {type FieldViewProps, useField, useFieldOptions} from 'alinea/cms' import {Select, SelectItem} from 'alinea/components' import type {Priority, PriorityField} from './Priority' export function PriorityView({field}: FieldViewProps<PriorityField>) { const [value, setValue] = useField(field) const options = useFieldOptions(field) return ( <Select label={options.label} value={value} onValueChange={next => setValue((next ?? 'normal') as Priority)} readOnly={options.readOnly} > <SelectItem value="low">Low</SelectItem> <SelectItem value="normal">Normal</SelectItem> <SelectItem value="high">High</SelectItem> </Select> ) } ``` The components are styled by the dashboard's stylesheet and theme, so they are meant for code that runs inside the dashboard. ## Conventions The API follows [shadcn/ui](https://ui.shadcn.com) and [Radix](https://www.radix-ui.com), so the names will feel familiar: - Compound components are named after their parts: `Dialog`, `DialogTrigger`, `DialogContent`, `DialogTitle`. - Controlled and uncontrolled state use the same prop names everywhere: `value`, `defaultValue` and `onValueChange`; `checked` and `onCheckedChange`; `open`, `defaultOpen` and `onOpenChange`. - Form controls take `label`, `description`, `error`, `required`, `disabled`, `readOnly`, `icon` and `shared`, and render their label and messages with `Field`. - Dates and times are ISO strings (`2026-09-23`, `14:30`). Collection keys are strings or numbers, and a selection is a `Set` of keys or `'all'`. - Every component accepts `className` and `style`. Each rendered part has a `data-slot` attribute (`data-slot="dialog-content"`), and variants are exposed as `data-variant` and `data-size`, which gives your CSS stable hooks. - Colors, fonts and radii come from CSS variables such as `--alinea-fg`, `--alinea-bg`, `--alinea-border`, `--alinea-primary` and `--alinea-radius`. Use them in your own styles to follow the theme. The props of every component are typed, so your editor shows what each one accepts. Types shared between components, such as `Selection`, `Key` and `IconType`, are exported as types from `alinea/components` too. ## Icons Components with an `icon` prop take an icon component (any component that renders an `<svg>`, such as those from [react-icons](https://react-icons.github.io/react-icons) or copied from [Icones](https://icones.js.org)) or an element. The icons of the dashboard itself are exported from `alinea/dashboard/icons`. ### Alert (https://v2.alineacms.com/docs/components/alert) A callout for an important message, with a title, a description and optional actions. Example: Alert ```tsx import {Alert, AlertDescription, AlertTitle} from 'alinea/components' import {IcRoundTranslate} from 'alinea/dashboard/icons' export function AlertExample() { return ( <Alert icon={IcRoundTranslate} style={{width: 300}}> <AlertTitle>Not translated yet</AlertTitle> <AlertDescription> This product page has no French version. </AlertDescription> </Alert> ) } ``` ## Variants `warning` asks for attention and `destructive` reports an error. Destructive alerts get `role="alert"`, which screen readers announce right away, the others use `role="status"`. Example: Alert: variants ```tsx import {Alert, AlertDescription, AlertTitle} from 'alinea/components' import { IcBaselineErrorOutline, IcRoundInfo, IcRoundWarning } from 'alinea/dashboard/icons' export function AlertVariantsExample() { return ( <div style={{display: 'grid', gap: 12, width: 420}}> <Alert icon={IcRoundInfo}> <AlertTitle>Scheduled for Monday</AlertTitle> <AlertDescription> The autumn collection goes live at 9:00. </AlertDescription> </Alert> <Alert variant="warning" icon={IcRoundWarning}> <AlertTitle>Unpublished changes</AlertTitle> <AlertDescription>Tom Verbeke is editing this page.</AlertDescription> </Alert> <Alert variant="destructive" icon={IcBaselineErrorOutline}> <AlertTitle>Could not publish</AlertTitle> <AlertDescription> The path /linen-shirt is already in use. </AlertDescription> </Alert> </div> ) } ``` ## Actions AlertActions holds buttons below the description, for example to retry or to dismiss the message. Example: Alert: actions ```tsx import { Alert, AlertActions, AlertDescription, AlertTitle, Button } from 'alinea/components' import {IcRoundHistory} from 'alinea/dashboard/icons' export function AlertActionsExample() { return ( <Alert icon={IcRoundHistory} style={{width: 420}}> <AlertTitle>A newer draft exists</AlertTitle> <AlertDescription> Maya Janssens saved a draft of “Oak dining chair” 5 minutes ago. </AlertDescription> <AlertActions> <Button size="sm" variant="outline"> Discard </Button> <Button size="sm" color="primary"> Open draft </Button> </AlertActions> </Alert> ) } ``` ## Props `Alert` A callout for an important message: AlertTitle, AlertDescription and AlertActions | Prop | Type | Default | Description | | --- | --- | --- | --- | | variant | `'default' \| 'destructive' \| 'warning'` | `'default'` | | | icon | `IconType \| ReactElement` | | | | role | `'alert' \| 'status' \| 'note'` | | Defaults to `alert` for destructive alerts, which interrupts assistive technology, and `status` for the others | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `AlertTitle` | Prop | Type | Default | Description | | --- | --- | --- | --- | | id | `string` | | | Also accepts `className` and `style`, `data-*` attributes. `AlertDescription` | Prop | Type | Default | Description | | --- | --- | --- | --- | | id | `string` | | | Also accepts `className` and `style`, `data-*` attributes. `AlertActions` Controls below the description | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `data-*` attributes. ### AppShell (https://v2.alineacms.com/docs/components/app-shell) The frame of a full dashboard view. Example: AppShell ```tsx import { AppShell, AppShellContent, NavRail, NavRailContent, NavRailItem, Page, PageHeader, PageTitle, ResizableHandle, ResizablePanel, ResizablePanelGroup, Sidebar, SidebarHeader, SidebarInset, Text } from 'alinea/components' import {LucideFile, LucideImage} from 'alinea/dashboard/icons' export function AppShellExample() { return ( <div style={{width: 640, height: 320}}> <AppShell> <NavRail aria-label="Roots"> <NavRailContent> <NavRailItem icon={LucideFile} label="Pages" active /> <NavRailItem icon={LucideImage} label="Media" /> </NavRailContent> </NavRail> <AppShellContent> <ResizablePanelGroup> <ResizablePanel defaultSize={200} minSize={160} priority="low"> <Sidebar aria-label="Pages"> <SidebarHeader> <Text weight="semibold">Oak & Loom</Text> </SidebarHeader> </Sidebar> </ResizablePanel> <ResizableHandle /> <ResizablePanel minSize={240} priority="high"> <SidebarInset> <Page> <PageHeader> <PageTitle>Linen shirt</PageTitle> </PageHeader> </Page> </SidebarInset> </ResizablePanel> </ResizablePanelGroup> </AppShellContent> </AppShell> </div> ) } ``` ## Props `AppShell` The full height application layout: a NavRail next to the AppShellContent surface that holds the sidebars and pages. | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `AppShellContent` The raised surface next to the NavRail, rendered as the main landmark | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Badge (https://v2.alineacms.com/docs/components/badge) A small label for a status or a category. With `status` it takes the color the dashboard uses for that entry status. Example: Badge ```tsx import {Badge} from 'alinea/components' export function BadgeExample() { return ( <> <Badge status="published">Published</Badge> <Badge status="draft">Draft</Badge> <Badge>Page</Badge> </> ) } ``` ## Statuses Example: Badge: statuses ```tsx import {Badge} from 'alinea/components' export function BadgeStatusesExample() { return ( <> <Badge status="published">Published</Badge> <Badge status="draft">Draft</Badge> <Badge status="unpublished">Unpublished</Badge> <Badge status="archived">Archived</Badge> <Badge status="untranslated">Untranslated</Badge> </> ) } ``` ## Icons and sizes Example: Badge: icons ```tsx import {Badge} from 'alinea/components' import { IcRoundArchive, IcRoundCheck, IcRoundEdit, IcRoundPublic, LucideFile } from 'alinea/dashboard/icons' export function BadgeIconsExample() { return ( <> <Badge icon={IcRoundCheck} status="published"> Published </Badge> <Badge icon={IcRoundEdit} status="draft"> Draft </Badge> <Badge icon={IcRoundArchive} status="archived"> Archived </Badge> <Badge icon={LucideFile} size="sm"> Blog post </Badge> <Badge icon={IcRoundPublic} size="sm"> Shared </Badge> </> ) } ``` ## Props `Badge` | Prop | Type | Default | Description | | --- | --- | --- | --- | | icon | `IconType \| ReactElement` | | | | size | `'default' \| 'sm'` | `'default'` | | | status | `ContentStatus` | | Colors the badge like the matching entry status | | title | `string` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ## Accessibility A badge is plain text. When its color carries meaning, make sure the text says the same. ### Blockquote (https://v2.alineacms.com/docs/components/blockquote) A quotation set apart from the text. Example: Blockquote ```tsx import {Blockquote} from 'alinea/components' export function BlockquoteExample() { return ( <Blockquote style={{width: 360}}> We build every chair to outlast the table it stands at. </Blockquote> ) } ``` ## Props `Blockquote` | Prop | Type | Default | Description | | --- | --- | --- | --- | | cite | `string` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Breadcrumb (https://v2.alineacms.com/docs/components/breadcrumb) Shows where the current page sits in a hierarchy. Example: Breadcrumb ```tsx import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from 'alinea/components' export function BreadcrumbExample() { return ( <Breadcrumb> <BreadcrumbList> <BreadcrumbItem> <BreadcrumbLink href="#pages">Pages</BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbLink href="#blog">Blog</BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbPage>Caring for linen</BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb> ) } ``` ## Props `Breadcrumb` Navigation landmark that shows the path to the current page | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `BreadcrumbList` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `data-*` attributes. `BreadcrumbItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `data-*` attributes. `BreadcrumbLink` | Prop | Type | Default | Description | | --- | --- | --- | --- | | href | `string` | | | | target | `string` | | | | rel | `string` | | | | asChild | `boolean` | | Merge the link styling onto the single child element | | onClick | `(event: MouseEvent<HTMLAnchorElement>) => void` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `BreadcrumbPage` The current page, the last item of the list | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `data-*` attributes. `BreadcrumbSeparator` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children | `ReactNode` | | Replaces the default chevron | Also accepts `className` and `style`, `data-*` attributes. `BreadcrumbEllipsis` Stands in for collapsed items Accepts `className` and `style`, `data-*` attributes. ### Button (https://v2.alineacms.com/docs/components/button) Triggers an action. Buttons come in solid, outline, ghost and link variants, in five colors and with sizes for toolbars and icon-only buttons. Example: Button ```tsx import {Button} from 'alinea/components' import {IcRoundCheck} from 'alinea/dashboard/icons' export function ButtonExample() { return ( <> <Button variant="outline">Cancel</Button> <Button color="primary" icon={IcRoundCheck}> Publish </Button> </> ) } ``` ## Variants Solid is the default. Use outline for secondary actions, ghost inside toolbars and dense rows, and link for an action that sits in a line of text. Example: Button: variants ```tsx import {Button} from 'alinea/components' export function ButtonVariantsExample() { return ( <> <Button>Solid</Button> <Button variant="outline">Outline</Button> <Button variant="ghost">Ghost</Button> <Button variant="link">Link</Button> </> ) } ``` ## Colors Primary marks the main action of a screen. Destructive and warning are for actions that remove or overwrite content. Example: Button: colors ```tsx import {Button} from 'alinea/components' export function ButtonColorsExample() { return ( <> <Button>Neutral</Button> <Button color="primary">Primary</Button> <Button color="secondary">Secondary</Button> <Button color="destructive">Destructive</Button> <Button color="warning">Warning</Button> </> ) } ``` ## Sizes Small fits toolbars and table rows, large suits empty states. The icon sizes render a square button without a label: give it an `aria-label`. Example: Button: sizes ```tsx import {Button} from 'alinea/components' import {IcRoundEdit} from 'alinea/dashboard/icons' export function ButtonSizesExample() { return ( <> <Button size="sm">Small</Button> <Button>Default</Button> <Button size="lg">Large</Button> <Button size="icon" variant="outline" icon={IcRoundEdit} aria-label="Edit" /> </> ) } ``` ## Icons and loading Pass an icon component to show it before the label. While `loading` is set the button shows a spinner and can't be pressed again. Example: Button: icons ```tsx import {Button} from 'alinea/components' import {IcRoundAdd} from 'alinea/dashboard/icons' export function ButtonIconsExample() { return ( <> <Button variant="outline" icon={IcRoundAdd}> Add entry </Button> <Button color="primary" loading> Publishing </Button> </> ) } ``` ## Props `Button` | Prop | Type | Default | Description | | --- | --- | --- | --- | | variant | `'solid' \| 'outline' \| 'ghost' \| 'link'` | `'solid'` | `link` looks like inline text that underlines on hover | | color | `'neutral' \| 'primary' \| 'secondary' \| 'destructive' \| 'warning'` | `'neutral'` | | | size | `'default' \| 'sm' \| 'lg' \| 'icon' \| 'icon-sm' \| 'icon-lg'` | `'default'` | | | active | `boolean` | | Renders the button in its selected state, eg. an active toolbar tool | | icon | `IconType \| ReactElement` | | | | asChild | `boolean` | | Merge the button styling onto the single child element, eg. a link | | type | `'button' \| 'submit' \| 'reset'` | | | | form | `string` | | | | name | `string` | | | | value | `string` | | | | disabled | `boolean` | | | | loading | `boolean` | | | | autoFocus | `boolean` | | | | aria-expanded | `boolean` | | | | aria-pressed | `boolean` | | | | aria-controls | `string` | | | | aria-current | `boolean \| 'page' \| 'step' \| 'location' \| 'date' \| 'time'` | | | | aria-keyshortcuts | `string` | | Keyboard shortcuts that activate the button, eg. `Meta+K Control+K` | | onClick | `(event: MouseEvent<HTMLButtonElement>) => void` | | | | ref | `Ref<HTMLButtonElement>` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ## Accessibility Built on the React Aria button: it responds to Enter and Space, shows a focus ring for keyboard users only and handles presses the same way for mouse, touch and keyboard. With `asChild` the styling moves onto its child, such as a link, which then takes care of its own semantics. ### Calendar (https://v2.alineacms.com/docs/components/calendar) A month grid to pick a date or a range of dates. Example: Calendar ```tsx import {Calendar} from 'alinea/components' export function CalendarExample() { return <Calendar aria-label="Delivery date" defaultValue="2026-09-23" /> } ``` ## Props `Calendar` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value | `string \| null` | | The selected date, `YYYY-MM-DD` | | defaultValue | `string \| null` | | | | onValueChange | `(value: string) => void` | | | | locale | `string; /** The earliest selectable date, `YYYY-MM-DD` */ min?: string; /** The latest selectable date, `YYYY-MM-DD` */ max?: string; disabled?: boolean; readOnly?: boolean; autoFocus?: boolean; /** Returns true for dates that cannot be selected, receives `YYYY-MM-DD` */ disabledDates?: (date: string) => boolean;` | | BCP 47 locale used to format dates, eg. `en-GB`, defaults to the user's locale | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `RangeCalendar` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value | `DateRange \| null` | | | | defaultValue | `DateRange \| null` | | | | onValueChange | `(value: DateRange) => void` | | | | locale | `string; /** The earliest selectable date, `YYYY-MM-DD` */ min?: string; /** The latest selectable date, `YYYY-MM-DD` */ max?: string; disabled?: boolean; readOnly?: boolean; autoFocus?: boolean; /** Returns true for dates that cannot be selected, receives `YYYY-MM-DD` */ disabledDates?: (date: string) => boolean;` | | BCP 47 locale used to format dates, eg. `en-GB`, defaults to the user's locale | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Checkbox (https://v2.alineacms.com/docs/components/checkbox) A single checkbox, its children are the label. To pick several values from a list, use a CheckboxGroup. Example: Checkbox ```tsx import {Checkbox} from 'alinea/components' export function CheckboxExample() { return <Checkbox defaultChecked>Show in navigation</Checkbox> } ``` ## States Set `checked` to `"indeterminate"` when some, but not all, of the items it controls are checked. A description and an error show below the label. Example: Checkbox: states ```tsx import {Checkbox} from 'alinea/components' export function CheckboxStatesExample() { return ( <div style={{display: 'grid', gap: 16, width: 280}}> <Checkbox>Featured product</Checkbox> <Checkbox defaultChecked>In stock</Checkbox> <Checkbox checked="indeterminate">All variants</Checkbox> <Checkbox disabled>Gift wrapping</Checkbox> <Checkbox description="Hide this page from search engines"> No index </Checkbox> <Checkbox required error="Accept the terms to publish"> I accept the terms </Checkbox> </div> ) } ``` ## Props `Checkbox` | Prop | Type | Default | Description | | --- | --- | --- | --- | | checked | `boolean \| 'indeterminate'` | | | | defaultChecked | `boolean` | | | | onCheckedChange | `(checked: boolean) => void` | | | | disabled | `boolean` | | | | required | `boolean` | | | | readOnly | `boolean` | | | | name | `string` | | | | value | `string` | | Submitted value, and the value inside a `CheckboxGroup` | | autoFocus | `boolean` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | ref | `Ref<HTMLLabelElement>` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ## Accessibility Clicking the label toggles the checkbox, Space toggles it from the keyboard. The indeterminate state is announced as mixed. ### CheckboxGroup (https://v2.alineacms.com/docs/components/checkbox-group) A labelled group of checkboxes for several values. Example: CheckboxGroup ```tsx import {Checkbox, CheckboxGroup} from 'alinea/components' export function CheckboxGroupExample() { return ( <CheckboxGroup label="Locales" defaultValue={['en', 'nl']}> <Checkbox value="en">English</Checkbox> <Checkbox value="nl">Nederlands</Checkbox> <Checkbox value="fr">Français</Checkbox> </CheckboxGroup> ) } ``` ## Props `CheckboxGroup` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value | `Array<string>` | | | | defaultValue | `Array<string>` | | | | onValueChange | `(value: Array<string>) => void` | | | | orientation | `Orientation` | `'vertical'` | | | name | `string` | | | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Code (https://v2.alineacms.com/docs/components/code) Inline code, soft, outlined or plain. Example: Code ```tsx import {Code, Text} from 'alinea/components' export function CodeExample() { return ( <div style={{display: 'grid', gap: 12, width: 420}}> <Text as="p"> The product lives at <Code>/products/linen-shirt</Code> in the{' '} <Code variant="outline">Products</Code> root. </Text> <Code block> {`const Product = Config.document('Product', { fields: { title: Field.text('Title'), price: Field.number('Price') } })`} </Code> </div> ) } ``` ## Props `Code` | Prop | Type | Default | Description | | --- | --- | --- | --- | | variant | `'soft' \| 'outline' \| 'ghost'` | `'soft'` | | | size | `'sm' \| 'default'` | `'default'` | | | block | `boolean` | | Render a multi-line block (`pre`) instead of inline code | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Collapsible (https://v2.alineacms.com/docs/components/collapsible) A section that opens and closes. Example: Collapsible ```tsx import { Collapsible, CollapsibleContent, CollapsibleTrigger, Text } from 'alinea/components' export function CollapsibleExample() { return ( <Collapsible defaultOpen style={{width: 320}}> <CollapsibleTrigger>Care instructions</CollapsibleTrigger> <CollapsibleContent> <Text as="p">Machine wash at 40°C.</Text> <Text as="p">Tumble dry low, iron while damp.</Text> </CollapsibleContent> </Collapsible> ) } ``` ## Props `Collapsible` A panel that expands and collapses its content | Prop | Type | Default | Description | | --- | --- | --- | --- | | disabled | `boolean` | `false` | | | id | `string` | | | | open | `boolean` | | | | defaultOpen | `boolean` | `false` | | | onOpenChange | `(open: boolean) => void` | | | Also accepts `className` and `style`, `data-*` attributes. `CollapsibleTrigger` The button that toggles the collapsible. By default it renders a fold icon followed by the children as title. | Prop | Type | Default | Description | | --- | --- | --- | --- | | asChild | `boolean` | | Merge the trigger behavior onto the single child element | | onClick | `(event: MouseEvent<HTMLButtonElement>) => void` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `CollapsibleContent` The content shown while the collapsible is open | Prop | Type | Default | Description | | --- | --- | --- | --- | | children | `ReactNode` | | | Also accepts `className` and `style`, `data-*` attributes. ### ColorSwatchPicker (https://v2.alineacms.com/docs/components/color-swatch-picker) Picks a color from a set of swatches. Example: ColorSwatchPicker ```tsx import {ColorSwatchPicker, ColorSwatchPickerItem} from 'alinea/components' const colors = ['#e8e0d2', '#c9a27e', '#8a5a3b', '#5b6b4f', '#3e4a5c'] export function ColorSwatchPickerExample() { return ( <ColorSwatchPicker aria-label="Fabric color" defaultValue="#8a5a3b"> {colors.map(color => ( <ColorSwatchPickerItem key={color} color={color} /> ))} </ColorSwatchPicker> ) } ``` ## Props `ColorSwatchPicker` A list of color swatches to pick one color from | Prop | Type | Default | Description | | --- | --- | --- | --- | | value | `string` | | The selected color | | defaultValue | `string` | | | | onValueChange | `(color: string) => void` | | | | layout | `'grid' \| 'stack'` | `'grid'` | Lay the swatches out in a wrapping row, or a column | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `ColorSwatchPickerItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | color (required) | `string` | | | | disabled | `boolean` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `ColorSwatch` Shows a color on a checkerboard so transparency is visible | Prop | Type | Default | Description | | --- | --- | --- | --- | | color (required) | `string` | | Any CSS color, eg. `#f80` or `rgb(255 128 0 / 50%)` | | colorName | `string` | | Accessible name of the color, derived from the color if left out | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### ComboBox (https://v2.alineacms.com/docs/components/combo-box) A text input that filters a list of options. Example: ComboBox ```tsx import {ComboBox, ComboBoxItem} from 'alinea/components' export function ComboBoxExample() { return ( <ComboBox label="Related product" defaultValue="chair" style={{width: 280}}> <ComboBoxItem value="shirt">Linen shirt</ComboBoxItem> <ComboBoxItem value="chair">Oak dining chair</ComboBoxItem> <ComboBoxItem value="throw">Wool throw</ComboBoxItem> <ComboBoxItem value="table">Walnut side table</ComboBoxItem> </ComboBox> ) } ``` ## Props `ComboBox` A labelled text input that filters a list of options to pick from | Prop | Type | Default | Description | | --- | --- | --- | --- | | value | `string \| null` | | | | defaultValue | `string \| null` | | | | onValueChange | `(value: string \| null) => void` | | Called with the selected value, or null when the selection is cleared | | inputValue | `string` | | | | defaultInputValue | `string` | | | | onInputValueChange | `(value: string) => void` | | | | placeholder | `string` | | | | allowsCustomValue | `boolean` | | Keep text that does not match an item instead of reverting it on blur | | emptyMessage | `ReactNode` | | Shown in the list when no item matches, the list stays closed if unset | | name | `string` | | Name of the hidden form input carrying the value | | autoFocus | `boolean` | | | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | | open | `boolean` | | | | defaultOpen | `boolean` | | | | onOpenChange | `(open: boolean) => void` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `ComboBoxItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value (required) | `string` | | | | textValue | `string` | | Text used for filtering and the input, defaults to string children | | disabled | `boolean` | | | Also accepts `className` and `style`. ### Command (https://v2.alineacms.com/docs/components/command) A searchable list of commands, like a command palette. Example: Command ```tsx import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Surface } from 'alinea/components' import { IcRoundFormatQuote, IcRoundImage, IcRoundNotes, IcRoundPanorama } from 'alinea/dashboard/icons' export function CommandExample() { return ( <Surface style={{width: 280}}> <Command> <CommandInput aria-label="Search blocks" placeholder="Add a block…" /> <CommandList aria-label="Blocks"> <CommandEmpty>No matching blocks</CommandEmpty> <CommandGroup heading="Blocks"> <CommandItem value="text" icon={IcRoundNotes}> Text </CommandItem> <CommandItem value="image" icon={IcRoundImage} keywords={['photo']}> Image </CommandItem> <CommandItem value="hero" icon={IcRoundPanorama}> Hero </CommandItem> <CommandItem value="quote" icon={IcRoundFormatQuote}> Quote </CommandItem> </CommandGroup> </CommandList> </Command> </Surface> ) } ``` ## Props `Command` A search input that filters a list of actions, as used in command palettes and pickers. Compose with CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem and CommandSeparator. | Prop | Type | Default | Description | | --- | --- | --- | --- | | shouldFilter | `boolean` | `true` | Whether items are filtered by the search query. Set to false when filtering the items yourself. Defaults to true. | | filter | `(value: string, search: string, keywords: Array<string>) => number \| boolean` | | Custom matcher for an item: return 0 or false to hide it. `keywords` holds the text of the item followed by its `keywords`. By default items are shown when their text or one of their keywords contains the query, ignoring case and accents. | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `CommandInput` | Prop | Type | Default | Description | | --- | --- | --- | --- | | placeholder | `string` | | | | autoFocus | `boolean` | | | | icon | `IconType \| ReactElement` | | Defaults to a search icon | | onValueChange | `(value: string) => void` | | Called when the search query changes | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `CommandList` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | CommandItem, CommandGroup, CommandSeparator and CommandEmpty elements | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `CommandEmpty` Place directly inside CommandList | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | Shown when no item matches the search query | Also accepts `className` and `style`, `data-*` attributes. `CommandGroup` | Prop | Type | Default | Description | | --- | --- | --- | --- | | heading | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `CommandItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value (required) | `string` | | Identifies the item, passed to `onSelect` and the `filter` | | keywords | `Array<string>` | | Additional words the item is found by | | textValue | `string` | | Text the item is found by, defaults to its string children | | onSelect | `(value: string) => void` | | Called when the item is clicked or chosen with Enter | | icon | `IconType \| ReactElement` | | | | disabled | `boolean` | | | Also accepts `className` and `style`, `data-*` attributes. `CommandSeparator` Accepts `className` and `style`, `data-*` attributes. ### ContentCard (https://v2.alineacms.com/docs/components/content-card) A card with a preview, title and details. Example: ContentCard ```tsx import {ContentCard, ContentGrid, ContentGridItem} from 'alinea/components' import {IcTwotoneDescription} from 'alinea/dashboard/icons' const entries = [{id: 'post'}, {id: 'image'}] export function ContentCardExample() { return ( <div style={{width: 460, height: 240}}> <ContentGrid aria-label="Entries" items={entries} minItemWidth={180}> {entry => ( <ContentGridItem id={entry.id} textValue={entry.id}> {entry.id === 'post' ? ( <ContentCard icon={IcTwotoneDescription} title="Caring for linen" breadcrumbs={['Journal']} description="Blog post" /> ) : ( <ContentCard variant="media" image="/catalog/workshop-bench.jpg" color="#8a6f55" title="workshop-bench.jpg" breadcrumbs={['Media', 'Workshop']} description="JPG" details="480×320 · 38 kB" /> )} </ContentGridItem> )} </ContentGrid> </div> ) } ``` ## Props `ContentCard` The contents of a ContentGridItem, mirroring the dashboard explorer cards | Prop | Type | Default | Description | | --- | --- | --- | --- | | title (required) | `ReactNode` | | | | variant | `'icon' \| 'media'` | `'icon'` | `icon` (the default) shows the icon on a neutral background, `media` previews a file: its image, or a file icon on the `color` placeholder | | icon | `IconType` | | The large icon on top of the card | | image | `string` | | Preview image of a media card | | color | `string` | | Placeholder color behind the image, eg. its average color | | breadcrumbs | `ReadonlyArray<ReactNode>` | | The location of the item, rendered above the title | | description | `ReactNode` | | A line below the title, eg. the type or file extension | | details | `ReactNode` | | Rendered at the end of the description, eg. dimensions and file size | Also accepts `className` and `style`. `ContentCardSkeleton` A pulsing placeholder card for items that are still loading Accepts `className` and `style`. ### ContentGrid (https://v2.alineacms.com/docs/components/content-grid) A selectable grid of cards, like the media library. Example: ContentGrid ```tsx import {ContentCard, ContentGrid, ContentGridItem} from 'alinea/components' const images = [ {id: 'oak-dining-chair'}, {id: 'walnut-stools'}, {id: 'ceramic-table-lamp'} ] export function ContentGridExample() { return ( <div style={{width: 640, height: 240}}> <ContentGrid aria-label="Product images" items={images} selectionMode="multiple" showSelectionControls defaultSelectedKeys={new Set(['walnut-stools'])} minItemWidth={180} > {image => ( <ContentGridItem id={image.id} textValue={image.id}> <ContentCard variant="media" image={`/catalog/${image.id}.jpg`} title={`${image.id}.jpg`} breadcrumbs={['Media', 'Products']} description="JPG" /> </ContentGridItem> )} </ContentGrid> </div> ) } ``` ## Props `ContentGrid` | Prop | Type | Default | Description | | --- | --- | --- | --- | | items (required) | `Iterable<T>` | | | | selectionBehavior | `'toggle' \| 'replace'` | `'toggle'` | How pointer clicks select cards. With `toggle` (the default) a click runs the card action, or toggles its selection while cards are selected. With `replace` a click selects only that card and a double click or Enter runs the card action. | | showSelectionControls | `boolean` | | Show a selection checkbox per card, defaults to multiple selection | | minItemWidth | `number` | `240` | Minimum card width in pixels, defaults to 240 | | maxItemWidth | `number` | `320` | Maximum card width in pixels, defaults to 320 | | itemHeight | `number` | `196` | Card height in pixels, defaults to 196 | | gap | `number` | `16` | Space between cards in pixels, defaults to 16 | | maxColumns | `number` | `5` | Defaults to 5 | | onItemAction | `(key: Key) => void` | | Called when a card is activated (see `selectionBehavior`), cards can override it with their own `onAction` | | renderEmptyState | `() => ReactNode` | | | | dropLabel | `string` | | Shown over the grid while files are dragged onto it | | dependencies | `ReadonlyArray<unknown>` | | Cards are cached per item, list the values `children` reads besides the item to render them again when those change | | children (required) | `(item: T) => ReactElement` | | | `ContentGridItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | id (required) | `Key` | | | | textValue (required) | `string` | | Text used for typeahead and as the accessible card name | | selectable | `boolean` | `true` | Set to false to disable selecting the card, its action still runs | | onAction | `() => void` | | Called when the card is activated, overrides `onItemAction` | | onClick | `() => void` | | Called on every click or tap of the card, next to its selection. Use it instead of `onAction` to act on a single click with the `replace` selection behavior, where `onAction` needs a double click. | | onDoubleClick | `() => void` | | | Also accepts `data-*` attributes. ### DataList (https://v2.alineacms.com/docs/components/data-list) Label and value pairs, such as the details of a file. Example: DataList ```tsx import { Badge, DataList, DataListItem, DataListLabel, DataListValue } from 'alinea/components' export function DataListExample() { return ( <DataList aria-label="Entry details" style={{width: 300}}> <DataListItem> <DataListLabel>Status</DataListLabel> <DataListValue> <Badge size="sm" status="published"> Published </Badge> </DataListValue> </DataListItem> <DataListItem> <DataListLabel>Created by</DataListLabel> <DataListValue>Maya Janssens</DataListValue> </DataListItem> <DataListItem> <DataListLabel>Path</DataListLabel> <DataListValue>/products/linen-shirt</DataListValue> </DataListItem> </DataList> ) } ``` ## Props `DataList` A description list of DataListItems with a label and a value | Prop | Type | Default | Description | | --- | --- | --- | --- | | orientation | `Orientation` | `'horizontal'` | `horizontal` lines labels up in a column next to their values, `vertical` stacks each label above its value and flows the items into as many columns as fit | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `DataListItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | full | `boolean` | | Spans every column of a vertical list | Also accepts `className` and `style`, `data-*` attributes. `DataListLabel` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `data-*` attributes. `DataListValue` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `data-*` attributes. ### DateField (https://v2.alineacms.com/docs/components/date-field) Types a date segment by segment. Example: DateField ```tsx import {DateField} from 'alinea/components' export function DateFieldExample() { return ( <DateField label="Publish date" defaultValue="2026-09-23" style={{width: 220}} /> ) } ``` ## Props `DateField` | Prop | Type | Default | Description | | --- | --- | --- | --- | | locale | `string; /** The date, `YYYY-MM-DD` */ value?: string \| null; defaultValue?: string \| null; onValueChange?: (value: string \| null) => void; /** The earliest valid date, `YYYY-MM-DD` */ min?: string; /** The latest valid date, `YYYY-MM-DD` */ max?: string; name?: string; autoFocus?: boolean;` | | BCP 47 locale used to format dates, eg. `en-GB`, defaults to the user's locale | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### DatePicker (https://v2.alineacms.com/docs/components/date-picker) A date field with a calendar popover. Example: DatePicker ```tsx import {DatePicker} from 'alinea/components' export function DatePickerExample() { return ( <DatePicker label="Launch date" defaultValue="2026-10-01" min="2026-09-24" style={{width: 240}} /> ) } ``` ## Props `DatePicker` | Prop | Type | Default | Description | | --- | --- | --- | --- | | locale | `string; /** The selected date, `YYYY-MM-DD` */ value?: string \| null; defaultValue?: string \| null; onValueChange?: (value: string \| null) => void; /** The earliest selectable date, `YYYY-MM-DD` */ min?: string; /** The latest selectable date, `YYYY-MM-DD` */ max?: string; /** Returns true for dates that cannot be selected, receives `YYYY-MM-DD` */ disabledDates?: (date: string) => boolean; name?: string; autoFocus?: boolean;` | | BCP 47 locale used to format dates, eg. `en-GB`, defaults to the user's locale | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### DateRangePicker (https://v2.alineacms.com/docs/components/date-range-picker) Picks a start and end date from a calendar. Example: DateRangePicker ```tsx import {DateRangePicker} from 'alinea/components' export function DateRangePickerExample() { return ( <DateRangePicker label="Sale period" defaultValue={{start: '2026-11-27', end: '2026-11-30'}} style={{width: 300}} /> ) } ``` ## Props `DateRangePicker` | Prop | Type | Default | Description | | --- | --- | --- | --- | | locale | `string; value?: DateRange \| null; defaultValue?: DateRange \| null; onValueChange?: (value: DateRange \| null) => void; /** The earliest selectable date, `YYYY-MM-DD` */ min?: string; /** The latest selectable date, `YYYY-MM-DD` */ max?: string; /** Returns true for dates that cannot be selected, receives `YYYY-MM-DD` */ disabledDates?: (date: string) => boolean; /** Form field name of the start date */ startName?: string; /** Form field name of the end date */ endName?: string; autoFocus?: boolean;` | | BCP 47 locale used to format dates, eg. `en-GB`, defaults to the user's locale | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Dialog (https://v2.alineacms.com/docs/components/dialog) A modal window on top of the page that asks for a decision or collects input. DialogTrigger opens it and DialogClose closes it. Example: Dialog ```tsx import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, TextField } from 'alinea/components' export function DialogExample() { return ( <Dialog> <DialogTrigger variant="outline">Rename entry</DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Rename entry</DialogTitle> <DialogDescription> The URL of the entry stays the same. </DialogDescription> </DialogHeader> <TextField label="Title" defaultValue="Summer collection" autoFocus /> <DialogFooter> <DialogClose variant="ghost">Cancel</DialogClose> <DialogClose color="primary">Save</DialogClose> </DialogFooter> </DialogContent> </Dialog> ) } ``` ## Confirm a destructive action Use `role="alertdialog"` for a decision editors have to make. With `dismissable={false}` a click outside the dialog doesn't close it. Example: Dialog: alert ```tsx import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from 'alinea/components' import {IcRoundDelete} from 'alinea/dashboard/icons' export function DialogAlertExample() { return ( <Dialog> <DialogTrigger color="destructive" variant="outline" icon={IcRoundDelete}> Delete </DialogTrigger> <DialogContent role="alertdialog" dismissable={false} showCloseButton={false} > <DialogHeader> <DialogTitle>Delete “Linen shirt”?</DialogTitle> <DialogDescription> The entry and its translations are removed. This can’t be undone. </DialogDescription> </DialogHeader> <DialogFooter> <DialogClose variant="ghost">Keep it</DialogClose> <DialogClose color="destructive">Delete entry</DialogClose> </DialogFooter> </DialogContent> </Dialog> ) } ``` ## Controlled Pass `open` and `onOpenChange` to open a dialog from anywhere, and close it once a change is saved. `useDialog` gives the content of a dialog its open state and a `close` function. Example: Dialog: form ```tsx import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, TextField } from 'alinea/components' import {useState} from 'react' export function DialogFormExample() { const [open, setOpen] = useState(false) const [name, setName] = useState('Oak & Loom') const [draft, setDraft] = useState(name) return ( <> <Button onClick={() => setOpen(true)}>Site name: {name}</Button> <Dialog open={open} onOpenChange={setOpen}> <DialogContent> <DialogHeader> <DialogTitle>Site settings</DialogTitle> </DialogHeader> <TextField label="Site name" value={draft} onValueChange={setDraft} /> <DialogFooter> <Button variant="ghost" onClick={() => setOpen(false)}> Cancel </Button> <Button color="primary" onClick={() => { setName(draft) setOpen(false) }} > Save </Button> </DialogFooter> </DialogContent> </Dialog> </> ) } ``` ## Props `Dialog` | Prop | Type | Default | Description | | --- | --- | --- | --- | | open | `boolean` | | | | defaultOpen | `boolean` | | | | onOpenChange | `(open: boolean) => void` | | | `DialogTrigger` Accepts every prop of `Button`. `DialogContent` | Prop | Type | Default | Description | | --- | --- | --- | --- | | role | `'dialog' \| 'alertdialog'` | | | | size | `'default' \| 'lg' \| 'full'` | `'default'` | `default` fits its content up to a narrow width, `lg` is a fixed wide dialog and `full` fills the viewport (minus a margin), defaults to `default` | | dismissable | `boolean` | `true` | Close the dialog when clicking outside of it, defaults to true | | showCloseButton | `boolean` | `true` | Defaults to true | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `DialogHeader` Accepts every prop of a `<div>` element. `DialogTitle` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`. `DialogDescription` Accepts every prop of a `<p>` element. `DialogFooter` Accepts every prop of a `<div>` element. `DialogClose` Closes the surrounding Dialog or Popover when pressed Accepts every prop of `Button`. `useDialog` The open state of the surrounding Dialog or Popover, to close it from within its content ## Accessibility Focus moves into the dialog when it opens and returns to the trigger when it closes. Focus stays inside while it is open, Escape closes it and the page behind it is hidden from screen readers. ### DropdownMenu (https://v2.alineacms.com/docs/components/dropdown-menu) A menu of actions that opens from a button, like the actions of an entry in the dashboard. Example: DropdownMenu ```tsx import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger } from 'alinea/components' import { IcBaselineContentCopy, IcRoundArchive, IcRoundDelete, IcRoundEdit, IcRoundMoreHoriz } from 'alinea/dashboard/icons' export function DropdownMenuExample() { return ( <DropdownMenu> <DropdownMenuTrigger variant="outline" size="icon" icon={IcRoundMoreHoriz} aria-label="Entry actions" /> <DropdownMenuContent align="start" aria-label="Entry actions"> <DropdownMenuItem icon={IcRoundEdit} onSelect={() => {}}> Rename <DropdownMenuShortcut>⌘R</DropdownMenuShortcut> </DropdownMenuItem> <DropdownMenuItem icon={IcBaselineContentCopy}> Duplicate </DropdownMenuItem> <DropdownMenuItem icon={IcRoundArchive} disabled> Archive </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem icon={IcRoundDelete} variant="destructive"> Delete </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ) } ``` ## Checkbox and radio items A menu can hold options as well: a checkbox item toggles one, a radio group picks one of several. Unlike actions they keep the menu open when selected. Example: DropdownMenu: selection ```tsx import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from 'alinea/components' import {IcRoundFilterList} from 'alinea/dashboard/icons' import {useState} from 'react' export function DropdownMenuSelectionExample() { const [drafts, setDrafts] = useState(true) const [archived, setArchived] = useState(false) const [sort, setSort] = useState('updated') return ( <DropdownMenu> <DropdownMenuTrigger variant="outline" icon={IcRoundFilterList}> View </DropdownMenuTrigger> <DropdownMenuContent aria-label="View options"> <DropdownMenuGroup aria-label="Show"> <DropdownMenuLabel>Show</DropdownMenuLabel> <DropdownMenuCheckboxItem checked={drafts} onCheckedChange={setDrafts} > Drafts </DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem checked={archived} onCheckedChange={setArchived} > Archived entries </DropdownMenuCheckboxItem> </DropdownMenuGroup> <DropdownMenuSeparator /> <DropdownMenuLabel>Sort by</DropdownMenuLabel> <DropdownMenuRadioGroup value={sort} onValueChange={setSort} aria-label="Sort by" > <DropdownMenuRadioItem value="updated"> Last updated </DropdownMenuRadioItem> <DropdownMenuRadioItem value="title">Title</DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> ) } ``` ## Props `DropdownMenu` | Prop | Type | Default | Description | | --- | --- | --- | --- | | open | `boolean` | | | | defaultOpen | `boolean` | | | | onOpenChange | `(open: boolean) => void` | | | `DropdownMenuTrigger` Accepts every prop of `Button`. `DropdownMenuContent` | Prop | Type | Default | Description | | --- | --- | --- | --- | | side | `Side` | | | | align | `Align` | | | | sideOffset | `number` | | | | alignOffset | `number` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `DropdownMenuItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | variant | `'default' \| 'destructive'` | `'default'` | | | href | `string` | | | | target | `string` | | | | closeOnSelect | `boolean` | | Defaults to true | | onSelect | `() => void` | | | | icon | `IconType \| ReactElement` | | | | disabled | `boolean` | | | | inset | `boolean` | | Adds leading space so the item lines up with items that have an icon | | textValue | `string` | | Text used for typeahead, defaults to the children if they are a string | Also accepts `className` and `style`, `data-*` attributes. `DropdownMenuCheckboxItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | checked (required) | `boolean` | | | | onCheckedChange (required) | `(checked: boolean) => void` | | | | closeOnSelect | `boolean` | `false` | Defaults to false | | icon | `IconType \| ReactElement` | | | | disabled | `boolean` | | | | inset | `boolean` | | Adds leading space so the item lines up with items that have an icon | | textValue | `string` | | Text used for typeahead, defaults to the children if they are a string | Also accepts `className` and `style`, `data-*` attributes. `DropdownMenuRadioGroup` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value (required) | `string \| null` | | | | onValueChange (required) | `(value: string) => void` | | | | closeOnSelect | `boolean` | | Defaults to true | Also accepts `id` and `aria-*` labelling attributes. `DropdownMenuRadioItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value (required) | `string` | | | | icon | `IconType \| ReactElement` | | | | disabled | `boolean` | | | | inset | `boolean` | | Adds leading space so the item lines up with items that have an icon | | textValue | `string` | | Text used for typeahead, defaults to the children if they are a string | Also accepts `className` and `style`, `data-*` attributes. `DropdownMenuGroup` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `id` and `aria-*` labelling attributes. `DropdownMenuLabel` | Prop | Type | Default | Description | | --- | --- | --- | --- | | inset | `boolean` | | | Also accepts `className` and `style`. `DropdownMenuSeparator` `DropdownMenuShortcut` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`. `DropdownMenuSub` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `[ReactElement, ReactElement]` | | A DropdownMenuSubTrigger followed by a DropdownMenuSubContent | `DropdownMenuSubTrigger` | Prop | Type | Default | Description | | --- | --- | --- | --- | | icon | `IconType \| ReactElement` | | | | disabled | `boolean` | | | | inset | `boolean` | | Adds leading space so the item lines up with items that have an icon | | textValue | `string` | | Text used for typeahead, defaults to the children if they are a string | Also accepts `className` and `style`, `data-*` attributes. `DropdownMenuSubContent` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ## Accessibility The menu opens with Enter, Space or the arrow keys, the arrow keys move through its items and typing jumps to a matching item. Escape closes it and returns focus to the trigger. ### DropZone (https://v2.alineacms.com/docs/components/drop-zone) A target to drop files or other content on. Example: DropZone ```tsx import {DropZone, DropZoneDescription, DropZoneTrigger} from 'alinea/components' import {IcRoundUploadFile} from 'alinea/dashboard/icons' export function DropZoneExample() { return ( <DropZone aria-label="Upload product photos" accept={['image/*']} onDropFiles={files => console.log(files)} style={{width: 320}} > <DropZoneTrigger icon={IcRoundUploadFile}>Browse photos</DropZoneTrigger> <DropZoneDescription>Or drag and drop images here.</DropZoneDescription> </DropZone> ) } ``` ## Props `DropZone` An area files can be dropped on, add a DropZoneTrigger to browse files | Prop | Type | Default | Description | | --- | --- | --- | --- | | onDropFiles (required) | `(files: Array<File>) => void` | | Called with the dropped or picked files that match `accept` | | accept | `Array<string>` | | Accepted mime types (`image/png`, `image/*`) or file extensions (`.pdf`), every file is accepted if left out | | multiple | `boolean` | `true` | Accept more than one file at a time, defaults to true | | disabled | `boolean` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `DropZoneTrigger` A button that opens the file browser of the surrounding DropZone Accepts every prop of `Button`. `DropZoneDescription` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children | `ReactNode` | | | Also accepts `className` and `style`. ### Empty (https://v2.alineacms.com/docs/components/empty) The placeholder for a view without content. Example: Empty ```tsx import { Button, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, Icon } from 'alinea/components' import {IcRoundAdd, LucideFile} from 'alinea/dashboard/icons' export function EmptyExample() { return ( <Empty variant="card" aria-label="No blog posts"> <EmptyHeader> <EmptyMedia variant="icon"> <Icon icon={LucideFile} /> </EmptyMedia> <EmptyTitle>No blog posts yet</EmptyTitle> <EmptyDescription> Posts you write in the journal show up here. </EmptyDescription> </EmptyHeader> <EmptyContent> <Button color="primary" icon={IcRoundAdd}> New post </Button> </EmptyContent> </Empty> ) } ``` ## Props `Empty` A placeholder for a view without content: an EmptyHeader with media, title and description, followed by optional EmptyContent with actions. | Prop | Type | Default | Description | | --- | --- | --- | --- | | variant | `'default' \| 'card'` | `'default'` | `card` renders the empty state on a raised surface | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `EmptyHeader` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `data-*` attributes. `EmptyMedia` An icon or illustration above the title | Prop | Type | Default | Description | | --- | --- | --- | --- | | variant | `'default' \| 'icon'` | `'default'` | `icon` places the icon on a muted tile | Also accepts `className` and `style`, `data-*` attributes. `EmptyTitle` | Prop | Type | Default | Description | | --- | --- | --- | --- | | id | `string` | | | | as | `'div' \| 'h1' \| 'h2' \| 'h3' \| 'h4' \| 'h5' \| 'h6'` | `'div'` | The element to render, use a heading when the empty state is the page | Also accepts `className` and `style`, `data-*` attributes. `EmptyDescription` | Prop | Type | Default | Description | | --- | --- | --- | --- | | id | `string` | | | Also accepts `className` and `style`, `data-*` attributes. `EmptyContent` Actions or further content below the header | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `data-*` attributes. ### Field (https://v2.alineacms.com/docs/components/field) The label, help text and error around your own input. Example: Field ```tsx import {Field} from 'alinea/components' export function FieldExample() { return ( <Field label="Image focus" description="Horizontal focal point of the hero image" htmlFor="focus" style={{width: 280}} > <input id="focus" type="range" min={0} max={100} defaultValue={40} /> </Field> ) } ``` ## Props `Field` Renders the label, description and error around a form control. All inputs in this library render a Field, use it directly to give a custom control the same chrome. | Prop | Type | Default | Description | | --- | --- | --- | --- | | htmlFor | `string` | | Id of the control the label describes, not needed inside our inputs | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | Also accepts `className` and `style`. `FieldLabel` | Prop | Type | Default | Description | | --- | --- | --- | --- | | htmlFor | `string` | | | | required | `boolean` | | | Also accepts `className` and `style`. `FieldDescription` Inside a react-aria field its id is added to the control's aria-describedby | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`. `FieldError` Inside a react-aria field its id is added to the control's aria-describedby | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`. `FieldSharedBadge` Marks a field as shared between translations | Prop | Type | Default | Description | | --- | --- | --- | --- | | children | `ReactNode` | `'Shared'` | | Also accepts `className` and `style`. ### FileTrigger (https://v2.alineacms.com/docs/components/file-trigger) Opens the file browser from any button. Example: FileTrigger ```tsx import {Button, FileTrigger} from 'alinea/components' import {IcRoundUploadFile} from 'alinea/dashboard/icons' export function FileTriggerExample() { return ( <FileTrigger accept={['.pdf']} onSelect={([file]) => console.log(file.name)} > <Button variant="outline" icon={IcRoundUploadFile}> Upload size guide </Button> </FileTrigger> ) } ``` ## Props `FileTrigger` Opens the file browser when its child is clicked | Prop | Type | Default | Description | | --- | --- | --- | --- | | onSelect (required) | `(files: Array<File>) => void` | | Called with the picked files that match `accept`, never with an empty list | | accept | `Array<string>` | | Accepted mime types (`image/png`, `image/*`) or file extensions (`.pdf`), every file is accepted if left out | | multiple | `boolean` | | Allow picking more than one file | | directory | `boolean` | | Pick a directory, every file in it is selected | ### FoldIcon (https://v2.alineacms.com/docs/components/fold-icon) A chevron that turns when its section opens. Example: FoldIcon ```tsx import {Button, FoldIcon} from 'alinea/components' import {useState} from 'react' export function FoldIconExample() { const [expanded, setExpanded] = useState(false) return ( <> <FoldIcon aria-label="Collapsed" /> <FoldIcon aria-label="Expanded" expanded /> <Button variant="ghost" aria-expanded={expanded} onClick={() => setExpanded(!expanded)} > <FoldIcon aria-hidden expanded={expanded} /> Care instructions </Button> </> ) } ``` ## Props `FoldIcon` | Prop | Type | Default | Description | | --- | --- | --- | --- | | expanded | `boolean` | | | ### Heading (https://v2.alineacms.com/docs/components/heading) A title in one of five sizes. Example: Heading ```tsx import {Heading} from 'alinea/components' export function HeadingExample() { return ( <div style={{display: 'grid', gap: 8, width: 360}}> <Heading as="h1">Oak dining chair</Heading> <Heading as="h2">Materials</Heading> <Heading as="h3">Care instructions</Heading> <Heading as="h2" size="sm" weight="medium" truncate> Visual size is independent of the element </Heading> </div> ) } ``` ## Props `Heading` | Prop | Type | Default | Description | | --- | --- | --- | --- | | as | `'h1' \| 'h2' \| 'h3' \| 'h4' \| 'h5' \| 'h6'` | `'h1'` | The heading element, defaults to h1 | | size | `'xs' \| 'sm' \| 'default' \| 'lg' \| 'xl'` | | Visual size, defaults to one matching the element | | weight | `'medium' \| 'semibold' \| 'bold'` | | | | truncate | `boolean` | | | | asChild | `boolean` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Icon (https://v2.alineacms.com/docs/components/icon) Renders an icon component at the text size. Example: Icon ```tsx import {Icon} from 'alinea/components' import { IcRoundCheck, IcRoundDelete, IcRoundImage, IcRoundSearch } from 'alinea/dashboard/icons' export function IconExample() { return ( <> <Icon icon={IcRoundSearch} style={{fontSize: 16}} /> <Icon icon={IcRoundImage} style={{fontSize: 24}} /> <Icon icon={IcRoundCheck} aria-label="Published" style={{fontSize: 24, color: 'var(--alinea-success)'}} /> <Icon icon={IcRoundDelete} aria-label="Delete" style={{fontSize: 24, color: 'var(--alinea-danger)'}} /> </> ) } ``` ## Props `Icon` | Prop | Type | Default | Description | | --- | --- | --- | --- | | icon (required) | `ComponentType \| ReactElement` | | | | aria-label | `string` | | | | aria-hidden | `boolean \| 'false' \| 'true'` | | | Also accepts every prop of a `<svgsvgelement>` element. ### Kbd (https://v2.alineacms.com/docs/components/kbd) A keyboard key or shortcut. Example: Kbd ```tsx import {Kbd, Text} from 'alinea/components' export function KbdExample() { return ( <> <Kbd>⌘ K</Kbd> <Kbd size="sm">Esc</Kbd> <Text> Save a draft with <Kbd>⌘</Kbd> <Kbd>S</Kbd> </Text> </> ) } ``` ## Props `Kbd` A keyboard key or shortcut, eg. <Kbd>⌘ K</Kbd> | Prop | Type | Default | Description | | --- | --- | --- | --- | | size | `'sm' \| 'default'` | `'default'` | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Link (https://v2.alineacms.com/docs/components/link) An inline link, plain or underlined. Example: Link ```tsx import {Link, Text} from 'alinea/components' export function LinkExample() { return ( <Text> Read the <Link href="#guide">style guide</Link> or{' '} <Link href="#support" variant="underline"> contact support </Link> . </Text> ) } ``` ## Props `Link` | Prop | Type | Default | Description | | --- | --- | --- | --- | | href | `string` | | | | target | `string` | | | | rel | `string` | | | | download | `boolean \| string` | | | | variant | `'plain' \| 'underline'` | `'plain'` | `plain` underlines on hover, `underline` is always underlined | | disabled | `boolean` | | | | onClick | `(event: MouseEvent<HTMLAnchorElement>) => void` | | | | ref | `Ref<HTMLAnchorElement>` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### List (https://v2.alineacms.com/docs/components/list) A list of items with a visual, title and status. Example: List ```tsx import { List, ListItem, ListItemDescription, ListItemStatus, ListItemTitle, ListItemVisual } from 'alinea/components' import {IcOutlineDrafts, IcRoundCheck} from 'alinea/dashboard/icons' export function ListExample() { return ( <List aria-label="History" style={{width: 360}}> <ListItem leading={ <ListItemVisual> <IcOutlineDrafts data-slot="icon" /> </ListItemVisual> } trailing={<ListItemStatus color="primary">Draft</ListItemStatus>} > <ListItemTitle>Linen shirt</ListItemTitle> <ListItemDescription>Maya Janssens · 10:48</ListItemDescription> </ListItem> <ListItem leading={ <ListItemVisual> <IcRoundCheck data-slot="icon" /> </ListItemVisual> } trailing={<ListItemStatus color="success">Published</ListItemStatus>} > <ListItemTitle>Oak dining chair</ListItemTitle> <ListItemDescription>Tom Verbeke · Yesterday</ListItemDescription> </ListItem> </List> ) } ``` ## Props `List` | Prop | Type | Default | Description | | --- | --- | --- | --- | | empty | `boolean` | | Renders the list as a status region, for a list holding `ListEmpty` | Also accepts every prop of `Surface`. `ListItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | leading | `ReactNode` | | | | trailing | `ReactNode` | | | | inner | `ReactNode` | | | | onClick | `() => void` | | Makes the item actionable: its header renders as a button | | selected | `boolean` | | | Also accepts every prop of a `<div>` element. `ListItemVisual` Accepts every prop of a `<span>` element. `ListItemTitle` Accepts every prop of a `<span>` element. `ListItemDescription` Accepts every prop of a `<span>` element. `ListItemStatus` | Prop | Type | Default | Description | | --- | --- | --- | --- | | color | `'default' \| 'muted' \| 'primary' \| 'destructive' \| 'warning' \| 'success'` | `'muted'` | Color of the text and its dot, defaults to `muted` | Also accepts every prop of a `<span>` element. `ListEmpty` | Prop | Type | Default | Description | | --- | --- | --- | --- | | icon | `IconType` | | | | title (required) | `ReactNode` | | | Also accepts every prop of a `<div>` element. `ListLabel` | Prop | Type | Default | Description | | --- | --- | --- | --- | | expanded (required) | `boolean` | | | | hasRows | `boolean` | | | | shared | `boolean` | | | | required | `boolean` | | Marks the label with an asterisk, like a required field label | | showFold | `boolean` | `true` | | | description | `ReactNode` | | | | inline | `boolean` | `false` | | Also accepts every prop of `Button`. `ListError` Accepts every prop of a `<div>` element. ### MediaPreview (https://v2.alineacms.com/docs/components/media-preview) A thumbnail of an image or an icon for other files. Example: MediaPreview ```tsx import {type FocusPoint, MediaPreview} from 'alinea/components' import {useState} from 'react' export function MediaPreviewExample() { const [focus, setFocus] = useState<FocusPoint>({x: 0.5, y: 0.6}) return ( <MediaPreview src="/catalog/oak-dining-chair.jpg" averageColor="#b59a7c" width={480} height={270} alt="Oak dining chair" focus={focus} onFocusChange={setFocus} style={{width: 320, height: 180}} /> ) } ``` ## Props `MediaPreview` An image preview with an optional, editable focal point | Prop | Type | Default | Description | | --- | --- | --- | --- | | src | `string` | | The image, leave out while it is being resolved | | placeholder | `string` | | A small image shown blurred behind the preview, eg. a thumbhash data url | | averageColor | `string` | | Background color behind the placeholder | | width | `number` | | Intrinsic width of the image, reserves its aspect ratio | | height | `number` | | Intrinsic height of the image, reserves its aspect ratio | | alt | `string` | `''` | | | focus | `FocusPoint` | | Shows a focal point marker on the image | | onFocusChange | `(focus: FocusPoint) => void` | | Makes the focal point editable by dragging, clicking or with the arrow keys. Leave out for a read-only preview. | | onFocusHover | `(focus: FocusPoint \| null) => void` | | Reports the point under the pointer while hovering or dragging, and null once the pointer leaves | | focusLabel | `string` | `'Focus point'` | Accessible label of the focal point control, defaults to "Focus point" | Also accepts `className` and `style`, `data-*` attributes. ### MultipleSelect (https://v2.alineacms.com/docs/components/multiple-select) Picks several values, shown as tags in the field. Example: MultipleSelect ```tsx import {MultipleSelect, MultipleSelectItem} from 'alinea/components' export function MultipleSelectExample() { return ( <MultipleSelect label="Materials" defaultValue={['linen', 'oak']} style={{width: 300}} > <MultipleSelectItem value="linen">Linen</MultipleSelectItem> <MultipleSelectItem value="oak">Oak</MultipleSelectItem> <MultipleSelectItem value="wool">Wool</MultipleSelectItem> <MultipleSelectItem value="walnut">Walnut</MultipleSelectItem> </MultipleSelect> ) } ``` ## Props `MultipleSelect` A labelled field to pick several values from a searchable list, the selected values are shown as removable tags. | Prop | Type | Default | Description | | --- | --- | --- | --- | | value | `Array<string>` | | | | defaultValue | `Array<string>` | | | | onValueChange | `(value: Array<string>) => void` | | | | placeholder | `string` | | | | emptyMessage | `ReactNode` | `'No options'` | Shown in the list when no item matches the search, defaults to "No options" | | name | `string` | | Name of the hidden form input carrying the values | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | | open | `boolean` | | | | defaultOpen | `boolean` | | | | onOpenChange | `(open: boolean) => void` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `MultipleSelectItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value (required) | `string` | | | | textValue | `string` | | Text used for searching and the tag, defaults to string children | | disabled | `boolean` | | | Also accepts `className` and `style`. ### NavRail (https://v2.alineacms.com/docs/components/nav-rail) A narrow bar of icon links to switch sections. Example: NavRail ```tsx import { NavRail, NavRailContent, NavRailFooter, NavRailItem } from 'alinea/components' import { IcBaselineAccountCircle, IcOutlineSettings, LucideFile, LucideImage } from 'alinea/dashboard/icons' export function NavRailExample() { return ( <div style={{display: 'flex', height: 240}}> <NavRail aria-label="Sections"> <NavRailContent> <NavRailItem icon={LucideFile} label="Pages" active /> <NavRailItem icon={LucideImage} label="Media" badge={3} /> <NavRailItem icon={IcOutlineSettings} label="Settings" /> </NavRailContent> <NavRailFooter> <NavRailItem icon={IcBaselineAccountCircle} label="Maya Janssens" /> </NavRailFooter> </NavRail> </div> ) } ``` ## Props `NavRail` A narrow vertical bar of icon buttons at the edge of the AppShell, for switching between the main sections of the application. It turns into a horizontal bar on small screens. | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `NavRailHeader` The top of the rail, eg. a logo or workspace switcher | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `data-*` attributes. `NavRailContent` The navigation items of the rail | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `NavRailItem` An icon button in the rail with its label in a tooltip | Prop | Type | Default | Description | | --- | --- | --- | --- | | icon (required) | `IconType` | | | | label (required) | `string` | | Shown in a tooltip and used as the accessible name, with the badge count | | active | `boolean` | | Marks the item as the current section | | href | `string` | | Renders the item as a link | | disabled | `boolean` | | | | badge | `ReactNode` | | A count or `true` for a dot, shown on top of the icon | | onClick | `(event: MouseEvent<HTMLElement>) => void` | | | Also accepts `className` and `style`, `data-*` attributes. `NavRailFooter` Pinned to the end of the rail, eg. activity and the user menu | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `data-*` attributes. ### NumberField (https://v2.alineacms.com/docs/components/number-field) A number input with increment and decrement buttons. Example: NumberField ```tsx import {NumberField} from 'alinea/components' export function NumberFieldExample() { return ( <NumberField label="Price" defaultValue={89} min={0} formatOptions={{style: 'currency', currency: 'EUR'}} style={{width: 220}} /> ) } ``` ## Props `NumberField` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value | `number \| null` | | The number, or null when the field is empty | | defaultValue | `number \| null` | | | | onValueChange | `(value: number \| null) => void` | | | | min | `number` | | | | max | `number` | | | | step | `number` | | | | formatOptions | `Intl.NumberFormatOptions` | | | | placeholder | `string` | | | | steppers | `boolean` | `true` | Show increment and decrement buttons, defaults to true | | name | `string` | | | | autoFocus | `boolean` | | | | onBlur | `(event: FocusEvent<HTMLInputElement>) => void` | | | | onFocus | `(event: FocusEvent<HTMLInputElement>) => void` | | | | onKeyDown | `(event: KeyboardEvent<HTMLInputElement>) => void` | | | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Page (https://v2.alineacms.com/docs/components/page) The header, content and footer of a dashboard page. Example: Page ```tsx import { Badge, Button, Page, PageActions, PageBack, PageContent, PageFooter, PageHeader, PageTitle, TextField } from 'alinea/components' export function PageExample() { return ( <div style={{display: 'flex', width: 560, height: 320}}> <Page> <PageHeader> <PageBack label="Back to Products" /> <PageTitle>Linen shirt</PageTitle> <Badge status="draft">Draft</Badge> <PageActions> <Button color="primary">Publish</Button> </PageActions> </PageHeader> <PageContent contained style={{padding: 16}}> <TextField label="Title" defaultValue="Linen shirt" /> </PageContent> <PageFooter> <Button variant="outline">Cancel</Button> <Button>Save</Button> </PageFooter> </Page> </div> ) } ``` ## Props `Page` A full height view in the SidebarInset: a PageHeader, a scrolling PageContent and an optional PageFooter. | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `PageHeader` The bar at the top of the page, holds an optional PageBack, the PageTitle and PageActions | Prop | Type | Default | Description | | --- | --- | --- | --- | | size | `'default' \| 'lg'` | `'default'` | The `lg` header is taller, as used above entry editors | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `PageBack` A back button at the start of the PageHeader | Prop | Type | Default | Description | | --- | --- | --- | --- | | label | `string` | `'Back'` | Accessible label of the button, defaults to "Back" | | disabled | `boolean` | | | | onClick | `(event: MouseEvent<HTMLButtonElement>) => void` | | | Also accepts `className` and `style`, `data-*` attributes. `PageTitle` | Prop | Type | Default | Description | | --- | --- | --- | --- | | id | `string` | | | | as | `HeadingProps['as']` | `'h1'` | The heading element, defaults to h1 | Also accepts `className` and `style`, `data-*` attributes. `PageActions` Controls at the end of the PageHeader | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `PageContent` The scrolling body of the page | Prop | Type | Default | Description | | --- | --- | --- | --- | | contained | `boolean` | | Centers the children in a column of readable width, as used for forms and documents | | ref | `Ref<HTMLDivElement>` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `PageFooter` A bar pinned to the bottom of the page, eg. for form actions | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Popover (https://v2.alineacms.com/docs/components/popover) Shows rich content in a panel next to its trigger, such as a few settings or a small form. DialogClose and useDialog work inside a popover too, to close it from its content. Example: Popover ```tsx import { DialogClose, Popover, PopoverContent, PopoverTrigger, Switch, Text } from 'alinea/components' import {IcRoundVisibility} from 'alinea/dashboard/icons' export function PopoverExample() { return ( <Popover> <PopoverTrigger variant="outline" icon={IcRoundVisibility}> Visibility </PopoverTrigger> <PopoverContent side="bottom" align="start" aria-label="Visibility"> <div style={{display: 'grid', gap: 10, justifyItems: 'start'}}> <Text size="sm" color="muted"> Choose where this entry is shown. </Text> <Switch defaultChecked>In navigation</Switch> <Switch>In search results</Switch> <DialogClose size="sm">Done</DialogClose> </div> </PopoverContent> </Popover> ) } ``` ## Props `Popover` | Prop | Type | Default | Description | | --- | --- | --- | --- | | modal | `boolean` | `true` | Whether interaction outside the popover is blocked while it is open. Defaults to true. | | open | `boolean` | | | | defaultOpen | `boolean` | | | | onOpenChange | `(open: boolean) => void` | | | `PopoverTrigger` Accepts every prop of `Button`. `PopoverAnchor` Positions the PopoverContent against this element instead of the PopoverTrigger | Prop | Type | Default | Description | | --- | --- | --- | --- | | asChild | `boolean` | | Render the child element as the anchor instead of a div | | virtualRef | `RefObject<Element \| null>` | | Position against an element rendered elsewhere, the anchor then renders only its children (if any) | Also accepts `className` and `style`, `data-*` attributes. `PopoverContent` | Prop | Type | Default | Description | | --- | --- | --- | --- | | onInteractOutside | `(event: Event) => void` | | Called on a click outside of the popover, call `event.preventDefault()` to keep the popover open. The page is covered while a modal popover is open, so `event.target` is that cover rather than the element below it. | | side | `Side` | | | | align | `Align` | | | | sideOffset | `number` | | | | alignOffset | `number` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ## Accessibility Focus moves into the popover when it opens and back to the trigger when it closes. A popover is modal by default: the rest of the page can't be reached until it closes. Set `modal={false}` to keep the page interactive. ### PreviewFrame (https://v2.alineacms.com/docs/components/preview-frame) A device frame for a live preview, with its toolbar. Example: PreviewFrame ```tsx import {PreviewFrame, PreviewToolbar, Text} from 'alinea/components' import {useState} from 'react' const page = `data:text/html,${encodeURIComponent( '<body style="font-family:sans-serif"><h1>Linen shirt</h1><p>€89</p></body>' )}` export function PreviewFrameExample() { const [version, setVersion] = useState(0) return ( <div style={{ display: 'flex', flexDirection: 'column', width: 420, height: 260 }} > <PreviewToolbar onReload={() => setVersion(version + 1)}> <Text size="sm" color="muted"> /products/linen-shirt </Text> </PreviewToolbar> <PreviewFrame key={version} title="Preview of Linen shirt" src={page} /> </div> ) } ``` ## Props `PreviewFrame` Renders a live preview of a page in an iframe filling the available space | Prop | Type | Default | Description | | --- | --- | --- | --- | | src | `string` | | The previewed url, leave out while it is unknown | | title (required) | `string` | | Accessible title of the iframe | | loading | `boolean` | | Covers the frame with a spinner, eg. until the page has loaded | | loadingLabel | `string` | `'Loading preview'` | Accessible label of the loading spinner, defaults to "Loading preview" | | unavailable | `ReactNode` | | Shown instead of the frame when there is no `src` and nothing loads | | sandbox | `string` | | The iframe sandbox tokens | | allow | `string` | | The iframe permissions policy | | onLoad | `() => void` | | | | ref | `Ref<HTMLIFrameElement>` | | | Also accepts `className` and `style`, `data-*` attributes. `PreviewToolbar` Browser-like controls above a PreviewFrame | Prop | Type | Default | Description | | --- | --- | --- | --- | | onBack | `() => void` | | Navigates back in the preview, the button is disabled when left out | | onForward | `() => void` | | Navigates forward in the preview, the button is disabled when left out | | onReload | `() => void` | | Reloads the preview, the button is disabled when left out | | onOpen | `() => void` | | Opens the preview elsewhere, the button is disabled when left out | | reloading | `boolean` | | Shows a spinner in the reload button | | labels | `PreviewToolbarLabels` | | Accessible labels of the buttons | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### RadioGroup (https://v2.alineacms.com/docs/components/radio-group) Picks one value from a small set of options. Example: RadioGroup ```tsx import {RadioGroup, RadioGroupItem} from 'alinea/components' export function RadioGroupExample() { return ( <RadioGroup label="Visibility" defaultValue="public"> <RadioGroupItem value="public">Public</RadioGroupItem> <RadioGroupItem value="unlisted">Unlisted</RadioGroupItem> <RadioGroupItem value="private">Private</RadioGroupItem> </RadioGroup> ) } ``` ## Props `RadioGroup` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value | `string` | | | | defaultValue | `string` | | | | onValueChange | `(value: string) => void` | | | | orientation | `Orientation` | `'vertical'` | | | name | `string` | | | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `RadioGroupItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value (required) | `string` | | | | disabled | `boolean` | | | | autoFocus | `boolean` | | | | description | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### ResizablePanelGroup (https://v2.alineacms.com/docs/components/resizable-panel-group) Panels with handles to resize them. Example: ResizablePanelGroup ```tsx import { ResizableHandle, ResizablePanel, ResizablePanelGroup, Surface, SurfaceContent } from 'alinea/components' export function ResizablePanelGroupExample() { return ( <Surface style={{display: 'flex', width: 560, height: 220}}> <ResizablePanelGroup> <ResizablePanel defaultSize={180} minSize={120} maxSize={280}> <SurfaceContent>Pages</SurfaceContent> </ResizablePanel> <ResizableHandle withHandle /> <ResizablePanel minSize={200} priority="high"> <SurfaceContent>Linen shirt</SurfaceContent> </ResizablePanel> <ResizableHandle withHandle /> <ResizablePanel defaultSize={160} minSize={120}> <SurfaceContent>Details</SurfaceContent> </ResizablePanel> </ResizablePanelGroup> </Surface> ) } ``` ## Props `ResizablePanelGroup` Lays out ResizablePanel children next to each other, with a draggable divider between them. Sizes are in px. Panels must be direct children, give them a `key` when they are rendered conditionally. | Prop | Type | Default | Description | | --- | --- | --- | --- | | direction | `Orientation` | `'horizontal'` | The axis the panels are laid out on, defaults to horizontal | | onLayout | `(sizes: Array<number>) => void` | | The size of every panel in px, after the user resized them | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `ResizablePanel` A panel of a ResizablePanelGroup | Prop | Type | Default | Description | | --- | --- | --- | --- | | defaultSize | `number` | | Size in px on mount, and the size restored by double clicking a handle | | size | `number` | | Size in px, eg. a stored preference. The panel is resized when it changes. Leave both sizes off to let the panel fill the remaining space. | | onSizeChange | `(size: number) => void` | | Called with the new size in px after the user resized the panel | | minSize | `number` | | | | maxSize | `number` | | | | visible | `boolean` | | Hide the panel without unmounting its children, defaults to true | | priority | `'low' \| 'normal' \| 'high'` | | Panels with a higher priority grow and shrink first when the group is resized, defaults to high for a panel without a size and low otherwise | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `ResizableHandle` Marks the divider between two panels of a ResizablePanelGroup. Drag it to resize the panels, double click it to restore their default sizes. | Prop | Type | Default | Description | | --- | --- | --- | --- | | withHandle | `boolean` | | Shows a grip on the divider | ### SearchField (https://v2.alineacms.com/docs/components/search-field) A text input for search queries, with a clear button. Example: SearchField ```tsx import {SearchField} from 'alinea/components' export function SearchFieldExample() { return ( <SearchField aria-label="Search entries" placeholder="Search entries" defaultValue="linen" style={{width: 280}} /> ) } ``` ## Props `SearchField` A text input for search queries with a clear button. The `icon` is shown inside the input, in front of the query. | Prop | Type | Default | Description | | --- | --- | --- | --- | | value | `string` | | | | defaultValue | `string` | | | | onValueChange | `(value: string) => void` | | | | onSubmit | `(value: string) => void` | | Called when the user presses enter | | onClear | `() => void` | | Called when the user clears the field with the clear button or escape | | placeholder | `string` | | | | loading | `boolean` | | Shows a spinner while results are being loaded | | name | `string` | | | | autoFocus | `boolean` | | | | onBlur | `(event: FocusEvent<HTMLInputElement>) => void` | | | | onFocus | `(event: FocusEvent<HTMLInputElement>) => void` | | | | onKeyDown | `(event: KeyboardEvent<HTMLInputElement>) => void` | | | | role | `'searchbox' \| 'combobox'` | | Set to `combobox` when the field controls a list of results that is navigated with the arrow keys while focus stays in the field (together with `aria-controls` and `aria-activedescendant`), defaults to `searchbox` | | aria-controls | `string` | | The id of the results a combobox controls | | aria-activedescendant | `string` | | The id of the active result within the results a combobox controls | | aria-expanded | `boolean` | | Whether the results a combobox controls are shown | | aria-haspopup | `'listbox' \| 'grid' \| 'tree' \| 'dialog'` | | The kind of element holding the results a combobox controls | | aria-autocomplete | `'list' \| 'none'` | | | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Select (https://v2.alineacms.com/docs/components/select) Picks a single value from a list of options. The value is the `value` of the chosen SelectItem, and editors can clear it unless the field is required. Example: Select ```tsx import {Select, SelectItem} from 'alinea/components' export function SelectExample() { return ( <Select label="Status" defaultValue="published" style={{width: 260}}> <SelectItem value="draft">Draft</SelectItem> <SelectItem value="published">Published</SelectItem> <SelectItem value="archived">Archived</SelectItem> </Select> ) } ``` ## Groups Group related options under a label and divide groups with SelectSeparator. Items can show an icon and a description below their label. Example: Select: groups ```tsx import { Select, SelectGroup, SelectItem, SelectSeparator } from 'alinea/components' import { IcRoundDescription, IcRoundFeed, IcRoundImage } from 'alinea/dashboard/icons' export function SelectGroupsExample() { return ( <Select label="Entry type" defaultValue="product" style={{width: 280}}> <SelectGroup label="Pages"> <SelectItem value="page" icon={IcRoundDescription}> Page </SelectItem> <SelectItem value="product" icon={IcRoundImage} description="A product with price and photos" > Product </SelectItem> </SelectGroup> <SelectSeparator /> <SelectGroup label="Blog"> <SelectItem value="post" icon={IcRoundFeed}> Blog post </SelectItem> </SelectGroup> </Select> ) } ``` ## States A placeholder shows while nothing is selected. Like the other form controls, a select takes `description`, `error`, `required`, `disabled` and `readOnly`. Example: Select: states ```tsx import {Select, SelectItem} from 'alinea/components' export function SelectStatesExample() { return ( <div style={{display: 'grid', gap: 16, width: 280}}> <Select label="Category" placeholder="Choose a category"> <SelectItem value="bedroom">Bedroom</SelectItem> <SelectItem value="dining">Dining</SelectItem> </Select> <Select label="Locale" defaultValue="en" disabled> <SelectItem value="en">English</SelectItem> <SelectItem value="nl">Nederlands</SelectItem> </Select> <Select label="Status" required error="Pick a status to continue"> <SelectItem value="draft">Draft</SelectItem> <SelectItem value="published">Published</SelectItem> </Select> </div> ) } ``` ## Props `Select` A labelled field to pick a single value from a list. The value can be cleared unless the field is required. | Prop | Type | Default | Description | | --- | --- | --- | --- | | value | `string \| null` | | | | defaultValue | `string \| null` | | | | onValueChange | `(value: string \| null) => void` | | Called with the selected value, or null when the value is cleared | | placeholder | `string` | | | | name | `string` | | Name of the hidden form input carrying the value | | autoFocus | `boolean` | | | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | | open | `boolean` | | | | defaultOpen | `boolean` | | | | onOpenChange | `(open: boolean) => void` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `SelectItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value (required) | `string` | | | | disabled | `boolean` | | | | icon | `IconType \| ReactElement` | | | | textValue | `string` | | Text used for typeahead and the value, defaults to string children | | description | `ReactNode` | | Secondary text shown below the label in the list | Also accepts `className` and `style`. `SelectGroup` | Prop | Type | Default | Description | | --- | --- | --- | --- | | label | `ReactNode` | | | Also accepts `id` and `aria-*` labelling attributes. `SelectSeparator` ## Accessibility The list opens with Enter, Space or the arrow keys and supports typeahead: typing jumps to the first matching option. The options are announced as a listbox with the selected one marked. ### Sidebar (https://v2.alineacms.com/docs/components/sidebar) A side panel with groups of navigation. Example: Sidebar ```tsx import { Button, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarHeader, Text, Tree, TreeItem } from 'alinea/components' import {IcRoundAdd, LucideFile, LucideFolder} from 'alinea/dashboard/icons' export function SidebarExample() { return ( <div style={{display: 'flex', width: 280, height: 320}}> <Sidebar aria-label="Content"> <SidebarHeader> <Text weight="semibold">Oak & Loom</Text> </SidebarHeader> <SidebarContent scroll> <SidebarGroup aria-labelledby="pages-label"> <SidebarGroupLabel id="pages-label">Pages</SidebarGroupLabel> <Tree aria-label="Pages" defaultExpandedKeys={['products']}> <TreeItem id="home" title="Home" icon={LucideFile} /> <TreeItem id="products" title="Products" icon={LucideFolder}> <TreeItem id="shirt" title="Linen shirt" icon={LucideFile} /> </TreeItem> </Tree> </SidebarGroup> </SidebarContent> <SidebarFooter> <Button color="secondary" icon={IcRoundAdd}> Create new </Button> </SidebarFooter> </Sidebar> </div> ) } ``` ## Props `Sidebar` A column next to the SidebarInset, eg. the content tree or entry details. Place it in a ResizablePanel to make it resizable. On small screens it covers the layout. | Prop | Type | Default | Description | | --- | --- | --- | --- | | side | `'left' \| 'right'` | `'left'` | The edge of the layout the sidebar is placed on, defaults to left. Exposed as `data-side` for styling. | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `SidebarHeader` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `SidebarContent` Fills the space between the header and footer | Prop | Type | Default | Description | | --- | --- | --- | --- | | scroll | `boolean` | | Scroll the content when it overflows, leave off when children scroll | | ref | `Ref<HTMLDivElement>` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `SidebarFooter` | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `SidebarGroup` A section of the sidebar, optionally titled with a SidebarGroupLabel | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `SidebarGroupLabel` | Prop | Type | Default | Description | | --- | --- | --- | --- | | id | `string` | | Defaults to an id that labels the surrounding SidebarGroup | Also accepts `className` and `style`, `data-*` attributes. `SidebarGroupAction` Places a small control, eg. a Button or DropdownMenuTrigger with `size="sm"`, at the end of the group label. | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `data-*` attributes. `SidebarInset` The main content column next to the sidebars | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### SortableList (https://v2.alineacms.com/docs/components/sortable-list) Rows that editors reorder by dragging, like list fields. Example: SortableList ```tsx import { Badge, type DragMoveEvent, SortableList, SortableListHandle, SortableListItem, SortableListItemDescription, SortableListItemHeader, SortableListItemTitle } from 'alinea/components' import {useState} from 'react' const initial = [ {id: 'hero', type: 'Hero', label: 'Autumn collection'}, {id: 'products', type: 'Products', label: 'New in linen'}, {id: 'quote', type: 'Quote', label: 'From the workshop'} ] export function SortableListExample() { const [sections, setSections] = useState(initial) function reorder({keys, target}: DragMoveEvent) { const moved = sections.filter(section => keys.has(section.id)) const rest = sections.filter(section => !keys.has(section.id)) const index = rest.findIndex(section => section.id === target.key) rest.splice(target.position === 'before' ? index : index + 1, 0, ...moved) setSections(rest) } return ( <SortableList aria-label="Sections" onReorder={reorder} style={{width: 400}} > {sections.map(section => ( <SortableListItem key={section.id} id={section.id} role="listitem"> <SortableListItemHeader> <SortableListHandle aria-label={`Drag ${section.label}`} /> <SortableListItemTitle> <Badge size="sm">{section.type}</Badge> <SortableListItemDescription> {section.label} </SortableListItemDescription> </SortableListItemTitle> </SortableListItemHeader> </SortableListItem> ))} </SortableList> ) } ``` ## Props `SortableList` | Prop | Type | Default | Description | | --- | --- | --- | --- | | onReorder | `(event: DragMoveEvent) => void` | | Enables reordering: items with an `id` can be dragged by their `SortableListHandle`, with a pointer or with the keyboard (Enter on the handle, Tab to an item, Enter to drop). Called with the key of the dragged item and the item it was dropped before or after. Items only drop within the list they were dragged from. | | dragType | `string` | | Mime type the dragged items carry, defaults to `alinea/sortable-list-item` | Also accepts every prop of `Surface`. `SortableListItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | id | `Key` | | Identifies the item within its list. Items with an id can be dragged when the list has `onReorder`. The id is not rendered as a DOM id. | | dragPreview | `ReactNode` | | Rendered under the pointer while dragging, eg. a `SortableListDragPreview` | Also accepts every prop of a `<div>` element. `SortableListItemHeader` Top bar of an item, holds its handle, title and actions Accepts every prop of a `<div>` element. `SortableListHandle` Drags its `SortableListItem` when the list has `onReorder`. Focusable in that case: press Enter to start a keyboard drag. Place it in the `SortableListItemHeader`, it shows while the header is hovered or focused. Accepts every prop of a `<span>` element. `SortableListItemTitle` Row of the toggle, badges and description of an item, fills the header Accepts every prop of a `<div>` element. `SortableListItemDescription` Muted, truncated text next to the badges of an item, eg. its label Accepts every prop of a `<span>` element. `SortableListItemActions` Accepts every prop of a `<div>` element. `SortableListItemToggle` Folds the content of an item | Prop | Type | Default | Description | | --- | --- | --- | --- | | expanded (required) | `boolean` | | Whether the item content is shown, rotates the fold icon | Also accepts every prop of `Button`. `SortableListItemContent` The fields of an expanded item Accepts every prop of a `<div>` element. `SortableListItemFooter` A summary under the header, eg. of a folded item Accepts every prop of a `<div>` element. `SortableListItemSettings` A group of settings or actions in the popover of an item | Prop | Type | Default | Description | | --- | --- | --- | --- | | variant | `'default' \| 'actions'` | `'default'` | `actions` removes the padding around a group of ghost buttons | Also accepts every prop of a `<div>` element. `SortableListDragPreview` Card shown under the pointer while dragging an item | Prop | Type | Default | Description | | --- | --- | --- | --- | | icon | `IconType` | | | | label (required) | `ReactNode` | | | Also accepts every prop of a `<div>` element. `SortableListAdd` Last row of the list holding the buttons that add items Accepts every prop of a `<div>` element. ### Spinner (https://v2.alineacms.com/docs/components/spinner) Shows that something is loading. Example: Spinner ```tsx import {Spinner} from 'alinea/components' export function SpinnerExample() { return ( <> <Spinner size="sm" aria-label="Saving" /> <Spinner aria-label="Loading products" /> <Spinner size="lg" aria-label="Loading page" /> <Spinner size="lg" value={68} aria-label="Uploading linen-shirt.jpg" /> </> ) } ``` ## Props `Spinner` A circular progress indicator | Prop | Type | Default | Description | | --- | --- | --- | --- | | size | `'sm' \| 'default' \| 'lg'` | `'default'` | | | value | `number` | | Progress from 0 to 100, leave out for an indeterminate spinner | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Surface (https://v2.alineacms.com/docs/components/surface) A bordered panel for a group of content. Example: Surface ```tsx import { Button, Surface, SurfaceContent, SurfaceHeader, SurfaceRow, Text } from 'alinea/components' export function SurfaceExample() { return ( <Surface aria-label="Shipping" role="region" style={{width: 360}}> <SurfaceHeader> <Text weight="semibold">Shipping</Text> </SurfaceHeader> <SurfaceContent> <Text color="muted">Free delivery on orders over €150.</Text> <Surface> <SurfaceRow> <Text style={{flex: 1}}>Belgium · 2 days</Text> <Button variant="ghost" size="sm"> Edit </Button> </SurfaceRow> </Surface> </SurfaceContent> </Surface> ) } ``` ## Props `Surface` | Prop | Type | Default | Description | | --- | --- | --- | --- | | depth | `'base' \| 'muted'` | | | | variant | `'base' \| 'muted'` | | | Also accepts every prop of a `<div>` element. `SurfaceHeader` Accepts every prop of a `<header>` element. `SurfaceContent` Accepts every prop of a `<div>` element. `SurfaceRow` Accepts every prop of a `<div>` element. ### Switch (https://v2.alineacms.com/docs/components/switch) Turns a setting on or off, with an immediate effect. For choices that are saved later, as part of a form, use a Checkbox. Example: Switch ```tsx import {Switch} from 'alinea/components' export function SwitchExample() { return <Switch defaultChecked>Published</Switch> } ``` ## States Example: Switch: states ```tsx import {Switch} from 'alinea/components' export function SwitchStatesExample() { return ( <div style={{display: 'grid', gap: 16, width: 280}}> <Switch>Show prices incl. VAT</Switch> <Switch defaultChecked>Free shipping</Switch> <Switch disabled>Gift cards</Switch> <Switch defaultChecked readOnly> Maintenance mode </Switch> </div> ) } ``` ## Props `Switch` | Prop | Type | Default | Description | | --- | --- | --- | --- | | checked | `boolean` | | | | defaultChecked | `boolean` | | | | onCheckedChange | `(checked: boolean) => void` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | name | `string` | | | | value | `string` | | | | autoFocus | `boolean` | | | | ref | `Ref<HTMLLabelElement>` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ## Accessibility Announced as a switch with its on or off state. Space toggles it and clicking the label does too. ### Table (https://v2.alineacms.com/docs/components/table) Rows and columns of data with sorting, selection, nested rows and drag and drop. Describe the columns up front, then render a TableRow for every item. Example: Table ```tsx import { Badge, Table, TableCell, TableRow, TableThumbnail, TableTitle } from 'alinea/components' const products = [ {id: 'oak-dining-chair', title: 'Oak dining chair', status: 'published'}, {id: 'walnut-stools', title: 'Walnut stools', status: 'draft'}, {id: 'linen-lounge-chair', title: 'Linen lounge chair', status: 'archived'} ] as const export function TableExample() { return ( <div style={{width: 520, height: 180}}> <Table aria-label="Products" items={products} columns={[ {id: 'image', header: 'Image', width: 72}, {id: 'title', header: 'Title', width: '1fr'}, {id: 'status', header: 'Status', width: 120} ]} > {product => ( <TableRow id={product.id} textValue={product.title}> <TableThumbnail src={`/catalog/${product.id}.jpg`} alt="" /> <TableTitle title={product.title} label="Products" /> <TableCell> <Badge size="sm" status={product.status}> {product.status} </Badge> </TableCell> </TableRow> )} </Table> </div> ) } ``` ## Selection and sorting Set a `selectionMode` to let editors select rows, `showSelectionControls` adds a checkbox to every row. Mark columns as `sortable` and sort the items in `onSortChange`. Example: Table: selection ```tsx import { type SortDescriptor, Table, TableCell, TableRow, TableTitle } from 'alinea/components' import {useState} from 'react' const products = [ {id: 'shirt', title: 'Linen shirt', price: 89}, {id: 'chair', title: 'Oak dining chair', price: 245}, {id: 'stool', title: 'Walnut stool', price: 160}, {id: 'lamp', title: 'Ceramic table lamp', price: 120} ] export function TableSelectionExample() { const [sort, setSort] = useState<SortDescriptor>({ column: 'title', direction: 'asc' }) const sorted = [...products].sort((a, b) => sort.column === 'price' ? a.price - b.price : a.title.localeCompare(b.title) ) if (sort.direction === 'desc') sorted.reverse() return ( <div style={{width: 520, height: 230}}> <Table aria-label="Products" items={sorted} columns={[ {id: 'title', header: 'Title', width: '1fr', sortable: true}, { id: 'price', header: 'Price', width: 120, align: 'end', sortable: true } ]} selectionMode="multiple" showSelectionControls defaultSelectedKeys={new Set(['chair'])} sortDescriptor={sort} onSortChange={setSort} > {product => ( <TableRow id={product.id} textValue={product.title}> <TableTitle title={product.title} /> <TableCell align="end">€{product.price}</TableCell> </TableRow> )} </Table> </div> ) } ``` ## Props `Table` | Prop | Type | Default | Description | | --- | --- | --- | --- | | items (required) | `Iterable<T>` | | | | columns (required) | `ReadonlyArray<TableColumn>` | | | | showHeader | `boolean` | `true` | Show the column headers, defaults to true | | expandable | `boolean` | `false` | Reserve space for expand toggles so titles line up, set when rows nest | | rowHeight | `number` | `44` | | | selectionBehavior | `'toggle' \| 'replace'` | `'toggle'` | How pointer clicks select rows. With `toggle` (the default) a click runs the row action, or toggles its selection while rows are selected. With `replace` a click selects only that row and a double click or Enter runs the row action. | | showSelectionControls | `boolean` | | Show a selection checkbox per row, defaults to multiple selection | | variant | `'surface' \| 'plain'` | `'surface'` | `plain` drops the rounded surface, eg. when the table fills a panel | | expandedKeys | `Iterable<Key>` | | | | defaultExpandedKeys | `Iterable<Key>` | | | | onExpandedChange | `(keys: Set<Key>) => void` | | | | sortDescriptor | `SortDescriptor` | | | | onSortChange | `(descriptor: SortDescriptor) => void` | | | | onRowAction | `(key: Key) => void` | | Called when a row is activated (see `selectionBehavior`), rows can override it with their own `onAction` | | renderEmptyState | `() => ReactNode` | | | | dependencies | `ReadonlyArray<unknown>` | | Rows are cached per item, list the values `children` reads besides the item to render them again when those change | | children (required) | `(item: T) => ReactElement` | | | `TableRow` | Prop | Type | Default | Description | | --- | --- | --- | --- | | id (required) | `Key` | | | | textValue (required) | `string` | | Text used for typeahead and as the accessible row name | | hasChildren | `boolean` | | Shows the expand toggle, also before the nested rows are loaded | | rows | `ReactNode` | | Nested TableRows, rendered when the row is expanded. Pass them only once expanded to load them lazily, eg. from a component that renders the rows of already loaded data. | | selectable | `boolean` | `true` | Set to false to disable selecting the row, its action still runs | | highlighted | `boolean` | | Highlights the row, eg. to mark items that are already in use | | onAction | `() => void` | | Called when the row is activated, overrides `onRowAction` | | onClick | `() => void` | | Called on every click or tap of the row, next to its selection. Use it instead of `onAction` to act on a single click with the `replace` selection behavior, where `onAction` needs a double click. | | onDoubleClick | `() => void` | | | Also accepts `data-*` attributes. `TableCell` | Prop | Type | Default | Description | | --- | --- | --- | --- | | label | `ReactNode` | | A small caption above the value, like the dashboard explorer shows its columns when the header is hidden | | align | `'start' \| 'end' \| 'center'` | | | | title | `string` | | Tooltip text | Also accepts `className` and `style`. `TableThumbnail` An image cell, sized to the row height | Prop | Type | Default | Description | | --- | --- | --- | --- | | src | `string` | | | | alt | `string` | `''` | | Also accepts `className` and `style`. `TableTitle` | Prop | Type | Default | Description | | --- | --- | --- | --- | | icon | `IconType` | | Doubles as the drag handle of the row when rows can be dragged | | title (required) | `ReactNode` | | | | label | `ReactNode` | | A small caption above the title, eg. the parent path | Also accepts `className` and `style`. ## Accessibility Built on the React Aria grid: the arrow keys move between rows, Space selects a row and Enter runs its action. Sortable column headers announce their sort direction. ### Tabs (https://v2.alineacms.com/docs/components/tabs) Splits content into sections that are shown one at a time. TabsTrigger and TabsContent are matched by their `value`. Example: Tabs ```tsx import {Tabs, TabsContent, TabsList, TabsTrigger} from 'alinea/components' export function TabsExample() { return ( <Tabs defaultValue="document"> <TabsList aria-label="Entry"> <TabsTrigger value="document">Document</TabsTrigger> <TabsTrigger value="metadata">Metadata</TabsTrigger> <TabsTrigger value="history">History</TabsTrigger> </TabsList> <TabsContent value="document">The fields of the entry.</TabsContent> <TabsContent value="metadata"> Title and description for search. </TabsContent> <TabsContent value="history">Earlier versions of the entry.</TabsContent> </Tabs> ) } ``` ## Variants `line` underlines the selected tab, `subtle` gives it a background and `enclosed` sets the tabs in a track, like a segmented control. Example: Tabs: variants ```tsx import {Tabs, TabsList, TabsTrigger} from 'alinea/components' const variants = ['line', 'subtle', 'enclosed'] as const export function TabsVariantsExample() { return ( <div style={{display: 'grid', gap: 24}}> {variants.map(variant => ( <Tabs key={variant} variant={variant} defaultValue="all"> <TabsList aria-label={`Filter, ${variant}`}> <TabsTrigger value="all">All</TabsTrigger> <TabsTrigger value="published">Published</TabsTrigger> <TabsTrigger value="drafts">Drafts</TabsTrigger> </TabsList> </Tabs> ))} </div> ) } ``` ## Vertical With `orientation="vertical"` the tabs stand next to their content, which suits settings with many sections. Example: Tabs: vertical ```tsx import {Tabs, TabsContent, TabsList, TabsTrigger} from 'alinea/components' export function TabsVerticalExample() { return ( <Tabs orientation="vertical" variant="subtle" defaultValue="general"> <TabsList aria-label="Settings"> <TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="languages">Languages</TabsTrigger> <TabsTrigger value="media" disabled> Media </TabsTrigger> </TabsList> <TabsContent value="general">Site name and logo.</TabsContent> <TabsContent value="languages"> English, Nederlands, Français. </TabsContent> <TabsContent value="media">Upload limits.</TabsContent> </Tabs> ) } ``` ## Props `Tabs` A set of layered sections of content, displayed one at a time | Prop | Type | Default | Description | | --- | --- | --- | --- | | value | `string` | | | | defaultValue | `string` | | | | onValueChange | `(value: string) => void` | | | | orientation | `Orientation` | `'horizontal'` | | | variant | `'line' \| 'subtle' \| 'enclosed'` | `'line'` | | | disabled | `boolean` | | | | id | `string` | | | Also accepts `className` and `style`, `data-*` attributes. `TabsList` The row of tab triggers, it scrolls horizontally when the triggers do not fit. | Prop | Type | Default | Description | | --- | --- | --- | --- | | children (required) | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `TabsTrigger` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value (required) | `string` | | | | disabled | `boolean` | | | | aria-label | `string` | | | Also accepts `className` and `style`, `data-*` attributes. `TabsContent` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value (required) | `string` | | | Also accepts `className` and `style`, `data-*` attributes. ## Accessibility The arrow keys move between tabs, Home and End jump to the first and last one. Only the selected tab is in the tab order, so Tab moves on to its content. ### TagGroup (https://v2.alineacms.com/docs/components/tag-group) A list of tags that can be selected or removed. Example: TagGroup ```tsx import {Tag, TagGroup} from 'alinea/components' export function TagGroupExample() { return ( <TagGroup label="Tags" selectionMode="multiple" defaultSelectedKeys={new Set(['linen'])} > <Tag id="linen">Linen</Tag> <Tag id="bedroom">Bedroom</Tag> <Tag id="summer">Summer</Tag> <Tag id="sale">Sale</Tag> </TagGroup> ) } ``` ## Props `TagGroup` | Prop | Type | Default | Description | | --- | --- | --- | --- | | variant | `TagVariant` | `'primary'` | | | shape | `TagShape` | `'square'` | | | onRemove | `(keys: Set<Key>) => void` | | Shows a remove button on every tag | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | | selectionMode | `SelectionMode` | | | | selectedKeys | `Selection` | | | | defaultSelectedKeys | `Selection` | | | | onSelectionChange | `(keys: Selection) => void` | | | | disabledKeys | `Iterable<Key>` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `Tag` | Prop | Type | Default | Description | | --- | --- | --- | --- | | id | `Key` | | Key of the tag, used in the selection and in `onRemove` | | textValue | `string` | | Text for keyboard navigation and screen readers, inferred from a string child | | disabled | `boolean` | | | | variant | `TagVariant` | | Defaults to the variant of the surrounding `TagGroup` | | shape | `TagShape` | | Defaults to the shape of the surrounding `TagGroup` | Also accepts `className` and `style`, `data-*` attributes. ### Text (https://v2.alineacms.com/docs/components/text) Body text with sizes, weights and colors. Example: Text ```tsx import {Text} from 'alinea/components' export function TextExample() { return ( <div style={{display: 'grid', gap: 8, width: 360}}> <Text as="p" size="lg"> Stonewashed linen that softens with every wash. </Text> <Text as="p"> Our <Text weight="semibold">Linen shirt</Text> is cut from European flax. </Text> <Text as="p" size="sm" color="muted"> Last edited by Maya Janssens </Text> <Text color="success">In stock</Text> <Text color="warning">Low stock</Text> <Text color="destructive">Sold out</Text> </div> ) } ``` ## Props `Text` | Prop | Type | Default | Description | | --- | --- | --- | --- | | as | `'span' \| 'p' \| 'div' \| 'label' \| 'small' \| 'strong' \| 'em'` | `'span'` | The element to render, defaults to span | | size | `'xs' \| 'sm' \| 'default' \| 'lg'` | `'default'` | | | weight | `'regular' \| 'medium' \| 'semibold' \| 'bold'` | | | | color | `'default' \| 'muted' \| 'primary' \| 'destructive' \| 'warning' \| 'success'` | | | | align | `'start' \| 'center' \| 'end'` | | | | truncate | `boolean` | | | | asChild | `boolean` | | | | htmlFor | `string` | | | | title | `string` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### TextField (https://v2.alineacms.com/docs/components/text-field) A text input with a label, help text and a validation message. Set `multiline` for a text area that grows with its content. Example: TextField ```tsx import {TextField} from 'alinea/components' export function TextFieldExample() { return ( <TextField label="Title" defaultValue="Summer collection" style={{width: 280}} /> ) } ``` ## States `description` tells editors what to enter, `error` shows a validation message instead and marks the input as invalid. Required fields show a marker next to their label, read-only fields can still be selected and copied. Example: TextField: states ```tsx import {TextField} from 'alinea/components' export function TextFieldStatesExample() { return ( <div style={{display: 'grid', gap: 16, width: 280}}> <TextField label="Slug" description="Used in the page URL" defaultValue="linen-shirt" /> <TextField label="Title" required error="A title is required" /> <TextField label="Author" defaultValue="Anna Peeters" disabled /> <TextField label="Entry id" defaultValue="2mXhVzR4" readOnly /> </div> ) } ``` ## Multiline A multiline field starts at `rows` lines and grows as editors type. Example: TextField: multiline ```tsx import {TextField} from 'alinea/components' export function TextFieldMultilineExample() { return ( <TextField label="Description" multiline rows={4} defaultValue="A relaxed shirt in washed linen that softens with every wear." style={{width: 300}} /> ) } ``` ## Icons `startIcon` and `endIcon` render inside the input, before and after the text. Example: TextField: icons ```tsx import {TextField} from 'alinea/components' import { IcRoundLink, IcRoundOpenInNew, IcRoundSearch } from 'alinea/dashboard/icons' export function TextFieldIconsExample() { return ( <div style={{display: 'grid', gap: 16, width: 280}}> <TextField aria-label="Search products" placeholder="Search products" startIcon={IcRoundSearch} /> <TextField label="Website" type="url" defaultValue="https://oakandloom.com" startIcon={IcRoundLink} endIcon={IcRoundOpenInNew} /> </div> ) } ``` ## Props `TextField` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value | `string` | | | | defaultValue | `string` | | | | onValueChange | `(value: string) => void` | | | | placeholder | `string` | | | | type | `'text' \| 'email' \| 'url' \| 'password' \| 'tel' \| 'search'` | `'text'` | | | multiline | `boolean` | | Render a textarea that grows with its content | | rows | `number` | `1` | Minimum number of rows of a multiline field | | startIcon | `IconType \| ReactElement` | | Icon displayed inside the input, before the text | | endIcon | `IconType \| ReactElement` | | Icon displayed inside the input, after the text | | name | `string` | | | | autoFocus | `boolean` | | | | autoComplete | `string` | | | | maxLength | `number` | | | | minLength | `number` | | | | onBlur | `(event: FocusEvent<TextFieldElement>) => void` | | | | onFocus | `(event: FocusEvent<TextFieldElement>) => void` | | | | onKeyDown | `(event: KeyboardEvent<TextFieldElement>) => void` | | | | inputProps | `DataProps` | | Extra data attributes for the input element | | ref | `Ref<HTMLDivElement>` | | | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ## Accessibility The label, description and error are linked to the input, so screen readers announce them together. An error sets `aria-invalid` on the input. ### TimeField (https://v2.alineacms.com/docs/components/time-field) Types a time of day segment by segment. Example: TimeField ```tsx import {TimeField} from 'alinea/components' export function TimeFieldExample() { return ( <TimeField label="Opening time" defaultValue="09:30" hourCycle={24} style={{width: 180}} /> ) } ``` ## Props `TimeField` | Prop | Type | Default | Description | | --- | --- | --- | --- | | locale | `string; /** The time, `HH:mm` or `HH:mm:ss` */ value?: string \| null; defaultValue?: string \| null; /** Receives `HH:mm`, or `HH:mm:ss` when granularity is `second` */ onValueChange?: (value: string \| null) => void; /** The earliest valid time, `HH:mm` or `HH:mm:ss` */ min?: string; /** The latest valid time, `HH:mm` or `HH:mm:ss` */ max?: string; /** The smallest unit that can be edited, defaults to `minute` */ granularity?: 'minute' \| 'second'; /** Defaults to the locale's hour cycle */ hourCycle?: 12 \| 24` | | BCP 47 locale used to format dates, eg. `en-GB`, defaults to the user's locale | | name | `string` | | | | autoFocus | `boolean` | | | | label | `ReactNode` | | | | description | `ReactNode` | | | | error | `ReactNode` | | | | required | `boolean` | | | | disabled | `boolean` | | | | readOnly | `boolean` | | | | icon | `IconType \| ReactElement` | | | | shared | `boolean` | | Marks the field as shared between translations | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Timestamp (https://v2.alineacms.com/docs/components/timestamp) A date shown relative to now, with the full date on hover. Example: Timestamp ```tsx import { DataList, DataListItem, DataListLabel, DataListValue, Timestamp } from 'alinea/components' export function TimestampExample() { return ( <DataList aria-label="Linen shirt" style={{width: 320}}> <DataListItem> <DataListLabel>Published</DataListLabel> <DataListValue> <Timestamp date="2026-09-14T09:30:00Z" format="date" /> </DataListValue> </DataListItem> <DataListItem> <DataListLabel>Last edited</DataListLabel> <DataListValue> <Timestamp date="2026-09-22T16:05:00Z" format="relative" /> </DataListValue> </DataListItem> </DataList> ) } ``` ## Props `Timestamp` A date or time formatted for the reader's locale, rendered as `<time>` | Prop | Type | Default | Description | | --- | --- | --- | --- | | date (required) | `string \| number \| Date` | | A Date, milliseconds since the epoch or an ISO string | | format | `TimestampFormat` | `'datetime'` | `relative` shows eg. "5 min. ago" for the last week and the date after, defaults to `datetime` | | locale | `string` | | Formats in this locale instead of the surrounding one | | title | `string` | | Tooltip text, defaults to the full date and time for shorter formats | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Toggle (https://v2.alineacms.com/docs/components/toggle) A button that stays pressed until it is pressed again. Example: Toggle ```tsx import {Toggle} from 'alinea/components' import {IcRoundVisibility} from 'alinea/dashboard/icons' export function ToggleExample() { return ( <> <Toggle defaultPressed icon={IcRoundVisibility}> Preview </Toggle> <Toggle variant="outline">Show drafts</Toggle> </> ) } ``` ## Props `Toggle` A two-state button that can be either on or off | Prop | Type | Default | Description | | --- | --- | --- | --- | | pressed | `boolean` | | | | defaultPressed | `boolean` | | | | onPressedChange | `(pressed: boolean) => void` | | | | variant | `'default' \| 'outline'` | | | | size | `'default' \| 'sm' \| 'lg'` | | | | icon | `IconType \| ReactElement` | | | | disabled | `boolean` | | | | autoFocus | `boolean` | | | | ref | `Ref<HTMLButtonElement>` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### ToggleGroup (https://v2.alineacms.com/docs/components/toggle-group) A row of toggles where one or several can be pressed. Example: ToggleGroup ```tsx import {ToggleGroup, ToggleGroupItem} from 'alinea/components' import {IcOutlineGridView, IcOutlineTableRows} from 'alinea/dashboard/icons' export function ToggleGroupExample() { return ( <ToggleGroup type="single" defaultValue="cards" aria-label="Layout"> <ToggleGroupItem value="cards" icon={IcOutlineGridView}> Cards </ToggleGroupItem> <ToggleGroupItem value="table" icon={IcOutlineTableRows}> Table </ToggleGroupItem> </ToggleGroup> ) } ``` ## Props `ToggleGroup` A set of two-state buttons that can be toggled on or off | Prop | Type | Default | Description | | --- | --- | --- | --- | | type (required) | `'single' \| 'multiple'` | | | | value | `string \| Array<string>` | | The pressed item, an empty string when none is pressed | | defaultValue | `string \| Array<string>` | | | | onValueChange | `((value: string) => void) \| ((value: Array<string>) => void)` | | | | variant | `'default' \| 'outline'` | `'default'` | | | size | `'default' \| 'sm' \| 'lg'` | `'default'` | | | disabled | `boolean` | | | | orientation | `Orientation` | `'horizontal'` | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `ToggleGroupItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value (required) | `string` | | | | icon | `IconType \| ReactElement` | | | | disabled | `boolean` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. ### Toolbar (https://v2.alineacms.com/docs/components/toolbar) Groups buttons and toggles, like a text editor toolbar. Example: Toolbar ```tsx import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSeparator, ToolbarToggleGroup, ToolbarToggleItem } from 'alinea/components' import { IcRoundFormatBold, IcRoundFormatItalic, IcRoundLink, IcRoundRedo, IcRoundUndo } from 'alinea/dashboard/icons' export function ToolbarExample() { return ( <Toolbar aria-label="Text formatting"> <ToolbarGroup> <ToolbarButton size="icon" icon={IcRoundUndo} aria-label="Undo" /> <ToolbarButton size="icon" icon={IcRoundRedo} aria-label="Redo" /> </ToolbarGroup> <ToolbarSeparator /> <ToolbarToggleGroup type="multiple" defaultValue={['bold']} aria-label="Marks" > <ToolbarToggleItem value="bold" icon={IcRoundFormatBold} aria-label="Bold" /> <ToolbarToggleItem value="italic" icon={IcRoundFormatItalic} aria-label="Italic" /> </ToolbarToggleGroup> <ToolbarSeparator /> <ToolbarButton size="icon" icon={IcRoundLink} aria-label="Link" /> </Toolbar> ) } ``` ## Props `Toolbar` A container for a set of controls, navigable with the arrow keys | Prop | Type | Default | Description | | --- | --- | --- | --- | | orientation | `Orientation` | `'horizontal'` | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `ToolbarGroup` Groups related controls within a toolbar | Prop | Type | Default | Description | | --- | --- | --- | --- | | children | `ReactNode` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `ToolbarButton` A button within a toolbar, a ghost Button by default Accepts every prop of `Button`. `ToolbarToggleGroup` | Prop | Type | Default | Description | | --- | --- | --- | --- | | type (required) | `'single' \| 'multiple'` | | | | value | `string \| Array<string>` | | The pressed item, an empty string when none is pressed | | defaultValue | `string \| Array<string>` | | | | onValueChange | `((value: string) => void) \| ((value: Array<string>) => void)` | | | | variant | `'default' \| 'outline'` | | | | size | `'default' \| 'sm' \| 'lg'` | | | | disabled | `boolean` | | | | orientation | `Orientation` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `ToolbarToggleItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | value (required) | `string` | | | | icon | `IconType \| ReactElement` | | | | disabled | `boolean` | | | Also accepts `className` and `style`, `id` and `aria-*` labelling attributes, `data-*` attributes. `ToolbarSeparator` Accepts `className` and `style`. ### Tooltip (https://v2.alineacms.com/docs/components/tooltip) A short description that appears when its trigger is hovered or focused. Use it to name icon-only buttons, not for information editors need to do their work. Example: Tooltip ```tsx import {Tooltip, TooltipContent, TooltipTrigger} from 'alinea/components' import {IcRoundOpenInNew} from 'alinea/dashboard/icons' export function TooltipExample() { return ( <Tooltip> <TooltipTrigger variant="outline" size="icon" icon={IcRoundOpenInNew} aria-label="Open preview" /> <TooltipContent>Open preview in a new tab</TooltipContent> </Tooltip> ) } ``` ## Placement `side` sets where the tooltip opens, it flips to the other side when there is no room. Example: Tooltip: sides ```tsx import {Tooltip, TooltipContent, TooltipTrigger} from 'alinea/components' const sides = ['top', 'right', 'bottom', 'left'] as const export function TooltipSidesExample() { return ( <> {sides.map(side => ( <Tooltip key={side} delayDuration={0}> <TooltipTrigger variant="outline">{side}</TooltipTrigger> <TooltipContent side={side}>Shown on the {side}</TooltipContent> </Tooltip> ))} </> ) } ``` ## Props `Tooltip` Shows a short description when the trigger is hovered or focused | Prop | Type | Default | Description | | --- | --- | --- | --- | | delayDuration | `number` | | Milliseconds the pointer rests on the trigger before the tooltip opens | | closeDelay | `number` | | Milliseconds before the tooltip closes once the pointer leaves | | disabled | `boolean` | | | | open | `boolean` | | | | defaultOpen | `boolean` | | | | onOpenChange | `(open: boolean) => void` | | | `TooltipTrigger` The element the tooltip describes. By default this is a Button, with `asChild` the focusable child is used instead. Accepts every prop of `Button`. `TooltipContent` | Prop | Type | Default | Description | | --- | --- | --- | --- | | id | `string` | | | | side | `Side` | `'top'` | | | align | `Align` | | | | sideOffset | `number` | `12` | | | alignOffset | `number` | | | Also accepts `className` and `style`, `data-*` attributes. ## Accessibility The tooltip describes its trigger with `aria-describedby`. It opens on keyboard focus as well as on hover and closes with Escape. An icon-only trigger still needs an `aria-label`. ### Tree (https://v2.alineacms.com/docs/components/tree) Nested items that expand and collapse. Example: Tree ```tsx import {Badge, Tree, TreeItem} from 'alinea/components' import {LucideFile, LucideFolder} from 'alinea/dashboard/icons' export function TreeExample() { return ( <Tree aria-label="Pages" defaultExpandedKeys={['products']} selectionMode="single" defaultSelectedKeys={new Set(['shirt'])} style={{width: 280}} > <TreeItem id="home" title="Home" icon={LucideFile} /> <TreeItem id="products" title="Products" icon={LucideFolder}> <TreeItem id="shirt" title="Linen shirt" icon={LucideFile} /> <TreeItem id="chair" title="Oak dining chair" icon={LucideFile} suffix={ <Badge size="sm" status="draft"> Draft </Badge> } /> </TreeItem> <TreeItem id="journal" title="Journal" icon={LucideFolder} /> </Tree> ) } ``` ## Props `Tree` | Prop | Type | Default | Description | | --- | --- | --- | --- | | items | `Iterable<T>` | | Items to render with the `children` function | | children (required) | `ReactNode \| ((item: T) => ReactNode)` | | | | expandedKeys | `Iterable<Key>` | | | | defaultExpandedKeys | `Iterable<Key>` | | | | onExpandedChange | `(keys: Set<Key>) => void` | | | | onAction | `(key: Key) => void` | | Called when an item is activated with a click or Enter. With single selection a click selects the item and a double click or Enter activates it, with multiple selection a click toggles the item once items are selected. | | renderEmptyState | `() => ReactNode` | | | | virtualized | `boolean` | | Only render the rows in view, every row must be `rowHeight` tall | | rowHeight | `number` | `34` | Row height in pixels when virtualized, defaults to 34 | | ref | `Ref<HTMLDivElement>` | | | `TreeItem` | Prop | Type | Default | Description | | --- | --- | --- | --- | | id | `Key` | | | | title (required) | `string` | | Plain text title, also used for typeahead | | label | `ReactNode` | | Rendered instead of the title | | icon | `IconType \| ReactElement` | | | | href | `string` | | Renders the icon and title as links, pressing the row still selects | | suffix | `ReactNode` | | Trailing content such as status icons | | hasChildItems | `boolean` | | Shows the expand button, set when children load lazily | | hideDragHandle | `boolean` | | Hides the drag handle of this item. Its row can still be dragged with a pointer, reject drops of the item with `canDrop`. | | items | `Iterable<T>` | | Child items to render with the `children` function | ### Custom fields (https://v2.alineacms.com/docs/custom-fields) When the built-in [fields](https://v2.alineacms.com/docs/fields) don't fit your content, create your own. A custom field has two parts: a constructor function that you call in your schema, and a React component that renders the field in the dashboard. ## Create a field The constructor describes the value the field stores and its options, and points to the component that renders it: File: fields/Range.ts ```tsx import {Field} from 'alinea' export type RangeField = Field.Create< number, {min?: number; max?: number; help?: string} > export function range( label: string, options: Field.Options<RangeField> = {} ): RangeField { return Field.create({ label, options, // The module that renders the field, its default export is used view: '@/fields/Range.view' }) } ``` `Field.Create<Value, Options>` is the type of a field that stores `Value` (anything JSON can hold) and accepts your `Options` next to the standard ones: `initialValue`, `required`, `readOnly`, `hidden`, `shared` and `validate`. Queries return the stored value as it is. `Field.create` takes: - `label`: the label of the field. - `options`: the options passed to your constructor. - `view`: the component that renders the field, see [Point to the view](#point-to-the-view). - `defaultValue`: a function returning the value of new entries, when the field has no `initialValue`. - `searchableText`: a function returning the text of a value to include in the dashboard search. ## Render the field The view receives the field as its `field` prop. Hooks from `alinea/cms` read and write its value, and `FieldChrome` renders the label, help text, shared badge and validation error around your control, like the built-in fields do: File: fields/Range.view.tsx ```tsx import { FieldChrome, type FieldViewProps, useField, useFieldOptions } from 'alinea/cms' import {useId} from 'react' import type {RangeField} from './Range' export default function RangeView({field}: FieldViewProps<RangeField>) { const [value, setValue] = useField(field) const {min = 0, max = 10, readOnly} = useFieldOptions(field) const id = useId() return ( <FieldChrome field={field} htmlFor={id}> <input id={id} type="range" min={min} max={max} value={value ?? min} disabled={readOnly} onChange={event => setValue(Number(event.target.value))} /> </FieldChrome> ) } ``` `FieldChrome` takes the `label`, the `help` option, `required`, `readOnly` and `shared` from the field's options and the message from its validation. Pass any of these as a prop to override it. Use the field in a type like any other: File: schema/Product.ts ```tsx import {Config} from 'alinea' import {range} from '@/fields/Range' export const Product = Config.document('Product', { fields: { rating: range('Rating', {min: 1, max: 5, help: 'From 1 to 5'}) } }) ``` `useFieldOptions` returns the options with the dashboard's state applied: `readOnly` is also `true` when the user's [role](https://v2.alineacms.com/docs/roles-permissions) can't edit the field or the entry is not editable, so respect it in your view. ## Point to the view `view` is usually a module path, resolved from your project root with the path aliases of your `tsconfig.json`. The default export is used, append `#Name` for a named export: `'@/fields/Priority.view#PriorityView'`. A path keeps dashboard code out of your site: your schema is imported by your pages and server components, and the view is only loaded by the dashboard. You can pass a component instead (`view: RangeView`), but then the component and the `alinea/cms` hooks it imports end up everywhere your schema is imported, including server components where they can't run. Use a path unless the field is only used in scripts. ## Build views with the dashboard components `alinea/cms` holds what is tied to the CMS: the hooks, `FieldChrome` and the view prop types. The generic building blocks are in [`alinea/components`](https://v2.alineacms.com/docs/components). Its form controls take the same `label`, `description`, `error`, `required`, `readOnly` and `shared` props and render their own chrome, so a field built on one of them looks native: File: fields/Priority.view.tsx ```tsx import { type FieldViewProps, useField, useFieldError, useFieldOptions } from 'alinea/cms' import {Select, SelectItem} from 'alinea/components' import type {Priority, PriorityField} from './Priority' export function PriorityView({field}: FieldViewProps<PriorityField>) { const [value, setValue] = useField(field) const options = useFieldOptions(field) const error = useFieldError(field) return ( <Select label={options.label} value={value} onValueChange={next => setValue((next ?? 'normal') as Priority)} error={error} required={options.required} readOnly={options.readOnly} shared={options.shared} > <SelectItem value="low">Low</SelectItem> <SelectItem value="normal">Normal</SelectItem> <SelectItem value="high">High</SelectItem> </Select> ) } ``` File: fields/Priority.ts ```tsx import {Field} from 'alinea' export type Priority = 'low' | 'normal' | 'high' export type PriorityField = Field.Create<Priority> export function priority( label: string, options: Field.Options<PriorityField> = {} ): PriorityField { return Field.create({ label, options, defaultValue: (): Priority => 'normal', view: '@/fields/Priority.view#PriorityView' }) } ``` ## The alinea/cms API Everything in `alinea/cms` works in field views and in the other components the dashboard renders: `Field.view` sections and custom type and root views. Field hooks, for the field passed to a view or another field of the same entry, list row or object: - `useField(field)`: the stored value and a setter, like `useState`. The setter also accepts an updater function. - `useFieldValue(field)`, `useFieldSetter(field)`: only the value, or only the setter. - `useFieldOptions(field)`: the field's options, including `label` and the resolved `readOnly` and `hidden`. - `useFieldError(field)`: the validation message from `required` or `validate`, if any. - `useFieldKey(field)`: the key the field is stored under, such as `'title'`. - `useSiblingFieldValue(key)`: the value of another field in the same entry, list row or object, by its key. Entry and dashboard hooks: - `useEntry()`: the entry being edited, with its `id`, `title`, `url`, `workspace`, `root`, `locale`, `status` and the stored `data`. `null` outside an entry. - `useLocale()`: the locale being edited, or `null` for content without translations. - `useGraph()`: query content from the dashboard with the same API as `cms`, for example to list entries in a picker. - `useUser()`, `usePolicy()`: the signed-in user and their [permissions](https://v2.alineacms.com/docs/roles-permissions). - `useNavigate()`: returns a function that opens an entry or root in the dashboard: `navigate({workspace, root, entryId})`. The editor is asked to confirm first when there are unsaved changes. - `usePreviewMetadata()`: the title, description and other metadata the live preview reports for the current page. Components and types: - `FieldChrome`: the label, help text and error of a field around your own control. - `EditField`, `EditFields`: render a field, or a record of fields, with the view it is configured with, for example to lay out existing fields in a `Field.view` section. - `EntryTable`: a table of entries with the columns of an [overview](https://v2.alineacms.com/docs/overviews#entry-tables-in-your-own-views), for example to list related entries in a `Field.view` section. - `FieldViewProps<F>`, `SectionViewProps`, `TypeViewProps`, `RootViewProps`: the props the dashboard passes to a field view, a `Field.view` component, a type's `view` and a root's `view`. - `OverviewCellProps`, `OverviewActionProps`, `OverviewEntry`: the props of an overview column's `view` and of an overview action, and the entry they receive. ## Show a component between fields `Field.view` adds a component to a type that doesn't store a value, for example to show instructions or information derived from the entry. Spread it into `fields`: File: schema/Product.ts ```tsx import {Config, Field} from 'alinea' export const Product = Config.document('Product', { fields: { title: Field.text('Title'), ...Field.view('@/fields/ProductUrls.view#ProductUrls') } }) ``` File: fields/ProductUrls.view.tsx ```tsx import {useEntry} from 'alinea/cms' import {Field, Link} from 'alinea/components' export function ProductUrls() { const entry = useEntry() if (!entry) return null return ( <Field label="Public URL" description="Where this product is published"> <Link href={entry.url} target="_blank"> {entry.url} </Link> </Field> ) } ``` ## Upgrading from Alinea 1.x Field views from 1.x keep working, with deprecated APIs: - `useField` from `alinea/dashboard` returns `{value, mutator, options, label, error}`. Replace it with the tuple `useField` from `alinea/cms` and the separate `useFieldOptions` and `useFieldError`. `useFieldValue`, `useFieldOptions`, `useFieldError` and `useFieldKey` also move to `alinea/cms`, and `useFieldMutator` becomes `useFieldSetter`. - `InputLabel` from `alinea/dashboard` is now the `Field` component. It no longer shows `help` from `{...options}`: use `FieldChrome` from `alinea/cms`, which reads it from the field, or pass the help text to `Field` from `alinea/components` as `description`. - `useLocale`, `useGraph` and `useEntryEditor` from `alinea/dashboard/hook/*` become `useLocale`, `useGraph` and `useEntry` from `alinea/cms`. - Hooks imported from `alinea/dashboard/hooks` still work, but that module is internal: import them from `alinea/cms`. - Components from `alinea/ui` were removed. Use [`alinea/components`](https://v2.alineacms.com/docs/components). See [Upgrading from 1.x](https://v2.alineacms.com/docs/upgrading) for the other dashboard changes. ### Overviews (https://v2.alineacms.com/docs/overviews) When an entry has children, or a root holds entries, the dashboard lists them in an overview: a table, or cards, with the title of each entry and, when they tell the entries apart, its type, status and who edited it last. The `overview` option of a container [type](https://v2.alineacms.com/docs/schema/type) or a [root](https://v2.alineacms.com/docs/workspaces/root) adds your own columns, a default order, a card image and toolbar actions. Image: The products overview of the Oak & Loom demo, with its own columns for price, material and stock, sorted by price. (https://v2.alineacms.com/admin/file/screenshots/dashboard-overview.webp) File: schema/Blog.ts ```tsx import {Config, Field, Query} from 'alinea' export const BlogPost = Config.document('Blog post', { fields: { cover: Field.image('Cover'), category: Field.entry('Category', {condition: {_type: 'Category'}}), publishDate: Field.date('Publish date') } }) export const Blog = Config.document('Blog', { contains: ['BlogPost'], fields: {}, overview: { columns: { cover: Config.column({ header: 'Cover', width: 72, select: BlogPost.cover }), category: Config.column({ header: 'Category', select: BlogPost.category, sortBy: BlogPost.category.first({select: Query.title}) }), publishDate: Config.column({ header: 'Published', width: 140, select: BlogPost.publishDate }) }, sort: {desc: BlogPost.publishDate}, layout: 'cards', thumbnail: BlogPost.cover } }) ``` The overview of a type applies to the children of every entry of that type, here the posts of each `Blog`. On a root it applies to the entries at the top level of the root: File: cms.ts ```tsx import {Config} from 'alinea' import {productsOverview} from '@/schema/Product' export const products = Config.root('Products', { contains: ['Product'], overview: productsOverview }) ``` ## Options - `columns`: the columns shown after the title and the built-in columns, keyed by name. Create them with `Config.column`, see [Columns](#columns). A column with `position: 'start'` goes before the built-in columns. - `builtins`: show or hide the [built-in columns](#built-in-columns), for example `{updated: false}`. - `sort`: the default order of the children, in the overview and in the sidebar tree. See [Sorting](#sorting). - `sorts`: the orders editors can pick in the filter and sort menu, keyed by name. See [Sort options](#sort-options). - `filters`: filters editors can apply in the filter and sort menu, keyed by name. See [Filters](#filters). - `layout`: `'table'` or `'cards'`, the layout the overview opens in. Editors can still switch. - `thumbnail`: the image shown on cards: an image field, or one per type (`{BlogPost: BlogPost.cover, Event: Event.poster}`). Defaults to the first image found in the fields of the entry. - `actions`: components rendered in the toolbar of the overview, such as an export button. See [Actions](#actions). ## Columns `Config.column(options)` takes: - `header` (required): the column header. - `select`: what the column shows. Anything a [query](https://v2.alineacms.com/docs/query) can select: a field (`Product.price`), an expression (`Query.url`), a query of linked entries, or an object of those. - `format`: a function that turns the selected value into text: `(value, {locale}) => string`. - `view`: a React component that renders the cell, see [Custom cells](#custom-cells). - `sortBy`, `sortable`: how the column sorts, see [Sorting](#sorting). - `width`: a width in pixels (`120`) or a fraction of the remaining space (`'2fr'`). The default is `'1fr'`. - `minWidth`: the minimum width in pixels of a fractional column, 120 by default. - `align`: `'start'`, `'end'` or `'center'`. Use `'end'` for numbers and amounts. - `collapsible`: hide the column on narrow screens. `true` by default, set it to `false` for columns that should always be visible. - `position`: `'start'` places the column right after the title, before the [built-in columns](#built-in-columns), for example a thumbnail or an article number. `'end'`, the default, places it after them. Columns keep the order you define them in within each position. The value passed to `format` and `view` is what a `cms.find` with the same `select` returns. Without `format` or `view`, a column that selects a field renders its value like the dashboard does elsewhere: an image as a thumbnail, a link to an entry as that entry's title. Linked entries open when clicked, and so do selected values with an `entryId` or `_entry` (or an `id` and a `title`). A catalogue of products, with linked categories and brands and a price per locale: File: schema/Product.ts ```tsx import {Config, Field, type OverviewOptions, Query} from 'alinea' export const Product = Config.document('Product', { fields: { articleNumber: Field.text('Article number'), categories: Field.entry.multiple('Categories', { condition: {_type: 'Category'} }), brand: Field.entry('Brand', {condition: {_type: 'Brand'}}), price: Field.number('Price'), stock: Field.number('Stock') } }) export const productsOverview: OverviewOptions = { columns: { articleNumber: Config.column({ header: 'Article number', width: 140, select: Product.articleNumber, position: 'start' }), categories: Config.column({ header: 'Categories', select: Product.categories.find({ select: {entryId: Query.id, title: Query.title}, filter: {_status: 'published'}, orderBy: {asc: Query.title} }) }), brand: Config.column({ header: 'Brand', select: Product.brand, sortBy: Product.brand.first({select: Query.title}) }), price: Config.column({ header: 'Price', width: 120, align: 'end', select: Product.price, format: (price, {locale}) => typeof price === 'number' ? new Intl.NumberFormat(locale ?? 'en', { style: 'currency', currency: 'EUR' }).format(price) : '' }), stock: Config.column({ header: 'Stock', width: 120, select: Product.stock, view: '@/views/StockCell#StockCell' }) }, builtins: {updated: false}, actions: ['@/views/ExportProducts#ExportProducts'] } ``` - `articleNumber` comes right after the title, before the built-in columns, because of `position: 'start'`. - `categories` selects the published categories of each product, sorted by title. They are rendered as links because they select an `entryId` and a `title`. - `brand` selects the entry field, so it shows the title of the brand. Linked entries don't sort by themselves: `sortBy` sorts the column by the title of the brand. - `price` is formatted as an amount in euro, in the locale of the listed entry. ### Custom cells `view` renders the cell with your own component. It receives `OverviewCellProps`: the selected `value`, the `entry` of the row (with its `id`, `type`, `title`, `url`, `status`, `locale`, `workspace`, `root` and `parentId`), the `column` key and the `locale`. Build it with [`alinea/components`](https://v2.alineacms.com/docs/components): File: views/StockCell.tsx ```tsx import type {OverviewCellProps} from 'alinea/cms' import {Badge} from 'alinea/components' export function StockCell({value}: OverviewCellProps<number | null>) { if (typeof value !== 'number') return null return <Badge>{value > 0 ? `${value} in stock` : 'Sold out'}</Badge> } ``` Like other views, `view` takes a component or a path to one. Use a path to keep dashboard code out of your site, see [Point to the view](https://v2.alineacms.com/docs/custom-fields#point-to-the-view). `format` is a plain function, so it can live in your schema. ## Built-in columns The title is always the first column. The built-in columns follow, then the columns you define. Columns with `position: 'start'` go between the title and the built-in columns. A built-in column only shows when it tells the listed entries apart. The dashboard decides this from all children of the parent, not only those on screen, so the columns don't change while scrolling or sorting: - `type`: the type of the entry. Shown when the children have more than one type, and in search results and filtered lists. - `status`: the publication status. Shown when the children differ in status, for example when some are drafts. A list of published entries has no status column. - `updated` and `author`: when the entry was last edited and by whom, read from the audit fields of the metadata field (under the key `metadata`, which `Config.document` and `Field.metadata()` add). Shown when at least one child stores that audit data, so lists of entries created before audit data was recorded don't show empty columns. The columns follow the types of the children that are there. A parent without `contains` accepts any type, but its overview only shows the columns of the types it holds. Force them on or off with `builtins`, for example `{status: true}` to always show the status, or `{type: false, updated: false, author: false}` to never show those. A column keyed `type`, `status`, `updated` or `author` replaces that built-in column in place, for example to show the writer of an article instead of the last editor. The `title` key is reserved. ## Sorting Editors sort an overview by clicking a column header, or from the filter and sort menu. The title, the built-in columns, expressions of the entry (such as `Query.title`) and fields that hold a single value (text, number, date, select, check, path, ...) are sortable. Columns that select links, lists, multiple selects, rich text or JSON need `sortBy`: an expression, or a query of a linked entry that selects a single expression, such as `Product.brand.first({select: Query.title})`. Set `sortable: false` to turn sorting off for a column. Sorting runs in the database query, and entries without a value come last. It only changes what the editor sees, never the stored order of the entries. While a list is sorted, "Sorted by Price · Reset" shows in the toolbar and rows can't be reordered; Reset goes back to the default order. The sort is kept in the url (`?sort=price`, or `?sort=-price` for descending), so it survives a reload and can be shared, and it is restored when the editor comes back to the overview in the same session. Search results stay ordered by relevance. ### Default order `sort` sets the order of the children in the overview and in the sidebar tree. It takes an [`orderBy`](https://v2.alineacms.com/docs/query/structural) or an array of them, such as `[{desc: Event.date}, {asc: Query.title}]`. Children of a parent with a `sort` can't be reordered by hand. `find_entries` of the [MCP server](https://v2.alineacms.com/docs/mcp) returns them in the same order. Without `sort`, children keep the order editors give them: drag rows in the table or cards, or entries in the sidebar tree, to reorder them. Like `insertOrder`, `sort` only affects the dashboard: queries return children in their stored order unless you pass an `orderBy`. `sort` replaces `orderChildrenBy`, which still works but is deprecated. ### Sort options The filter and sort menu lists the title and the sortable columns. Declare `sorts` to list your own orders instead. Each takes a `label`, `by`: a value like a column's `sortBy` or an array of them, where later values order the entries that share the earlier ones, and `direction`: `'asc'` (the default) or `'desc'`. Picking an option again reverses it. File: schema/Events.ts ```tsx import {Config, Query} from 'alinea' import {Event} from './Event' export const Events = Config.document('Events', { contains: ['Event'], overview: { sort: {asc: Event.date}, sorts: { date: {label: 'Date', by: Event.date}, title: {label: 'Title', by: Query.title}, venue: {label: 'Venue', by: [Event.city, Event.venue]} } }, fields: {} }) ``` The key of an option is kept in the url (`?sort=venue`). An option keyed like a column also orders that column when its header is clicked, other columns stay sortable by their header. ## Filters `filters` adds filters to the filter and sort menu. Each has a `label` and `options`, keyed by name, with a `label` and a `filter`: a query [filter](https://v2.alineacms.com/docs/query/filtering) on the fields of the listed entries. Editors pick one option of a filter, or several with `multiple: true`: entries then match any of them. Entries match every filter that is applied, and the search terms. Rows can't be reordered while a filter applies. File: schema/Products.ts ```tsx import {Config} from 'alinea' export const Products = Config.document('Products', { contains: ['Product'], overview: { filters: { availability: { label: 'Availability', options: { inStock: {label: 'In stock', filter: {stock: {gt: 0}}}, soldOut: {label: 'Sold out', filter: {stock: 0}} } }, price: { label: 'Price', multiple: true, options: { low: {label: 'Under €50', filter: {price: {lt: 50}}}, mid: {label: '€50 to €200', filter: {price: {gte: 50, lt: 200}}}, high: {label: '€200 and up', filter: {price: {gte: 200}}} } } } }, fields: {} }) ``` ## Mixed lists When a parent contains several types, a column can select a different value per type. Key `select` and `sortBy` by the type names of your schema: File: schema/News.ts ```tsx import {Config, Field, Query} from 'alinea' export const Article = Config.document('Article', { fields: { author: Field.entry('Author', {condition: {_type: 'Person'}}), publishDate: Field.date('Publish date') } }) export const Event = Config.document('Event', { fields: { organiser: Field.entry('Organiser', {condition: {_type: 'Person'}}), startDate: Field.date('Start date') } }) export const News = Config.document('News', { contains: ['Article', 'Event'], fields: {}, overview: { columns: { person: Config.column({ header: 'Author or organiser', select: {Article: Article.author, Event: Event.organiser}, sortBy: { Article: Article.author.first({select: Query.title}), Event: Event.organiser.first({select: Query.title}) } }), date: Config.column({ header: 'Date', width: 140, select: {Article: Article.publishDate, Event: Event.startDate} }) } } }) ``` Entries of a type without a value in the column show "–" and come last when sorted. ## Actions `actions` renders components in the toolbar of the overview. They receive `OverviewActionProps`: the `workspace`, `root` and `parentId` of the list, the `locale`, the `search` terms, the `sort` the editor picked and a `query` for the listed entries in their current order, without paging. Pass the query to `useGraph().find` with your own `select`, for example to export the products above as CSV: File: views/ExportProducts.tsx ```tsx import {Query} from 'alinea' import {type OverviewActionProps, useGraph} from 'alinea/cms' import {Button} from 'alinea/components' import {Product} from '@/schema/Product' export function ExportProducts({query}: OverviewActionProps) { const graph = useGraph() async function exportCsv() { const rows = await graph.find({ ...query, select: { title: Query.title, articleNumber: Product.articleNumber, price: Product.price } }) const csv = rows .map(row => [row.title, row.articleNumber, row.price].join(';')) .join('\n') const link = document.createElement('a') link.href = URL.createObjectURL(new Blob([csv], {type: 'text/csv'})) link.download = 'products.csv' link.click() } return ( <Button variant="outline" onClick={exportCsv}> Export </Button> ) } ``` ## Entry tables in your own views `EntryTable` from `alinea/cms` renders a table of entries with the columns of an overview: the headers sort and a row opens its entry. Use it in a `Field.view` section, or any other custom view, to show related entries. Here a brand lists its products: File: schema/Brand.ts ```tsx import {Config, Field} from 'alinea' export const Brand = Config.document('Brand', { fields: { logo: Field.image('Logo'), ...Field.view('@/views/BrandProducts#BrandProducts') } }) ``` File: views/BrandProducts.tsx ```tsx import {EntryTable, useEntry} from 'alinea/cms' import {Field} from 'alinea/components' import {Product, productsOverview} from '@/schema/Product' export function BrandProducts() { const entry = useEntry() if (!entry) return null return ( <Field label="Products"> <EntryTable aria-label="Products" type={Product} overview={productsOverview} filter={{brand: {has: {_entry: entry.id}}}} emptyMessage="No products for this brand yet" /> </Field> ) } ``` `EntryTable` takes: - `type`: list entries of this type, or of an array of types. - `filter`: a query [filter](https://v2.alineacms.com/docs/query/filtering), such as `{brand: {has: {_entry: entry.id}}}`. - `workspace`, `root`, `parentId`: only list entries in this workspace or root, or the children of this entry (`null` for the top level). - `overview`: the columns and default order to use: an overview object, or the type or root that configures one. Defaults to the overview of the root or type that contains `type`. - `columns`: show these columns instead of those of the overview. - `sort`: the default order. Defaults to the `sort` of the overview, then the title. - `locale`: the locale of the entries, defaults to the locale selected in the dashboard. - `limit`: the maximum number of rows. - `emptyMessage`: shown when no entries match. - `aria-label`, `className`, `style`. Tables with the same query share their data, so showing the same table twice doesn't load it twice. ## Media library The [media](https://v2.alineacms.com/docs/workspaces/media) root and its folders list a preview, the dimensions ("1200 × 800 px"), the file size and the file type of each file, without the status, type, updated and author columns. Pass an `overview` to `Config.media({overview})` to replace these columns. Its menu sorts by title, size, dimensions and file type, and filters on files or folders and on the kind of file: images, PDF, documents, spreadsheets, presentations, archives, video and audio. Folders stay listed while a file type is picked, so editors can still open them. ## Custom dashboard pages To add a page of your own to the dashboard, such as a report or a table of data from another system, create a root with a `view`. The root shows up with the other roots of the workspace, and its view renders as a full page: File: cms.ts ```tsx import {Config} from 'alinea' import {IcReport} from '@/icons' export const reports = Config.root('Reports', { icon: IcReport, view: '@/views/Reports#Reports' }) ``` File: views/Reports.tsx ```tsx import {EntryTable, type RootViewProps} from 'alinea/cms' import {Heading, Text} from 'alinea/components' import {Product} from '@/schema/Product' export function Reports({root}: RootViewProps) { return ( <> <Heading>{root.label}</Heading> <Text>Products that are out of stock</Text> <EntryTable aria-label="Sold out products" type={Product} filter={{stock: 0}} emptyMessage="Everything is in stock" /> </> ) } ``` The view receives `RootViewProps`: the configuration of the root, including its `label`. It can use everything in [`alinea/components`](https://v2.alineacms.com/docs/components), such as `Table` for rows you load yourself, the hooks of [`alinea/cms`](https://v2.alineacms.com/docs/custom-fields) (`useGraph`, `useNavigate`, `useLocale`, ...) and `EntryTable`. A root without `contains` has an empty sidebar tree. Prefer this over a type with a hidden title and a single `Field.view`: that creates entries in your content only to hold a view. Two other options replace parts of the dashboard: - A type's `view` replaces the editor of entries of that type. It receives `TypeViewProps`: `{type}`. - A type's `defaultView: 'overview'` opens the overview of an entry's children first, instead of its form. ## Upgrading from Alinea 1.x - `summaryRow` and `summaryThumb` are no longer rendered. Configure the columns on the parent with `overview.columns`, and the card image with `overview.thumbnail`. - `orderChildrenBy` still works, but is deprecated: use `overview.sort`. - The `overview: true` field option is deprecated. It's still used when the parent defines no `overview.columns`: the marked fields of the types of the listed children become columns, with the label of the field as header and up to five columns. A field name shared by several child types is one column, and types without it show "–". These columns sort by the same rules as other columns. ### Reference (https://v2.alineacms.com/docs/reference) Working with Alinea should feel intuitive but if you're looking for more in-depth information or feel like something is missing have a look here. ### Configuration (https://v2.alineacms.com/docs/configuration) All configuration lives in `cms.ts`, which exports the `cms` instance your pages query and the handler serves. `alinea init` creates it in the project root, or in `src/` when that folder exists. Alinea also finds it as `cms.tsx`, `cms.js` or `cms.jsx`; pass `--config` to the [CLI](https://v2.alineacms.com/docs/cli) to use another location. File: cms.ts ```tsx import {Config, Field} from 'alinea' import {createCMS} from 'alinea/next' const Page = Config.document('Page', { fields: { body: Field.richText('Body') } }) export const cms = createCMS({ schema: {Page}, workspaces: { main: Config.workspace('Main', { source: 'content', mediaDir: 'public/media', roots: { pages: Config.root('Pages', {contains: ['Page']}), media: Config.media() } }) }, baseUrl: { development: 'http://localhost:3000', production: process.env.NEXT_PUBLIC_SITE_URL ?? 'https://example.com' }, handlerUrl: '/api/cms', adminPath: '/admin', preview: true, enableDrafts: true }) ``` Import `createCMS` from `alinea/next` in a Next.js project. It accepts the options below and returns the `cms` object with the query methods (`find`, `first`, `get`, `count`), `cms.previews` and `cms.workspaces`. ## Required options ### `schema` An object of all your types, keyed by name: `{Page, BlogPost}`. The key is the type name stored in content files and used in `contains` and `_type`, so don't rename it once content exists. See [Schema](https://v2.alineacms.com/docs/schema). ### `workspaces` An object of [workspaces](https://v2.alineacms.com/docs/workspaces), at least one. Keys may only use `a-z`, `A-Z`, `0-9` and `_`. With more than one workspace, each `source` folder must be named after its key and all of them must sit in the same parent folder. ### `handlerUrl` The URL of the API route that serves the dashboard and media files: the route that exports `createHandler` from `alinea/next`. `alinea init` sets it to `/api/cms` and creates `app/(alinea)/api/cms/route.ts`. A relative URL is resolved against `baseUrl`. Without it `alinea dev`, `alinea build` and your site throw `Missing handlerUrl in Alinea config`. ### `baseUrl` The URL of your website, either one string or one per environment: `{development, production}`, picked by `NODE_ENV`. A value without a protocol gets `https://`. - `alinea build` exits with `No baseUrl was set for the production build` when there is no production URL. - In production your site reaches the handler at `handlerUrl` resolved against this URL, to check for new content. That includes `next start` on your machine, which talks to the production URL. Most projects read the production URL from an environment variable, see [Environment variables](#environment-variables) below. ## Dashboard options ### `adminPath` The path the dashboard is served on, defaults to `/admin`. During development `withAlinea` forwards it to the local dashboard of `alinea dev`; `alinea build` writes the dashboard to `public/admin.html` plus a `public/admin/` folder of assets (named after the path), and `withAlinea` serves it from there. Media URLs live below it as well: `/admin/file/...`. It can't be `/`. It replaces the deprecated `dashboardFile` option of 1.x. ### `preview` Shows a preview next to the editor. `true` loads your site in an iframe and updates it while editors type, see [Live previews](https://v2.alineacms.com/docs/live-previews). You can also pass a React component that receives `{entry}` and renders the preview itself. Workspaces, roots and types accept the same `preview` option, the most specific one wins: `preview: false` on a type hides the preview for entries without a page, such as site settings. ### `enableDrafts` Off by default: every save in the dashboard publishes right away. With `enableDrafts: true`: - Editors get a separate Save draft action next to Publish, and new entries start as drafts. - Published entries can be unpublished again. - The published version stays live until the draft is published. - The [MCP server](https://v2.alineacms.com/docs/mcp) accepts `publish: false` to save drafts. ### `roles` Custom roles with permission policies, see [Roles and permissions](https://v2.alineacms.com/docs/roles-permissions). An `admin` role is always added. ### `maxUploadSize` The maximum size of an uploaded file, in bytes, for example `20 * 1024 * 1024` for 20 MB. Larger uploads are refused with an error that names the limit. No limit by default. ### `resizeImages` Scale down JPEG, PNG and WebP uploads larger than `maxWidth` or `maxHeight` pixels before they are stored. Defaults to `{maxWidth: 2560, maxHeight: 2560}`, set it to `false` to store uploads as they are. The dashboard resizes in the browser, so large photos never travel to the server. `quality` sets the JPEG and WebP encoding quality between 0 and 1, `0.85` by default. When you upload with `cms.upload`, pass `transformImage` from `alinea/core/media/TransformImage` next to `createPreview`; it uses `sharp` on the server. The `maxUploadSize` limit applies to the resized image. Images keep their format; animated PNG and WebP images, GIFs and SVGs are stored as they are. ## Content delivery options ### `syncInterval` How your deployed site keeps its content fresh. The site checks the content revision of the repository and syncs right away when it changed; `syncInterval` is the fallback interval in seconds when that revision can't be determined. Defaults to `60`. - `0` syncs on every query. - `Infinity` never syncs: the site only serves the content it was built with, and new content goes live with the next deploy. A single query can override it with `syncInterval`, or skip syncing with `disableSync: true`. See [Instant publishing](https://v2.alineacms.com/docs/instant-publishing). ### `publicDir` The folder your site serves static files from, defaults to `public`. `alinea build` writes the dashboard into it, and media files stored inside it (the workspace `mediaDir`) are served from there. ### `tracer` A function to instrument Alinea operations, for example with OpenTelemetry spans. It is called as `tracer(name, run)` and must return the result of `run()`. Errors thrown by the tracer itself are ignored, the operation still runs. File: cms.ts ```ts import {trace} from '@opentelemetry/api' const tracer = trace.getTracer('alinea') export const cms = createCMS({ // ... tracer(name, run) { return tracer.startActiveSpan(name, async span => { try { return await run() } finally { span.end() } }) } }) ``` ## Good to know ### Environment variables `cms.ts` runs on the server and in the dashboard, which runs in the browser. In the dashboard only variables that start with `NEXT_PUBLIC_` or `PUBLIC_` are defined; other `process.env` values are `undefined` there and log a warning. Use a public variable for anything the config needs, such as the production URL, and keep secrets in the handler route instead. The CLI reads `.env.local`, or `.env` when there is no `.env.local`, from the project folder or the nearest parent folder that has one. Variables already set in the environment win. ### Splitting up the config Nothing requires one big file. Most projects keep each type in its own file and import them into `cms.ts`, for example `schema/Page.ts` or one folder per page type with its view component next to it, as the [tutorial](https://v2.alineacms.com/docs/tutorial) does. The config file itself only has to export `cms` as a named export, a default export is refused with `No export named cms found`. ### Dealing with errors Your config file is compiled and executed by `alinea dev` and `alinea build`. An error thrown while loading it points to the compiled file: ```shellscript Error: Fail at file:///home/alineacms/alinea/node_modules/@alinea/generated/config.js?1706175675574:419:7 at ModuleJob.run (node:internal/modules/esm/module_job:194:25) ``` Alinea compiles your config with a source map. Enable Node's `--enable-source-maps` flag in your scripts to get positions in your own files: ```json { "scripts": { "dev": "NODE_OPTIONS=--enable-source-maps alinea dev -- next dev", "build": "NODE_OPTIONS=--enable-source-maps alinea build -- next build" } } ``` Note (info): If you're developing on Windows you can use [cross-env](https://www.npmjs.com/package/cross-env) to achieve the same. The error now points to the right file: ```shellscript Error: Fail at <anonymous> (/home/alinea/apps/dev/cms.ts:278:7) at ModuleJob.run (node:internal/modules/esm/module_job:194:25) ``` ### CLI (https://v2.alineacms.com/docs/cli) The `alinea` command line tool generates your content cache and runs the local dashboard. Run it through your package manager, for example `npx alinea dev` or `pnpm alinea dev`. ```shellscript Usage $ alinea <command> [options] Available Commands init Initialize a new Alinea project in the current directory dev Start a development dashboard build Generate types and content cache For more info, run any command with the `--help` flag $ alinea init --help $ alinea dev --help Options -v, --version Displays current version -h, --help Displays this message ``` The CLI requires Node.js 24 or higher (or Bun), and React 19 in the project. It loads environment variables from `.env.local`, or from `.env` when there is no `.env.local`, looking in the project directory and then its parents. Variables already set in the environment take precedence. ## Commands ### alinea init Sets up Alinea in the current directory. Run once during setup, see the [Quickstart](https://v2.alineacms.com/docs/quickstart). It fails when a config file already exists. - Creates `cms.ts` (in `src/` when the project has a `src` directory) with an example schema and workspace. Its production `baseUrl` is read from `NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL`, which Vercel sets, and falls back to `http://localhost:3000`. - Creates a first entry in `content/pages/welcome.json` and a `content/media` folder. - Prefixes the `dev` and `build` scripts in `package.json` with `alinea dev --` and `alinea build --`. Scripts that already run alinea are left alone, and the formatting of `package.json` is kept. - In a Next.js project (`next` in `dependencies`), creates the API route `app/(alinea)/api/cms/route.ts` and reminds you to wrap your Next.js config with `withAlinea`. - Adds `/public/admin.html` and `/public/admin/`, where `alinea build` writes the dashboard, to `.gitignore`. - Detects your package manager from the lockfile (`bun.lock` or `bun.lockb`, `pnpm-lock.yaml`, `yarn.lock` or `package-lock.json`, falling back to npm) and prints the command that starts the dashboard, such as `bun alinea dev` or `npx alinea dev`. ### alinea dev Starts the local dashboard, by default on http://localhost:4500, moving to the next free port when that one is taken. It watches your config and content: changes to `cms.ts` are recompiled and the dashboard reloads. It also serves the [MCP server](https://v2.alineacms.com/docs/mcp) for coding agents at `/mcp` on the same port. There is no login during development: you are signed in as the name and email of your git config, and edits are written straight to the JSON files in your content folder, ready to review and commit. Pass another command after `--` to run it alongside the dashboard. It is started once the config is compiled and receives the environment variables `withAlinea` needs to serve the dashboard on your site's `adminPath`: ```shellscript # Start the dashboard and the Next.js development server alinea dev -- next dev ``` ```shellscript Aliases $ alinea serve Options -c, --config Config file location -d, --dir Root directory of the project -p, --port Port to listen on --production Use production backend --dev Watch alinea sources ``` - `--config`: the config file, relative to the project directory. Without it Alinea looks for `cms.ts`, `cms.tsx`, `cms.js` or `cms.jsx` in the project directory and its `src` folder. - `--dir`: the project directory, defaults to the current directory. - `--port`: the port of the local dashboard and MCP server, defaults to 4500. - `--production`: runs the local dashboard with `NODE_ENV=production`. - `--dev` is only used when working on Alinea itself. ### alinea build Indexes your content into the `@alinea/generated` package your site reads its content from, and writes the dashboard to your public folder: `public/admin.html` and a `public/admin/` asset folder for the default `adminPath`. `alinea init` adds both to `.gitignore`. Pass your own build command after `--` and it runs once the content is generated; `alinea build` exits with that command's exit code, so a failing `next build` fails your CI. ```shellscript # Generate content, then build the Next.js site alinea build -- next build ``` ```shellscript Aliases $ alinea generate Options -c, --config Config file location -d, --dir Root directory of the project --fix Any missing or incorrect properties will be overwritten by their default ``` `--config` and `--dir` work as for `alinea dev`. `--fix` rewrites content files whose data doesn't match the schema, filling missing or invalid properties with their defaults, and exits without running the command after `--`. Commit the result. The build stops with an error when the config can't be loaded, when `handlerUrl` is missing or when `baseUrl` has no production URL, see [Configuration](https://v2.alineacms.com/docs/configuration). ## Deploying Make sure your hosting platform runs the `build` script from `package.json`, not `next build` directly. `withAlinea` gets the `adminPath` and `handlerUrl` from the CLI; when Next.js runs without it, it logs `Alinea dashboard settings were not provided; dashboard routing is disabled` and neither the dashboard nor media URLs are served. The same goes for development: run `alinea dev -- next dev`, not `next dev` on its own. See [Deploy](https://v2.alineacms.com/docs/deploy) for setting up a backend.