Skip to content

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

  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:

cms.ts
// 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 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:

// 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:

// 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:

app/(alinea)/api/cms/route.ts
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.

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: 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:

// 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:

schema/Blog.ts
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 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.