# Databases (/docs/concepts/databases) Zitadel persists projects, users, sessions, and related state in a SQL database. Configure exactly one dialect. When `database:` is omitted, the server uses **SQLite** at `/zitadel.db`. - **SQLite** — local development, CLI `zitadel start`, and small single-node / homelab deployments (single-writer limits apply). - **PostgreSQL** — production and the Docker Compose operator stack. - **Spanner** — production on Google Cloud Spanner. PostgreSQL and Spanner are the production peers. For the CLI binary runtime path, see [Run Zitadel locally](/docs/guides/local-runtime). For YAML and environment examples, see the repository [configuration guide](https://github.com/zitadel/nextgen/blob/main/docs/quick-start/configuration.md). # Flows and schemas (/docs/concepts/flows-and-schemas) The flow engine drives login, registration, passkey, and related auth journeys. A user schema defines **what** the platform stores about a user and how that user type can authenticate. A flow definition defines **when and where** users are asked to provide that data — which step renders when, and which fields each step collects. A flow names its schema by `objectType` in `user_schema` and runs against that schema's newest revision; its `steps[].fields` must name properties of it. `zitadel setup` scaffolds editable copies of the default user schema and login flow into `.zitadel/schemas/` and `.zitadel/flows/`, uploads them, and seeds `.zitadel/state.json` so the local files and the platform start in sync. Each folder contains a README explaining what you can change. The local files are the source of truth: edit them, then sync with `plan` and `apply` ([Edit the login flow and schema](/docs/guides/plan-apply)). An edit publishes a new immutable revision instead of changing the old one ([How syncing works](/docs/concepts/repo-config#how-syncing-works)). # Concepts (/docs/concepts) These pages explain the model behind the preview: the current surface, and the direction the ADRs and design docs record. A [project](/docs/concepts/project-team-user) holds everything else: its users and teams, their sessions, and its configuration. That configuration lives in your repository as files ([Configuration as code](/docs/concepts/repo-config)), above all the [user schemas and login flows](/docs/concepts/flows-and-schemas) that decide what a user is and how they sign in. A sign-in runs as an [auth attempt](/docs/concepts/sessions-auth-attempts) and ends in a session. The server keeps all of it in one of the [databases](/docs/concepts/databases) it supports. # Project, team, and user (/docs/concepts/project-team-user) ## Project [#project] A project is the top-level tenant and the data boundary. Every user, session, credential, team, audit event, and variable is stored in exactly one project, as is its configuration: schemas, flows, branding, identity providers, and releases. Data that must stay separate, such as a staging user base, belongs in a second project. Only access crosses projects: a user or team can hold a role on another project, and one team can own several projects. ## Team [#team] A team is a tenant-grouping inside a project. It can represent a paying customer or a B2B end-customer depending on which project it lives in. ## User [#user] A user is an identity inside a project. Users can be self-owned or team-owned depending on lifecycle policy. ## Claim [#claim] A project is created first and claimed later: claiming attaches it to your team, and a claimed project is one that carries an active owning-team grant. Claim is implemented end to end: the `zitadel claim` command, the OpenAPI endpoints, the server handlers, and the console claim page, the browser leg where you sign in on the platform project and complete the claim, are all shipped. Some design docs still describe target claim and platform lifecycle behavior. A local project skips the browser leg by default. `zitadel start` creates a local admin, `admin@zitadel.localhost`, with a team of its own, and `zitadel setup --server local` attaches the new project to that team, so a local project is owned by the local admin from the moment setup finishes. `zitadel claim` then reports it as already owned; it is the fallback for when that step failed. # Configuration as code (/docs/concepts/repo-config) Repo files describe configuration; server APIs own runtime resources. Your project's user schemas, login flows, branding, and sign-in connections live in your repository, where you review them like code, and `zitadel plan` and `zitadel apply` sync them to the platform. [Edit the login flow and schema](/docs/guides/plan-apply) walks through a change. ## What the CLI writes [#what-the-cli-writes] - `zitadel.json` - `.zitadel/**` - framework-specific app files - `.env.example`, the environment template to commit - `.env.local`, with the project's real values, including the project secret ## What stays out of source control [#what-stays-out-of-source-control] Commit `zitadel.json` and `.zitadel/**`, apart from these: `.env.local` and `.zitadel/secret` hold the project secret, and `.zitadel/local/**` is a local server's runtime data, not product configuration. Setup adds `.zitadel/secret` and `.env*` (all but `.env.example`) to `.gitignore`; `start` adds `.zitadel/local/`. ## How syncing works [#how-syncing-works] `zitadel plan` validates local configuration and previews the sync diff without mutating files or platform state. `zitadel apply` validates and uploads it. - `.zitadel/state.json` maps each config file to its platform id and last-synced content hash. `plan` and `apply` compare against it to decide what changed; don't edit it by hand. - **Flows are validated at plan time** against the same rules the platform enforces on apply — transition wiring, step reachability, schema field references, and cross-purpose invariants (e.g. a login entry step must wire `user_not_found` when `register` is also a purpose). Violations fail `plan` and `apply` before anything is uploaded, with the same error text the server would return. Non-blocking product guidance shows up as `# warning:` lines in the plan. (If the pre-flight ever disagrees with your server version, set `ZITADEL_SKIP_FLOW_VALIDATION=1` to skip it and let the server decide.) - **SSO configuration is checked across files at plan time.** `plan` and `apply` read your user schemas, login flows and connection files together and stop, before anything is changed, when they disagree about what the plan touches: a step that offers a provider its schema does not list (or when SSO is off), a schema that lists a provider with no connection file, a step that offers providers without routing every outcome (a `user_already_exists` route that carries an action does not count), a claim mapping that fills nothing the schema defines, a `verified_claims` key with no `claim_mapping` row, or a mapping or `verified_claims` key that names a property no schema has (a top-level property name, not a dotted path). The files a plan does not touch are not judged again; two connection files may not share a slug or a project variable, and that is checked across all of them. One error names every problem with the file, the step and the name at fault. Setting `ZITADEL_SKIP_FLOW_VALIDATION` turns this check off along with flow validation. - **A connection's `oidc` block names no endpoint, or every one it needs.** With none, the endpoints come from the issuer's discovery document. Otherwise it names `authorization_endpoint`, `jwks_uri`, `token_endpoint`, and `userinfo_endpoint` unless `id_token_mapping` is true. The server refuses a block in between (`idp.endpoints_partial`), so `plan` and `apply` refuse it first with `E_VALIDATION`, naming the file and the missing endpoints, and an apply never stops part-way. `ZITADEL_SKIP_FLOW_VALIDATION` does not skip this. - **Connection files are also checked against the platform, and that check is never skipped.** `ZITADEL_SKIP_FLOW_VALIDATION` does not turn these off. When a `client_id` or `client_secret` names a project variable, it must exist (`E_CREDENTIAL_MISSING` when it does not), the id must be a plain variable and the secret a secret one, and one variable may not serve both (`E_VALIDATION`). The `slug`, `protocol`, `oidc.issuer`, and `subject_claim` (an absent one reads as `sub`) of a connection that was applied cannot be edited (`E_VALIDATION`). If the platform cannot be read, the plan stops instead of skipping them. - **A removed connection file is not deleted.** Deleting a connection is not supported yet, so `plan` and `apply` report the file as kept: separately from the changes, with its own count (`unsupported_deletions` in `--json`, plus the files in `unsupported_deletion_files` on `apply`) and never as "no changes". Renaming a connection file is a move: the file takes over the connection, and nothing is left to restore. - Schemas and flows are **revisioned**: an edit publishes a new immutable revision instead of mutating the old one. Existing users keep validating against the schema revision they were created with. Older flow revisions stay active; a login that names a flow runs its newest active revision. - Flows name their schema by its `objectType` in `user_schema` and run against its newest revision, so a schema edit needs no change to `user_schema`. If the edit removes or renames a property a step collects, update that step's `fields`: `plan` only checks the flows it uploads. - Flow files carry `"$schema": "../meta/flow-definition.json"` — a pointer at the flow dialect spec setup copies into `.zitadel/meta/`, so editors validate and autocomplete flow edits offline. It is local-only: the platform ignores it, and syncing never treats it as a change. - Your local files stay as written: `apply` never writes what the platform stores back into them. The one exception is a flow whose `user_schema` pins a schema revision id: when `apply` publishes a new revision of that schema, it re-pins the flow to the new id, and lists the file in `files_updated`. - Comparison is semantic rather than byte-for-byte. The platform stores defaults you leave out: it writes `"x-audit": false` into every schema property that omits it, and `pkce_enabled`, `token_endpoint_auth_method` and `id_token_mapping` into a connection's `oidc` block. A field you leave out reads as its default, so spelling a default out or deleting it again changes no behavior and `plan` reports no change. That holds at any depth, including on a leaf of a nested object property. - `plan` diffs lists element by element. A flow's steps and a step's actions are matched by `name`, so an edit to one step shows as the lines that changed under that step, and the unchanged steps are summarized as hidden. # Sessions and auth attempts (/docs/concepts/sessions-auth-attempts) An auth attempt is an ephemeral state machine for one authentication journey. It issues challenges, verifies proofs, and hands off a completed result. A session is the durable post-auth container. It carries the verified user and factors after an auth attempt succeeds. The generated OpenAPI reference exposes these resources as endpoint groups. The SDK middleware reads the session cookie and validates its token to protect routes and expose auth state to framework code (see [SDK proxy](/docs/sdks/proxy)). ## Session lifetime [#session-lifetime] A completed sign-in gives the session a lifetime of 24 hours. The lifetime starts at sign-in, and using the session does not extend it. When it runs out, the session cookie stops working, `GET /sessions/me` answers `401`, and the user signs in again. A sign-in can ask for a different lifetime with the `ttl` field of `POST /sessions/exchange` (see [Sessions](/docs/reference/api/sessions)), up to the server's maximum of 30 days. On a server you run yourself, `session.default_ttl` sets the default and `session.max_ttl` the maximum (see the [configuration guide](https://github.com/zitadel/nextgen/blob/main/docs/quick-start/configuration.md#sessions)). An anonymous session, which a client can create before anyone signs in, lasts 10 minutes. When a sign-in completes on it, it gets the full lifetime. # Agent setup (/docs/get-started/agents) The CLI is an agent-facing product surface. Agents should prefer structured JSON over prose hints and should prove the generated app in a browser. ## Agent Skills [#agent-skills] The CLI ships an [Agent Skill](https://agentskills.io) so a coding agent can drive it with always-current guidance. Install it into any skills-compatible agent (Claude Code, Cursor, Copilot, Codex, and more) from the repo: ```sh npx skills add zitadel/nextgen --full-depth ``` `--full-depth` installs the whole set — the `zitadel` router skill, the CLI skill, and the per-framework SDK and component skills. Without it, only the top-level router skill is installed. The CLI skill also ships inside the `@zitadel/cli` package. ## Invocation contract [#invocation-contract] Always pass: ```sh --non-interactive --json ``` When operating outside the current directory, also pass: ```sh --cwd ``` The CLI prints one parseable JSON object on stdout. Installer, audit, and package-manager progress belongs on stderr. Capture stdout and stderr separately when scripting, into files outside the app directory: setup scaffolds a new app only into an empty directory. The contract covers the Zitadel commands; oclif's built-in `help`, `version`, and `commands` print no envelope. ## Envelope shape [#envelope-shape] Every JSON envelope includes: - `cli_version` - `command` - `source` - `status`: `ok`, `skipped`, or `error` `ok` responses include command-specific `data` and a `warnings` list. `skipped` is a deliberate no-op, not a failure, with a stable `reason` such as `already-claimed`. `error` responses include `code`, `message`, and usually a `hint`. Agents should prefer `next_commands` over prose hints: it sits inside `data` on `ok` responses and at the top level on `skipped` and `error` responses. ## Agent prompt [#agent-prompt] ```text Create a new, empty local app directory with a lowercase name, such as my-app, and run these in it: 1. npx @zitadel/cli@latest doctor --non-interactive --json 2. npx @zitadel/cli@latest start --non-interactive --json 3. npx @zitadel/cli@latest setup --framework next --server local --non-interactive --json 4. npm run dev Use the available browser automation to open the generated app. Register a unique user, verify the signed-in profile state, log out, log back in with the same user, and finish on the signed-in page. To reach the management console, run npx @zitadel/cli@latest console --non-interactive --json and open data.sign_in_url right away. Each link signs in the local admin once and expires after a minute, so get a new one per visit. The link step 2 returns usually expires before setup finishes. Summarize commands run, JSON statuses, files changed, browser steps completed, and any setup or UI friction. ``` ## Stable UI proof [#stable-ui-proof] `` and `` are web components with shadow DOM, and the fields and buttons inside them have shadow roots of their own. Browser drivers with shadow-DOM-aware locators can target the test hooks directly. Generic DOM-eval drivers need to recurse through every shadow root. The hooks are `data-testid` values. Useful ones include: - `zitadel-field-email` and `zitadel-field-password` (the field elements) - `zitadel-input-email` and `zitadel-input-password` (the native inputs inside them) - `zitadel-action-submit` (the submit button element) - `zitadel-action-submit-button` (the native button inside it) For sign-out, the generated profile page renders ``: target `[data-testid="zitadel-session-logout"]`. An app that renders `` instead, as the React, Vue, Solid, Svelte, and Qwik manual samples do, has no test id there: click its "Open user menu" button, then target the `.signout-btn` button. # Add Zitadel to an existing app (/docs/get-started/existing-app) In an app you already have, setup detects the framework and patches the app instead of scaffolding a new one. You need what the [Quickstart](/docs/get-started/quickstart#prerequisites) needs. Commit your work, then run these in the app's directory: ```sh npx @zitadel/cli@latest doctor npx @zitadel/cli@latest start npx @zitadel/cli@latest setup --server local ``` Setup asks the same questions and writes the same Zitadel files as for a new app ([Scaffold auth](/docs/get-started/quickstart#scaffold-auth)). Then prove the browser flow as the [Quickstart](/docs/get-started/quickstart#prove-the-browser-flow) does. ## What setup writes, by framework [#what-setup-writes-by-framework] - **Next.js and Nuxt:** setup detects the framework and patches the app. Before it creates anything, it stops with `E_CONFLICT` rather than overwrite a file of yours that it would write, such as your own `proxy.ts` or `app/login/page.tsx`. Next.js runs one request boundary, so setup also stops on yours under the other name: on Next 16 it writes `proxy.ts`, and Next refuses to run beside a `middleware.ts`. Move yours aside, rerun setup, and merge it into the file setup writes. If `package.json` names Next.js by a tag such as `latest`, setup can't tell the version and writes `middleware.ts`, so it stops on an existing `proxy.ts`: pin the version the app runs, such as `^16.0.0`, and rerun. - **SvelteKit, SolidStart, Qwik Router, and TanStack Start:** setup detects the framework and writes the server hook or middleware (`src/hooks.server.ts`, `src/middleware.ts`, `src/routes/plugin@nextgen.ts`, or `src/start.ts`) plus the landing, login, register, and profile routes. - **React, Vue, Angular, Solid, Svelte, and Qwik:** setup writes the app's root component to a fixed path: `src/App.tsx` (React, Solid), `src/App.vue`, `src/App.svelte`, `src/app.tsx` (Qwik), or `src/app/app.ts` (Angular). If your app has a file there, setup stops with `E_CONFLICT` on it, again before creating anything. It also reads the entry that mounts your root component, the script `index.html` loads or Angular's `src/main.ts`, and stops when that mounts one from another file, such as `src/App.jsx` or Angular 17–19's `src/app/app.component.ts`, because your app would never load setup's. Import the root from the path setup writes in that entry, rerun setup, and move what you need from yours into it. - **Other apps,** such as a Next.js Pages Router app, a React app without Vite, or an app on a framework older than setup supports (Next.js 15.2.3, Nuxt 4, SvelteKit 2 with Svelte 5, SolidStart 2, Qwik Router and Qwik 2.0.0-beta.45, TanStack Start 1, React 18, Vue 3.2, Angular 17, Solid 1.6, Svelte 5): setup doesn't support them yet and stops without changing anything. Don't pass `--force` there: where setup detects no framework at all, `--force` makes it scaffold a new app over yours. ## When setup stops [#when-setup-stops] If you pass `--framework`, name the one your app uses: when setup detects another, it stops with `E_CONFLICT` instead of treating your app as a directory to scaffold into. The files setup writes under `.zitadel/`, such as the default schema and login flow, are checked before it creates the project too, so a conflict leaves nothing behind. After an `E_CONFLICT`, the error's hint says what to change. For a file at a path setup writes, move it aside and rerun, or rerun with `--force` to let setup replace it and merge your version back from git. `--force` doesn't help with your root component or request boundary at another path, or with a connection under `.zitadel/idps/`, which setup never replaces. # Quickstart (/docs/get-started/quickstart) Use this path when you want to add Zitadel auth to a local app without a source checkout. In a new, empty folder (for an app you already have, see [Add Zitadel to an existing app](/docs/get-started/existing-app)): ```sh npx @zitadel/cli@latest doctor npx @zitadel/cli@latest start npx @zitadel/cli@latest setup --server local npm run dev ``` `doctor` checks your machine, `start` runs Zitadel locally, `setup` scaffolds an app with login, registration and profile pages, and `npm run dev` serves it. The sections below explain each step. ## Prerequisites [#prerequisites] - Node.js 24 or newer. Setup checks your Node against the framework's own range; an Angular app needs a narrower one (see [Node versions](#node-versions)). - A package manager supported by your app. - Docker only if you intentionally choose the Docker runtime backend. ## Create an app directory [#create-an-app-directory] ```sh mkdir myapp cd myapp ``` Use a new, empty directory. To add Zitadel to an app you already have, follow [Add Zitadel to an existing app](/docs/get-started/existing-app) instead. ## Check prerequisites [#check-prerequisites] ```sh npx @zitadel/cli@latest doctor ``` Before setup, `doctor` checks local runtime prerequisites. After setup, it also verifies generated app files and local config. ## Start local Zitadel [#start-local-zitadel] ```sh npx @zitadel/cli@latest start ``` This runs the `@zitadel/server` npm binary that matches the CLI's version. `start` also creates a local admin, `admin@zitadel.localhost`, and ends by printing a one-time link that signs you in to the management console. [Run Zitadel locally](/docs/guides/local-runtime) covers where the runtime keeps its state, the console link, and ports. ## Scaffold auth [#scaffold-auth] ```sh npx @zitadel/cli@latest setup --server local ``` Setup scaffolds a new app into an empty directory. What `start` wrote there, `.zitadel/local/` and the `.gitignore` line that ignores it, doesn't count. In a directory with other files but no app that setup recognizes, setup stops and points at `--force`: pass it only when those files may go, because setup replaces the ones that collide with the new app's (`package.json`, `src/`, and so on). Run setup in the directory `start` ran in, or one inside it: setup looks there for the local admin, and without it the project is not attached to a team and the console does not list it. When it scaffolds a new app, setup asks which framework to use. Scripted runs pass `--framework` with one of `next`, `nuxt`, `sveltekit`, `solid-start`, `qwik-router`, `tanstack-start`, `react`, `vue`, `angular`, `solid`, `svelte`, or `qwik`. Setup creates the app only once it has asked all its questions, so cancelling one leaves the directory as it was, and `--dry-run` previews setup without creating the app. If setup fails or you cancel it after it has created the app, it removes the app again, so you can run the same command once more. Setup also asks how users should sign in and scaffolds the matching schema and login flow: `--preset password-first` (the default: email + password) or `--preset passkey-first` (a one-tap passkey up front, with an email + password fallback). The preset only picks the starting point: the schema and login flow it writes to `.zitadel/schemas/` and `.zitadel/flows/` are yours to edit. Setup writes the app files, `.env.example`, `.env.local` (with the project id and the project secret), `zitadel.json`, and `.zitadel/**`, adds the files that hold secrets to `.gitignore` ([Configuration as code](/docs/concepts/repo-config)), then installs dependencies with the detected package manager unless `--skip-install` is passed. The pages and the request boundary (Next.js's `proxy.ts`, for example) that setup writes are yours to change. Setup records each one, and `doctor` reports a file you changed as edited, or as adopted once you delete its `zitadel-cli: managed-file` comment. `doctor --fix` brings back only a file that is missing, never one you changed. Against a local server, setup by default also attaches the project to the local admin's team and prints `Project owned by admin@zitadel.localhost (team ...)`: there is nothing to [claim](/docs/concepts/project-team-user#claim). ## Add Zitadel to an existing app [#add-zitadel-to-an-existing-app] Setup patches an app you already have instead of scaffolding one, and stops before it would overwrite a file of yours: follow [Add Zitadel to an existing app](/docs/get-started/existing-app). ## Prove the browser flow [#prove-the-browser-flow] Start the generated app: ```sh npm run dev ``` Then open the dev server URL, usually `http://localhost:3000/login` — **use exactly the origin setup registered** (`localhost`, not `127.0.0.1`): the origin allowlist and passkeys are bound to it, and a passkey registered under one hostname can never assert under another. Complete the browser proof: ```text register a user -> log out -> log in with the same user -> profile shows signed in ``` Do not treat a rendered login form as completion. The useful proof is a full register, logout, and login loop in a real browser. The user you just registered is an end user of your app. The management console is a separate sign-in, as the local admin. The dev server keeps its terminal busy, so open a second terminal in the same directory: ```sh npx @zitadel/cli@latest console ``` [Run Zitadel locally](/docs/guides/local-runtime#open-the-console) explains what that link does and how long it lasts. ## Customize the login [#customize-the-login] Setup scaffolded editable copies of your user schema and login flow into `.zitadel/schemas/` and `.zitadel/flows/` — each folder has a README explaining what you can change. Edit the files, then preview and publish: ```sh npx @zitadel/cli@latest plan npx @zitadel/cli@latest apply ``` [Edit the login flow and schema](/docs/guides/plan-apply) walks through adding a `company` field to registration. ## Stop or reset [#stop-or-reset] Run these in the directory `start` ran in ([Run Zitadel locally](/docs/guides/local-runtime#inspect-stop-and-reset)). Stop local Zitadel while keeping runtime data: ```sh npx @zitadel/cli@latest stop ``` Delete local runtime data: ```sh npx @zitadel/cli@latest reset --force ``` ## Node versions [#node-versions] When setup creates a new app, it checks your Node against the range the framework's CLI publishes before it starts. For the frameworks other than Angular, setup warns if your Node is outside that range and carries on. To create an Angular app, use a Node version the Angular CLI accepts: 22.22.3 or newer 22.x, 24.15.0 or newer 24.x, or 26.x. The Angular CLI publishes `^22.22.3 || ^24.15.0 || >=26.0.0`, but it checks each major on its own, so Node 27 and later are refused. Node 23 and 25 run with a warning. Setup refuses any other Node and names the range. # Customize the login text (/docs/guides/customize-copy) The login component looks up every title, description, field label, button and error message by a key: `identifier.title`, for example, is the title of the screen that asks for the email, "Sign in" in English. It ships dictionaries in English, German and Italian that cover every step of the login flow `zitadel setup` creates, and picks the visitor's language from the browser. To change a text, pass `locales` to `ZitadelLogin`: for each language, the keys you want to change and their new text. Every key you leave out keeps its built-in text. ```tsx ``` Every SDK's `ZitadelLogin` takes `locales` ([SDKs](/docs/sdks)). On a plain `` element, set it as a JavaScript property: it is not an attribute. German and Italian visitors keep the built-in translation of a text you change only in `en` ([Choose the language](#choose-the-language)). ## Find the key for a text [#find-the-key-for-a-text] A key starts with the name of the step that shows the text, as your flow file, `.zitadel/flows/default-login.json`, names it: | Text | Key | | --- | --- | | Title | `.title` | | Description | `.description` | | Field label | `.field.` | | Placeholder, help text | `.field..placeholder`, `.field..help` | | Button | the action's `text_key` in the flow file, or `.action.` if it has none | | Back button | `.action.back`, or `action.back` if the step has none | | Error message | `error.`, such as `error.invalid_credentials` | `` is the field's name in the step's `fields`; a sign-in method field such as `x-auth-methods#password` uses the method, `password`. The flows `zitadel setup` creates give every button a `text_key`, so the email screen's Continue button, for example, is `identifier.action.continue`, not `identifier.action.submit`. Every built-in key and its English text is in the [English dictionary](https://github.com/zitadel/nextgen/blob/main/packages/components/src/orchestrator/locales/en.ts). Keep any placeholder in braces, such as `{0}` or `{name}`, in a text you replace: the login fills it in, with a field's label or a provider's name. ## Label a step you add [#label-a-step-you-add] A step you add to your flow has no built-in text, so supply its keys in `en`: every visitor sees your English until you add their language. For a step named `company-details` with a `company` field and a `submit` action: ```tsx ``` A key that no dictionary has shows on screen as the key itself, and the browser console warns `missing text key`: a button that reads `company-details.action.submit` names the entry to add. Fields are the exception: a missing label shows the field's name made readable, so `dateOfBirth` reads "Date of birth", and a missing placeholder or help text shows nothing. Keys that your own [login design](/docs/guides/customize-design) adds to its template work the same way. ## Choose the language [#choose-the-language] The login shows the language set by `lang`, or the browser's language when `lang` is unset. It reads only the language, not the region: a browser set to `de-CH` gets `de`, so key `locales` by `de`, never `de-CH`. For each key, a visitor gets the first of these that has a text for it: 1. your entries for their language, such as `de`; 2. the built-in texts for their language, if the login ships them; 3. your `en` entries; 4. the built-in English texts. So your English is the fallback for every language: a step you labelled only in `en` reads in English for everyone. A built-in translation still beats it, so change a built-in text for German or Italian visitors with a `de` or `it` entry, or set `lang="en"` to show everyone English. ## Steps that offer sign-in providers [#steps-that-offer-sign-in-providers] A step that shows sign-in provider buttons asks for `.description.sso` instead of `.description`, because its text must also suit users who have no password. Only the password step has one built in, "Enter your password or continue with your sign-in provider"; every other step shows its plain description. If you change a step's `.description` but not its `.description.sso`, your text shows whether or not the step offers providers. # Customize the login design (/docs/guides/customize-design) The login component ships with a built-in template that follows your project without any files in your repo. Its look comes from your app: style it from your own stylesheet with [`--zl-*` tokens and CSS parts](#style-the-login-from-your-app), and put any page layout — a split screen, a brand pane, marketing copy — around it in your own code. When you need to change the widget's *structure*, you take ownership of its template: the CLI copies a starter design into `.zitadel/branding/`, and once you apply it, the login renders your files. ## Style the login from your app [#style-the-login-from-your-app] Two hooks reach inside the login's shadow DOM from your app's stylesheet. Both are stable across releases, and both win over the branding your project publishes, so the login matches your app without a branding revision. ### Tokens [#tokens] Set `--zl-*` custom properties on `zitadel-login`. These names are stable: | What it styles | Tokens | | --- | --- | | Primary button | `--zl-primary`, `--zl-primary-foreground` | | Page and card | `--zl-background`, `--zl-card`, `--zl-popover` | | Text | `--zl-foreground`, `--zl-muted-foreground`, `--zl-card-foreground`, `--zl-popover-foreground` | | Muted surfaces | `--zl-muted`, `--zl-secondary`, `--zl-secondary-foreground`, `--zl-accent`, `--zl-accent-foreground` | | Edges | `--zl-border` (the card and alerts), `--zl-input` (fields and other controls) | | Links | `--zl-link` | | Status | `--zl-success`, `--zl-warning`, `--zl-destructive` | | Type | `--zl-font-family-sans`, `--zl-font-family-heading`, `--zl-text-{xs,sm,base,lg,xl}-size`, `--zl-text-{xs,sm,base,lg,xl}-leading` | | Shape | `--zl-radius-{xs,sm,md,lg,xl}`, `--zl-spacing-4`, `--zl-spacing-8`, `--zl-logo-scale` | Other `--zl-*` variables in the component's styles are internal and can change in any release. ### CSS parts [#css-parts] For what no token covers, such as border widths, shadows or letter case, style the login's parts with `::part()`. A part's name is the inner component's tag without `zl-`, a hyphen, and the part: the `card` part of `zl-card` is `zitadel-login::part(card-card)`. | Component | Parts | | --- | --- | | `zl-card` | `card-card`, `card-header`, `card-body`, `card-footer` | | `zl-field` | `field-root`, `field-label`, `field-label-row`, `field-forgot-link`, `field-input-wrap`, `field-input`, `field-trailing-icon`, `field-trailing-action`, `field-password-toggle`, `field-help`, `field-success`, `field-error` | | `zl-button` | `button-root`, `button-spinner` | | `zl-alert` | `alert-alert`, `alert-icon`, `alert-body`, `alert-title`, `alert-message`, `alert-detail`, `alert-link`, `alert-close` | | `zl-checkbox` | `checkbox-field`, `checkbox-root`, `checkbox-input`, `checkbox-box`, `checkbox-face`, `checkbox-check`, `checkbox-label`, `checkbox-error` | | `zl-select` | `select-root`, `select-label`, `select-field`, `select-native`, `select-trigger`, `select-listbox`, `select-option`, `select-error` | | `zl-sso-providers` | `sso-providers-root`, `sso-providers-divider`, `sso-providers-list`, `sso-providers-provider`, `sso-providers-provider-button` | | `zl-page-shell` | `page-shell-shell`, `page-shell-header`, `page-shell-main`, `page-shell-footer` | | `zl-account`, `zl-avatar`, `zl-pill` | `account-chip`, `avatar-avatar`, `pill-pill` | The login also names its own parts directly: `form`, `paired-identifier`, `attribution`, and `attribution-mark`. A flat, hard-edged look, for example, needs both hooks: ```css zitadel-login { --zl-font-family-sans: "Space Mono", ui-monospace, monospace; --zl-primary: #0a0a0a; --zl-primary-foreground: #c6ff00; --zl-radius-xs: 0; --zl-radius-sm: 0; --zl-radius-md: 0; --zl-radius-lg: 0; --zl-radius-xl: 0; } zitadel-login::part(card-card) { border: 3px solid #0a0a0a; box-shadow: 6px 6px 0 #0a0a0a; } zitadel-login::part(button-root) { text-transform: uppercase; } ``` The login draws its own card, so give that card your frame through `card-card` rather than wrapping the element in a second box. `` and `` take the same tokens but expose no parts: frame them from your own markup. ## Starter designs [#starter-designs] | Design | Layout | Uses logo | | --- | --- | --- | | `centered` | The default card, ejected as-is | yes | | `minimal` | The same form without card chrome | no | Both render every step of your login flow — sign-in, registration, passkey ceremonies, and their error states — because they template the flow engine's step responses rather than individual screens. ## Eject a design [#eject-a-design] `zitadel setup` never applies a design: it embeds the maintained login component and publishes no branding revision. Take ownership of the template at any time: ```sh npx @zitadel/cli@latest branding eject ``` `branding eject` offers the two starter designs above, then scaffolds `.zitadel/branding/` locally — nothing is uploaded until you apply: - `branding.json` — the descriptor: layout, brand asset URLs, and the template reference. - `login.liquid` — the LiquidJS template the login renders for every step. - `README.md` — the template rules and workflow, kept next to the files. ## Add your branding [#add-your-branding] Brand assets live in `branding.json` and must be hosted `https://` URLs. While you develop, images may also use `http://` on `localhost`, a `127.x.x.x` address, or `[::1]`; the login shows them only when its own page runs on one of those hosts. The CLI validates the URLs at plan time, and the server rejects anything else: ```json { "$schema": "../meta/branding.json", "layout": "centered", "logo_url": "https://cdn.example.com/logo.svg", "typography": { "font_family": "Inter, ui-sans-serif, sans-serif", "font_url": "https://cdn.example.com/fonts/inter.css" }, "liquid_template": { "$file": "./login.liquid" } } ``` Each design decides which images it shows: `centered` shows the logo, `minimal` shows none, and neither shows `hero_url`. A URL the design doesn't use is stored but not shown, and `plan` still checks it. `typography.font_family` sets the typeface for body and headings; include a generic family as a fallback. `typography.font_url` is a stylesheet that loads the face. It needs a `font_family`, must be `https://`, and is loaded only when the login runs as `variant="page"`. Setup generates page-variant sign-in pages only when it creates the Next.js or Nuxt app itself, in an empty directory. In a Next.js or Nuxt app that already exists, even a new one, and in every SPA template, the login runs as a widget, so load the font from your own page there. Without `font_url` the login loads no font of its own: it renders in Arimo where the browser already has it, and in the system font otherwise, so it sends no request to a font host. A `font_url` on a third-party host such as Google Fonts sends every visitor's IP address to that host, which under the GDPR can require their consent. Serve the stylesheet and font files from your own domain to avoid that. ## Publish and iterate [#publish-and-iterate] Branding follows the same loop as schemas and flows: ```sh npx @zitadel/cli@latest plan npx @zitadel/cli@latest apply ``` `plan` validates the descriptor and the template (LiquidJS parse, banned patterns, required tags) and shows the pending change; `apply` publishes it. Every publish creates an immutable revision — there is no in-place update, and rolling back means re-applying an earlier template. The live login picks up the newest revision on its next step response. The scaffolded `README.md` in `.zitadel/branding/` documents the template rules (compose `zl-*` components, no ` ``` `ZitadelLogin` also takes `resumeFlowId`, an existing flow to resume instead of starting a new one; unset, `null` or `""` falls back to the `flow` query parameter. ## Widget props and events [#widget-props-and-events] `ZitadelLogin`, `ZitadelLogout` and `ZitadelSession` take the props below, spelled the same in every SPA SDK. An unset prop keeps the widget's own default. | Prop | Type | Default | Widgets | Description | | --- | --- | --- | --- | --- | | `project` | `ZitadelProject` | unset | Login, Logout, Session | The handle from `configureZitadel(...)`. Wins over `projectId` and `proxyPath`. | | `projectId` | `string` | unset | Login, Logout, Session | Project id, read when no `project` handle is passed. | | `proxyPath` | `string` | `"/__nextgen"` | Login, Logout, Session | Same-origin path the widget calls. | | `purpose` | `CreateFlowBodyPurpose` | `"login"` | Login | Flow purpose. | | `flowName` | `string` | unset | Login | Runs the flow definition with this `name`; unset runs the project's default flow for the purpose. | | `postSignInUrl` | `string` | unset | Login | Where to navigate after a completed sign-in. When the page's `next` query parameter names a path on the same origin, as the server SDKs' middleware sets it for a signed-out visit to a protected route, the widget returns there instead. | | `forcePostSignInUrl` | `boolean` | `false` | Login | Navigates to `postSignInUrl` even when the page's `next` query parameter names a path, for a page that uses `next` for something of its own. | | `postSignOutUrl` | `string` | unset | Logout, Session | Where to navigate after sign-out. | | `resumeFlowId` | `string` or `null` | unset | Login | An existing flow's id to resume instead of starting a new flow; unset, `null` or `""` falls back to the `flow` query parameter, and an id that no longer resolves starts a new flow. | | `variant` | `"widget"` or `"page"` | `"widget"` | Login, Session | `widget` is content-sized and paints no page chrome; `page` claims the viewport, paints the surface background and loads the brand font, and on Login focuses the first field. | | `theme` | `"light"`, `"dark"` or `"auto"` | unset | Login, Logout, Session | Colour mode. Unset follows the tenant's branding theme on Login, then the variant default (`dark` for `page`, `auto` for `widget`); Logout defaults to `auto`. `auto` follows `prefers-color-scheme`. | | `suppressHeader` | `boolean` | `false` | Login, Session | Visually hides the widget's own heading block while keeping it in the accessibility tree, for pages that already carry the heading. | | `suppressPasswordToggle` | `boolean` | `false` | Login | Hides the show/hide button on password fields. | | `heading` | `string` | `"Signed in as"` | Session | Heading text override. | | `logoutLabel` | `string` | `"Sign out"` | Session | Logout action label override. | | `locales` | `Record>>` | unset | Login | Copy overrides merged over the builtin dictionaries, keyed by primary language subtag, for example `{ en: { "identifier.title": "Welcome back" } }`. | | `lang` | `string` | browser language | Login | BCP-47 language override. | Each widget event reaches the callback in the last column, which receives the event's `detail` payload. | Widget event | Fires when | Detail | Widgets | Callback | | --- | --- | --- | --- | --- | | `zitadel-flow-step` | Login shows a step, the first one included. | `{ step }` | Login | `onFlowStep` | | `zitadel-flow-input` | The visitor edits a field. | `{ name, value }` | Login | `onFlowInput` | | `zitadel-flow-complete` | The flow reaches a terminal step. | `{ behavior, redirect_uri?, handoff_token?, handoff_token_expires_at? }` | Login | `onFlowComplete` | | `zitadel-flow-redirect` | The widget hands the browser to an identity provider; nobody is signed in yet. | `{ redirect_url, step }` | Login | `onFlowRedirect` | | `zitadel-flow-error` | The flow reports an error. | `{ message }` | Login | `onFlowError` | | `zitadel-signout` | The user signs out. | `{ display, identifier }` | Logout, Session | `onSignout` | ```svelte track(detail)} /> ``` ## Standard HTML attributes [#standard-html-attributes] `ZitadelLogin`, `ZitadelLogout` and `ZitadelSession` also take the standard HTML attributes (`class`, `style`, `id`, `title`, `slot`, `hidden`, `role`, `tabindex`, `data-*` and `aria-*`) and pass them to the widget element, so you can position, style and label the widget without an extra wrapper element. Nothing else reaches the element: any other prop is ignored. ```svelte