# 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-product (https://v2.alineacms.com/admin/file/screenshots/dashboard-product.webp)
### 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 && (
Could not load weather for {block.region}.
) : ({weather.location}: {weather.temperature} {weather.unit} ({weather.summary})
)}{page.intro}
}{post.body}
:← Back to the full blog archive
{(previousPost || nextPost) && ( )}{text}
{block.text}
case 'ImageBlock': return{post.intro}
))}Page {page} of {pageCount}
Parsed from HTML.
') .value() const rows = Edit.list(sections) .add('Text', {title: 'The row title', text}) .value() const author = Edit.link(BlogPost.author).addEntry(authorId).value() ``` - `Edit.richText(field)`: `add(type, block)` appends a rich text block, `addHtml(html)` parses HTML into rich text, `value()` returns the result. - `Edit.list(field)`: `add(type, row)` appends a row, `insertAt(index, type, row)` and `removeAt(index)` edit an existing list passed as second argument. - `Edit.link(field)`: `addEntry(id)`, `addImage(id)`, `addFile(id)` or `addUrl({url, title, target})`. `Edit.links(field)` does the same for fields with multiple links. ## Upload files `cms.upload` uploads a file to the media library and returns the new media file entry. Use the id to link the file from an image or file field: ```tsx import {Edit} from 'alinea' import {createPreview} from 'alinea/core/media/CreatePreview' import {readFile} from 'node:fs/promises' const file = new File([await readFile('./photo.jpg')], 'photo.jpg', { type: 'image/jpeg' }) const image = await cms.upload({file, createPreview}) await cms.update({ type: Page, id: pageId, set: {hero: Edit.link(Page.hero).addImage(image._id).value()} }) ``` - `file`: a `File`, or a `[name, bytes]` tuple. - `workspace`, `root`, `parentId`: where to store it, defaults to the media root of the first workspace. Pass the id of a media folder as `parentId` to upload into it. - `createPreview`: pass it for images to store their dimensions, average color, focus point and a small preview. It uses the `sharp` package on the server, which you need to install yourself. - `replaceId`: replace the file of an existing media entry. Uploads count against the `maxUploadSize` of your config. ## Good to know - Every commit is checked against the roles of the user who makes it, see [Roles and permissions](https://v2.alineacms.com/docs/roles-permissions). In development that is the local admin. - Validation from your schema (`required`, `validate`) also runs when you publish from code: `cms.create` and `cms.update` throw when a published version has invalid fields. Drafts (`status: 'draft'`) are not validated. - To normalize content files after bulk edits, run `alinea build --fix`. ### Internationalization (https://v2.alineacms.com/docs/internationalization) Alinea translates content per root: every entry in a translated root has a version per locale, each with its own fields, path and URL. For a few values that should be edited side by side for all languages, use a localised field instead (see [Localised fields](#localised-fields) below). Image: The Dutch translation of a product, with the language menu open. Shared fields hold the same value in every language, all other fields are translated. (https://v2.alineacms.com/admin/file/screenshots/dashboard-localiser.webp) ## Translate a root Add `i18n` with the list of locales to a [Root](https://v2.alineacms.com/docs/workspaces/root). The first locale is the default: new entries start in it. File: cms.ts ```tsx import {Config} from 'alinea' Config.root('Pages', { i18n: { locales: ['en', 'fr', 'nl'] }, contains: ['Page', 'Blog'] }) ``` Every locale gets its own folder, so translations of an entry are separate files that share one entry id: ```text content/pages/en/about.json content/pages/fr/a-propos.json content/pages/nl/over-ons.json ``` Each translation has its own path, so the URLs above are `/en/about`, `/fr/a-propos` and `/nl/over-ons`. By default an entry's URL is the locale followed by the paths of its parents and its own path, and `index` entries are left out: the home page of the French site is `/fr`. Roots without `i18n` hold content that is the same in every language, such as authors or settings. Entries in one workspace can mix both kinds. ## Translate in the dashboard Translated roots show a language menu in the dashboard. When you open an entry in a locale it doesn't exist in yet, the dashboard offers to create the translation, empty or copied from an existing translation. A translation can only be created once its parent entry exists in that locale. Mark fields that should be identical in every language with `shared: true`, for example images, prices or dates. Editors see them with a shared badge, and saving the entry writes the value to every translation. Only fields of the entry type itself can be shared, not fields inside list rows or objects. File: schema/Product.ts ```tsx import {Config, Field} from 'alinea' export const Product = Config.document('Product', { fields: { description: Field.richText('Description'), price: Field.number('Price', {shared: true}), gallery: Field.image.multiple('Gallery', {shared: true}) } }) ``` ## Query translated content Pass `locale` to get the entries of one language. Without it, a query returns every translation. ```tsx const posts = await cms.find({ type: BlogPost, locale: 'fr' }) ``` Links follow the locale of the entry they are stored on: an entry link on the French page resolves to the French translation of the linked entry, or to the linked entry itself when it lives in a root without `i18n`. `Query.translations` selects the other translations of an entry. Add `includeSelf: true` to include the current one. That is all you need for a language switcher and for `hreflang` alternates. ## Routing in Next.js Put your pages below a `[locale]` segment and look entries up by their full URL: File: app/[locale]/[[...slug]]/page.tsx ```tsx import {cms} from '@/cms' import {Query} from 'alinea' import type {Metadata} from 'next' import {notFound} from 'next/navigation' interface PageProps { params: Promise<{locale: string; slug?: Array