Build Auth Pages with Ory Elements
You are building login, registration, recovery, verification, and settings pages using Ory Elements — a prebuilt UI component library that renders Ory's self-service flows.
Default rule: every page in this guide is rendered with Ory Elements.
Do not hand-roll forms from flow.ui.nodes unless the user has
explicitly opted out after seeing the Ory Elements path. Custom node
rendering belongs in the fallback section at the end of this skill.
Why Ory Elements
- One component per flow (
<Login>,<Registration>,<Recovery>,<Verification>,<Settings>) covers the entire self-service surface. - Handles CSRF tokens, error messages, multi-step states, social buttons, passkeys, and MFA prompts automatically.
- New node types and flow capabilities ship with Elements upgrades — no per-app rewrite needed.
- Works in Next.js (App Router and Pages Router) and any React SPA.
Avoid the 404 / 500 / CSRF traps
These pages only work if the surrounding wiring is correct — most "the login
page is broken" reports are wiring, not the pages or the plugin. If you have not
run /project:ory-auth-setup, do that first; it establishes the four invariants these
pages depend on:
- First-party SDK URL — the browser must reach Ory on your own origin (the
Ory Tunnel or local gateway,
http://localhost:4000in dev), neverhttps://<slug>.projects.oryapis.comdirectly, or every form submit fails with a CSRF error. - Routes match the project config — the
/auth/*paths below must match the project's self-serviceui_urls and the middleware lists, and you must create an/auth/errorpage, or Ory's redirects 404. - Server calls forward cookies — server-side session/flow reads must forward
the request cookies (the
@ory/nextjsserver helpers do this); reusing the browser client on the server 500s.
See the Correctness contract and the App bug vs. plugin bug triage in
/project:ory-auth-setup for the full detail.
Before you start
Check the project setup:
Identify the framework: Next.js (App Router preferred), React SPA (Vite, CRA, Remix client routes), Vue, or server-rendered. Vue and server-rendered apps drop into the fallback path below.
Verify Ory Elements and the SDK are installed. If not:
For Next.js:
npm install @ory/elements-react @ory/nextjs @ory/client-fetchFor React SPAs:
npm install @ory/elements-react @ory/client-fetchVerify
ORY_SDK_URLorNEXT_PUBLIC_ORY_SDK_URLis configured in the environment.
If Ory is not set up at all, suggest the user run
/project:ory-auth-setup first.
Understand Ory self-service flows
Ory uses a self-service flow model. Each auth action (login, registration, recovery, verification, settings) is a "flow" with these stages:
- Initialize — call the Ory API to create a flow (returns a flow object with UI nodes).
- Render — display the form. Ory Elements does this for you.
- Submit — post the user's input back to Ory. Ory Elements wires
the form action; in the SPA flow you call
updateLoginFlow/updateRegistrationFlow/ etc. - Handle result — redirect on success, show errors on failure. Ory Elements surfaces errors in-form automatically.
Build the login page
Login — Next.js App Router (preferred)
Create app/auth/login/page.tsx:
import { Login } from "@ory/elements-react/theme"
import { getLoginFlow, OryPageLayout } from "@ory/nextjs/app"
export default async function LoginPage(props: {
searchParams: Promise<{ flow?: string; return_to?: string }>
}) {
const searchParams = await props.searchParams
const flow = await getLoginFlow(searchParams)
if (!flow) {
return null
}
return (
<OryPageLayout>
<Login flow={flow} />
</OryPageLayout>
)
}
Login — React SPA
Use the same <Login> component from @ory/elements-react/theme.
Initialize the flow via the SDK and pass it in:
import { useEffect, useState } from "react";
import { FrontendApi, Configuration, LoginFlow } from "@ory/client-fetch";
import { Login } from "@ory/elements-react/theme";
import "@ory/elements-react/theme/styles.css";
const ory = new FrontendApi(
new Configuration({
basePath: import.meta.env.VITE_ORY_SDK_URL,
credentials: "include",
})
);
export function LoginPage() {
const [flow, setFlow] = useState<LoginFlow | null>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const flowId = params.get("flow");
const load = flowId
? ory.getLoginFlow({ id: flowId })
: ory.createBrowserLoginFlow();
load.then(setFlow).catch(console.error);
}, []);
if (!flow) return <div>Loading…</div>;
return <Login flow={flow} />;
}
<Login> handles CSRF, error rendering, social buttons, passkeys,
and submission for you. Do not unwrap flow.ui.nodes here.
Build the registration page
Registration — Next.js App Router
Create app/auth/registration/page.tsx:
import { Registration } from "@ory/elements-react/theme"
import { getRegistrationFlow, OryPageLayout } from "@ory/nextjs/app"
export default async function RegistrationPage(props: {
searchParams: Promise<{ flow?: string; return_to?: string }>
}) {
const searchParams = await props.searchParams
const flow = await getRegistrationFlow(searchParams)
if (!flow) {
return null
}
return (
<OryPageLayout>
<Registration flow={flow} />
</OryPageLayout>
)
}
Registration — React SPA
Mirror the login pattern: initialize with createBrowserRegistrationFlow
(or getRegistrationFlow when a flow query param is present) and
render <Registration flow={flow} /> from @ory/elements-react/theme.
Build the account recovery page
Recovery — Next.js App Router
Create app/auth/recovery/page.tsx:
import { Recovery } from "@ory/elements-react/theme"
import { getRecoveryFlow, OryPageLayout } from "@ory/nextjs/app"
export default async function RecoveryPage(props: {
searchParams: Promise<{ flow?: string }>
}) {
const searchParams = await props.searchParams
const flow = await getRecoveryFlow(searchParams)
if (!flow) {
return null
}
return (
<OryPageLayout>
<Recovery flow={flow} />
</OryPageLayout>
)
}
Recovery — React SPA
Initialize with createBrowserRecoveryFlow and render
<Recovery flow={flow} /> from @ory/elements-react/theme.
Build the verification page
Verification — Next.js App Router
Create app/auth/verification/page.tsx:
import { Verification } from "@ory/elements-react/theme"
import { getVerificationFlow, OryPageLayout } from "@ory/nextjs/app"
export default async function VerificationPage(props: {
searchParams: Promise<{ flow?: string }>
}) {
const searchParams = await props.searchParams
const flow = await getVerificationFlow(searchParams)
if (!flow) {
return null
}
return (
<OryPageLayout>
<Verification flow={flow} />
</OryPageLayout>
)
}
Verification — React SPA
Initialize with createBrowserVerificationFlow and render
<Verification flow={flow} /> from @ory/elements-react/theme.
Build the account settings page
Settings — Next.js App Router
Create app/auth/settings/page.tsx:
import { Settings } from "@ory/elements-react/theme"
import { SessionProvider } from "@ory/elements-react/client"
import { getSettingsFlow, OryPageLayout } from "@ory/nextjs/app"
export default async function SettingsPage(props: {
searchParams: Promise<{ flow?: string }>
}) {
const searchParams = await props.searchParams
const flow = await getSettingsFlow(searchParams)
if (!flow) {
return null
}
return (
<OryPageLayout>
<SessionProvider>
<Settings flow={flow} />
</SessionProvider>
</OryPageLayout>
)
}
Unlike the other flows, <Settings> must be wrapped in <SessionProvider>
from @ory/elements-react/client. Settings renders session-dependent controls
(connected social accounts, unlinking, logout of other sessions) that read from
the session context; the pre-auth pages (login, registration, recovery,
verification) have no session yet and do not need the provider.
Settings — React SPA
Initialize with createBrowserSettingsFlow and render
<Settings flow={flow} /> from @ory/elements-react/theme, wrapped in
<SessionProvider> from @ory/elements-react/client (the same requirement
as the App Router page above — Settings reads session-dependent state). The
component handles password changes, profile traits, MFA enrollment,
and connected social providers.
Build the error page (do not skip — missing it causes 404s)
Ory redirects the browser to the error UI on any flow error (expired flow, validation failure, misconfiguration). If that route doesn't exist the user hits a 404 or a blank page instead of a readable message, and the failure looks like a plugin bug when it's just a missing page.
Create app/auth/error/page.tsx and render the Elements error view for your
installed version (getFlowError from @ory/nextjs/app + the Elements error
component). Make sure its path matches the project's error.ui_url
(see /project:ory-auth-setup, Step 8). For a React SPA, fetch the error with
getFlowError({ id }) from the error query param and display
error.error.message.
Add session management
Create a browser utility to check the current session. (For server
components, route handlers, and middleware, use the @ory/nextjs server
helpers instead — they forward the request cookies. Reusing this browser client
on the server has no cookies and will 500/401.)
import { FrontendApi, Configuration, Session } from "@ory/client-fetch";
const sdkUrl = process.env.NEXT_PUBLIC_ORY_SDK_URL;
if (!sdkUrl) {
throw new Error("NEXT_PUBLIC_ORY_SDK_URL is not set — see ory-auth-setup Step 4.");
}
// Browser-only client. `credentials: "include"` only sends cookies in the browser,
// and sdkUrl must be your own origin (the tunnel/gateway in dev), not *.oryapis.com.
const ory = new FrontendApi(
new Configuration({ basePath: sdkUrl, credentials: "include" }),
);
export async function getSession(): Promise<Session | null> {
try {
return await ory.toSession();
} catch {
return null;
}
}
export async function logout(): Promise<void> {
const { logout_url } = await ory.createBrowserLogoutFlow();
window.location.href = logout_url;
}
Add route protection
Next.js Middleware
Create middleware.ts at the project root:
import { createOryMiddleware } from "@ory/nextjs/middleware";
export const middleware = createOryMiddleware({
protectedPaths: ["/dashboard", "/settings", "/profile"],
publicPaths: ["/", "/auth/login", "/auth/registration", "/auth/recovery"],
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
React SPA
Wrap protected routes in a ProtectedRoute component that calls
ory.toSession() and redirects to /auth/login on failure.
Style and customize
Ory Elements supports theming. Wrap auth pages in <ThemeProvider>
from @ory/elements-react/theme and pass a custom theme object.
Customizing the theme keeps the Elements rendering — you do not need
to drop down to raw nodes to restyle the forms.
import { ThemeProvider } from "@ory/elements-react/theme";
<ThemeProvider theme={{
// Custom colors, fonts, spacing, radii, dark mode tokens, …
}}>
<Login flow={flow} />
</ThemeProvider>
For larger design changes, the Ory Elements primitives package exposes the underlying card, button, and input components — use those before falling back to custom node rendering.
Enable MFA and passkeys
These are project-side capabilities: once enabled on the Ory project, the
<Login>, <Registration>, and <Settings> components render the second-factor
and passkey UI automatically — no page code changes. Configure them with the
Ory CLI against your project (ory list projects for the id), the same idiom as
/project:ory-social-login. Against the local stack, apply the equivalent keys to the
local Kratos config instead (/project:ory-local-dev).
Passkeys
ory patch identity-config <project-id> \
--add '/selfservice/methods/passkey/enabled=true' \
--add '/selfservice/methods/passkey/config/rp/display_name="My App"' \
--add '/selfservice/methods/passkey/config/rp/id="your-domain.com"' \
--add '/selfservice/methods/passkey/config/rp/origins=["https://your-domain.com"]'
rp.idis the domain only — no scheme, no port (example.com, nothttps://example.com:3000).rp.originsmust list the exact scheme+host+port the browser uses.- For local dev, WebAuthn treats
localhostas a secure origin: setrp.id="localhost"andrp.origins=["http://localhost:3000"](match your app URL). Passkeys registered againstlocalhostwon't work on the deployed domain and vice-versa.
MFA methods
Enable the second factors you want. Backup codes (lookup_secret) should always
be enabled alongside any other method so a user who loses their device can
recover.
# TOTP (authenticator app)
ory patch identity-config <project-id> \
--add '/selfservice/methods/totp/enabled=true' \
--add '/selfservice/methods/totp/config/issuer="My App"'
# Backup codes
ory patch identity-config <project-id> \
--add '/selfservice/methods/lookup_secret/enabled=true'
# Email code as a second factor
ory patch identity-config <project-id> \
--add '/selfservice/methods/code/mfa_enabled=true'
Require the second factor (do not skip)
Enabling a method only lets users enroll — it does not require the second factor. Without this step MFA is opt-in and unenforced. Set the required assurance level to the highest the identity has available:
ory patch identity-config <project-id> \
--add '/selfservice/flows/settings/required_aal="highest_available"' \
--add '/session/whoami/required_aal="highest_available"'
With highest_available, toSession() / getServerSession() return an
incomplete session (AAL1) until the user clears the second factor, and Elements
prompts for it on the next <Login>. Your route protection should treat an
AAL1 session on an MFA-enrolled user as unauthenticated.
Test the flow
Don't stop at "the page renders." Run these in order — each one isolates a different failure class:
Start the dev server and the Ory tunnel (local dev against Network), or the local stack. Confirm the browser SDK URL is your own origin, not
*.oryapis.com.Flow API reachable (isolates Ory from your app):
curl -i "$NEXT_PUBLIC_ORY_SDK_URL/self-service/login/browser"Expect
200and aSet-Cookie: csrf_token...header. If this fails it's the tunnel / Ory config, not your pages.No 404s: visit
/auth/login,/auth/registration,/auth/recovery,/auth/verification,/auth/settings, and/auth/error— each returns200. A 404 means a route/ui_urlmismatch.Register at
/auth/registration— confirm Elements renders all configured methods (password, social, passkey, etc.).Sign in at
/auth/login; confirm a protected route loads, and redirects to login when signed out.Test
/auth/recoverywith a registered email,/auth/settingsfor profile changes, and logout.
If a submit fails with a CSRF error, the SDK URL is cross-site (trap #1). If a
redirect 404s, a route doesn't match the project config (trap #2). Neither is a
plugin bug — see the App bug vs. plugin bug triage in /project:ory-auth-setup.
Writing E2E tests (Playwright)
Automating these flows has three gotchas that cause flaky or wrong-for-the-wrong- reason failures. Get them right up front:
Match URLs by pattern, never literally. Ory appends
?flow=<uuid>on every flow redirect, so an exact-string wait never matches.await page.waitForURL(/\/auth\/login\?flow=/) // regex await page.waitForURL("**/dashboard**") // glob — also matches query params // wrong: await page.waitForURL("http://localhost:3000/auth/login")Use strong, dissimilar credentials. Kratos rejects passwords that are too similar to the identifier or that appear in a breach database — a weak fixture password fails validation, not the flow you meant to test.
const email = `test-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com` const password = "Str0ngP@ssword!123" // fixed, strong, not in any breach listAssert on Ory Elements' own testids, not form-level selectors. Validation messages render inside the auth card, keyed by Kratos UI message id (the
4xxxxxxrange is validation errors), not on the flow wrapper.page.locator('[data-testid^="ui/message/4"]') // any validation error page.locator('[data-testid="login-auth-card"]').getByText(/credentials|invalid/i) // wrong: page.locator('[data-testid="login-flow"]').getByText(...) — errors aren't here
These selectors track the installed @ory/elements-react version; if a testid
assertion breaks after an Elements upgrade, inspect the rendered DOM before
assuming a flow bug.
Fallback: rendering UI nodes by hand
Use this path only when Ory Elements cannot run in the target environment (e.g., a Vue app where the React Elements components are not viable, a server-rendered template engine with no client bundle, or a constrained native shell). Before choosing this path, confirm with the user that mounting an Elements island is not an option.
In that case, iterate flow.ui.nodes and render each node based on
node.type (input, text, img, a, script) and
node.attributes. Group nodes by node.group (default, password,
oidc, webauthn, code, lookup_secret, totp) so each method
renders together. Render flow.ui.messages and per-node messages for
errors. Submit using flow.ui.action and flow.ui.method. This is
significantly more code than <Login flow={flow} /> and must track
Ory flow API changes manually.
Next steps
- Add social login providers: use
/project:ory-social-login. Elements renders the buttons automatically once providers are configured server-side. - Add multi-factor authentication and passkeys — see Enable MFA and
passkeys above. Once the project is configured, the
<Login>and<Settings>components render the second-factor and passkey UI automatically. - Customize the identity schema for additional profile fields. Elements reads the schema from the flow and renders new fields automatically.
- Set up webhooks for registration events.