Self-Hosted
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:
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 = handlerThe 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 torootDir: thesourceof 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:
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:
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. 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/postgresMySQL:
mysql2SQLite:
@libsql/client,d1(Cloudflare D1),sql.js@electric-sql/pglite
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:
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.sessionTokenfor 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 withforcePathStyle.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 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}:
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_KEYsigns 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.productionin your config: it builds the OAuth2 redirect and upload URLs from it.