Upgrading from 1.x
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
Run Node.js 24 or higher, locally, in CI and wherever your site runs, and use React 19.
Update the package:
npm install alinea@preview(orpnpm add,yarn add,bun add).Add
handlerUrlto your config, it is now required. ReplacedashboardFilewithadminPath.Make sure your Next.js config is wrapped in
withAlineaand your scripts runalinea dev -- next devandalinea build -- next build: media URLs are now served through your site.Move children of
Config.media(...)into itschildrenoption.Remove the options of
Field.metadata(...)and thestepoption ofField.time.Rich text: pass
extensionsas a function and upgrade custom extensions to Tiptap 3.Handler: replace
remotewithbackend, and move entry hooks tobeforeCommit/afterCommit.Self-hosted OAuth2: provide
validateClaims.Custom field views and dashboard code: import hooks from
alinea/cmsand components fromalinea/componentsinstead ofalinea/dashboardandalinea/ui.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:
// 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 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 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
withAlineaand your scripts run throughalinea dev --andalinea build --, which pass it theadminPathandhandlerUrl. 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.localPatternsorremotePatternsyourself, check that the admin file route is still allowed. Theuploads.alinea.cloudremote pattern is no longer added.Don't change the
mediaDirof an existing workspace: stored locations are relative to it. New projects default topublic/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:
// 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.timeno longer has astepoption.Field.filepickers now also show images.Field.entry.multipleno longer allows the same entry twice by default, passallowDuplicates: trueto 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.optionsmust return their value synchronously, andConfig.track.valuewas 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:
// 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
// 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:
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 = handlerErrors thrown in afterCommit are logged and don't fail the commit. See 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:
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:
validateClaimsis now required, the backend refuses to start without it. Check at least the issuer and audience of the token there.GitHub: the
authoroption was removed. Commits get aCo-authored-bytrailer for the signed-in editor instead.Custom auth implementations:
authenticatereceives a secondoptionsargument.Database: new
alinea_userandalinea_user_roletables are created automatically on first use (on Postgres with row level security enabled). Drafts are no longer stored in the database, the 1.7alinea_drafttable 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:
// 1.7
import {Button, Loader} from 'alinea/ui'
// 2.0
import {Button, Spinner} from 'alinea/components'See 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:
// 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 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:
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 withoverview.columns.summaryThumb: set the card image withoverview.thumbnail. By default cards show the first image found in the entry's fields.orderChildrenBy: still works, move it tooverview.sort.overview: trueon a field: still shows the field as a column, but only while the parent has nooverview.columns.
See 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 for per-field translations, URL aliases and backlinks, instant publishing and the MCP server for coding agents.