Use it in Next.js
app/login/page.tsx
"use client";
import { useEffect, useState } from "react";
import type { LoginFlow } from "@ory/client";
import PullCordLogin from "@/components/PullCordLogin";
import { loadLoginFlow, submitPassword } from "@/lib/ory-flow";
import { PlainLoginForm } from "./plain-form";
const ORY = process.env.NEXT_PUBLIC_ORY_SDK_URL;
export default function LoginPage() {
const [flow, setFlow] = useState<LoginFlow>();
const [message, setMessage] = useState("");
useEffect(() => {
loadLoginFlow().then(setFlow, () => setMessage("Sign-in is unavailable right now."));
}, []);
async function signIn(email: string, password: string) {
if (!flow) throw new Error("no flow yet");
setMessage("");
const res = await submitPassword(flow, email, password);
if (!res.ok) {
setFlow(res.flow);
setMessage(res.message);
throw new Error(res.message); // the card shakes, the button resets
}
// Ory has set the session cookie. A full page load lets server components see it.
setTimeout(() => window.location.assign(flow.return_to || "/dashboard"), 900);
}
return (
<main className="grid min-h-dvh place-items-center bg-[#07080c]">
{/* The lamp room is drawn at 1000 x 660, so it only shows where it fits. */}
<div className="hidden lg:block">
<PullCordLogin
defaultOn
subtitle={message || "Sign in with your email and password"}
forgotHref={`${ORY}/self-service/recovery/browser`}
onFooterAction={() => window.location.assign(`${ORY}/self-service/registration/browser`)}
onSubmit={signIn}
/>
</div>
<div className="w-full max-w-sm p-6 lg:hidden">
<PlainLoginForm message={message} onSubmit={signIn} />
</div>
</main>
);
}
lib/ory.ts
import { Configuration, FrontendApi } from "@ory/client";
// Locally: the Ory Tunnel URL (http://localhost:4000). In production: your custom domain on Ory,
// e.g. https://auth.example.com, so Ory's cookies live on your own domain.
export const ory = new FrontendApi(
new Configuration({
basePath: process.env.NEXT_PUBLIC_ORY_SDK_URL,
baseOptions: { withCredentials: true },
}),
);
lib/ory-flow.ts
import type { LoginFlow, UpdateLoginFlowBody } from "@ory/client";
import { ory } from "@/lib/ory";
type HttpError = { response?: { status: number; data: unknown } };
const isHttpError = (e: unknown): e is HttpError => typeof e === "object" && e !== null && "response" in e;
/** The flow's hidden csrf_token node. Browser flows reject a submit without it. */
export function csrfToken(flow: LoginFlow) {
for (const node of flow.ui.nodes) {
if (node.attributes.node_type === "input" && node.attributes.name === "csrf_token") {
return String(node.attributes.value ?? "");
}
}
return "";
}
/** The first message Ory put on the flow or on one of its fields, such as the wrong-credentials error. */
export function firstMessage(flow: LoginFlow) {
const all = [...(flow.ui.messages ?? []), ...flow.ui.nodes.flatMap((n) => n.messages)];
return all.find((m) => m.type === "error")?.text ?? all[0]?.text ?? "";
}
/** Reuse ?flow=<id> when Ory sent the browser here, otherwise start a new browser login flow. */
export async function loadLoginFlow(): Promise<LoginFlow> {
const params = new URLSearchParams(window.location.search);
const id = params.get("flow");
if (id) {
try {
return (await ory.getLoginFlow({ id })).data;
} catch {
// expired or unknown: fall through and start a fresh one
}
}
return (await ory.createBrowserLoginFlow({ returnTo: params.get("return_to") ?? undefined })).data;
}
export type LoginResult = { ok: true } | { ok: false; flow: LoginFlow; message: string };
export async function submitPassword(flow: LoginFlow, identifier: string, password: string): Promise<LoginResult> {
const body: UpdateLoginFlowBody = {
method: "password",
identifier,
password,
csrf_token: csrfToken(flow),
};
try {
await ory.updateLoginFlow({ flow: flow.id, updateLoginFlowBody: body });
return { ok: true };
} catch (err) {
if (isHttpError(err) && err.response?.status === 400) {
// Ory answers a wrong password with the same flow, now carrying the error message.
const next = err.response.data as LoginFlow;
return { ok: false, flow: next, message: firstMessage(next) || "Wrong email or password" };
}
if (isHttpError(err) && err.response?.status === 410) {
// The flow expired while the page sat open: start a new one and ask again.
const next = (await ory.createBrowserLoginFlow()).data;
return { ok: false, flow: next, message: "That took a while. Please sign in again." };
}
throw err;
}
}
app/login/plain-form.tsx
"use client";
import { useState } from "react";
export function PlainLoginForm({ message, onSubmit }: { message: string; onSubmit: (email: string, password: string) => Promise<void> }) {
const [busy, setBusy] = useState(false);
return (
<form
className="flex flex-col gap-3 text-white"
onSubmit={async (e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
setBusy(true);
try {
await onSubmit(String(data.get("email")), String(data.get("password")));
} catch {
setBusy(false); // the message above the form already says why
}
}}
>
<h1 className="text-2xl font-semibold">Welcome back</h1>
{message ? <p role="alert" className="text-sm text-red-400">{message}</p> : null}
<input name="email" type="email" autoComplete="email" required placeholder="[email protected]" className="rounded-lg bg-white/10 px-3 py-2" />
<input name="password" type="password" autoComplete="current-password" required placeholder="Password" className="rounded-lg bg-white/10 px-3 py-2" />
<button disabled={busy} className="rounded-lg bg-lime-300 py-2 font-medium text-black">{busy ? "Signing in" : "Sign in"}</button>
</form>
);
}