Add sign-in to a Next.js app with Nautilus

This guide adds email-and-password accounts to a Next.js app that uses the App Router: a sign-up page, a sign-in page, a dashboard only a signed-in user can open, and a sign-out button. Nautilus, through its Gate API, keeps the users, their password hashes and their sessions; your app keeps one cookie.

Everything that talks to Gate runs on the server, in server actions and server components. The browser never sees your API key, and it never sees Gate's session token either: the token lives in an httpOnly cookie, and the page checks it with Gate on every request.

The code below was run end to end — sign-up, the protected page, sign-out, sign-in, a wrong password, a second sign-up with the same address — with Next.js 16.4 and @verne-software/sdk 1.4.1.

What you need

  • A Next.js app with the App Router (npx create-next-app@latest).
  • An API key with the gate scope (Sign-in and users on the Keys page). See API Keys.

1. Install the SDK and add the key

npm install @verne-software/sdk
VERNE_API_KEY=vrn_9f8a7...

2. One client for the server

import { Verne } from '@verne-software/sdk';

// Server-only: this module reads the API key. Import it from server components,
// server actions and route handlers, never from a 'use client' file.
export const verne = new Verne({
  apiKey: process.env.VERNE_API_KEY!,
});

3. Sign-up, sign-in, session check and sign-out

All of Gate fits in one file. Two things in it are worth knowing before you read it:

  • A flow is started with your key and finished without it. createRegistrationFlow() and createLoginFlow() call Gate with your API key and return a flow object. The user's details are then posted as JSON to the flow's own ui.action URL — no key, no cookie, no CSRF token. Registration takes two posts to that URL (the traits, then the password); a login takes one. The API reference has the raw requests.
  • The answer is a session token, not a cookie. A successful flow answers 200 with session_token and session. The token is what sessions.check() takes, and session.id is what sessions.revoke() takes, so the code keeps both, in two httpOnly cookies.
import { cookies } from 'next/headers';
import { VerneAPIError } from '@verne-software/sdk';
import { verne } from './verne';

const TOKEN_COOKIE = 'gate_session';
const SESSION_ID_COOKIE = 'gate_session_id';

type Message = { text: string };
type FlowAnswer = {
  session_token?: string;
  session?: { id: string; expires_at: string };
  ui?: { messages?: Message[]; nodes?: { messages?: Message[] }[] };
};

/** Posts one step of a flow to the flow's own `ui.action` URL. No API key, no cookie. */
async function submit(flow: Record<string, unknown>, body: Record<string, unknown>) {
  const { action } = flow.ui as { action: string };
  const res = await fetch(action, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify(body),
    cache: 'no-store',
  });
  return { status: res.status, answer: (await res.json()) as FlowAnswer };
}

/** Every message Kratos put on the flow, for showing to the user. */
function messagesOf(answer: FlowAnswer): string {
  const all = [
    ...(answer.ui?.messages ?? []),
    ...(answer.ui?.nodes ?? []).flatMap((node) => node.messages ?? []),
  ];
  return all.map((m) => m.text).join(' ') || 'Something went wrong. Try again.';
}

/** Keeps the session on the server side of the browser: an httpOnly cookie. */
async function keepSession(answer: FlowAnswer) {
  const jar = await cookies();
  const options = {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax' as const,
    path: '/',
    expires: new Date(answer.session!.expires_at),
  };
  jar.set(TOKEN_COOKIE, answer.session_token!, options);
  jar.set(SESSION_ID_COOKIE, answer.session!.id, options);
}

export type Result = { ok: true } | { ok: false; error: string };

export async function signUp(email: string, password: string): Promise<Result> {
  const flow = await verne.gate.createRegistrationFlow();
  const traits = { 'traits.email': email, 'traits.locale': 'en' };

  // Step 1: the traits. A 400 that asks for the password is "accepted, continue".
  await submit(flow, { method: 'profile', ...traits });

  // Step 2: the password, with the traits again.
  const { status, answer } = await submit(flow, { method: 'password', ...traits, password });
  if (status !== 200 || !answer.session_token) return { ok: false, error: messagesOf(answer) };

  await keepSession(answer);
  return { ok: true };
}

export async function signIn(email: string, password: string): Promise<Result> {
  const flow = await verne.gate.createLoginFlow();
  const { status, answer } = await submit(flow, { method: 'password', identifier: email, password });
  if (status !== 200 || !answer.session_token) return { ok: false, error: messagesOf(answer) };

  await keepSession(answer);
  return { ok: true };
}

/** The signed-in user's identity id, or null. Asks Gate on every call: revocations count at once. */
export async function currentUserId(): Promise<string | null> {
  const token = (await cookies()).get(TOKEN_COOKIE)?.value;
  if (!token) return null;
  try {
    const session = await verne.gate.sessions.check(token);
    return session.mfa_satisfied ? session.identity_id : null;
  } catch (err) {
    // 401: expired, revoked, or the user was deactivated. 403 mfa_required: a second factor is owed.
    if (err instanceof VerneAPIError && (err.status === 401 || err.code === 'mfa_required')) return null;
    throw err;
  }
}

export async function signOut(): Promise<void> {
  const jar = await cookies();
  const sessionId = jar.get(SESSION_ID_COOKIE)?.value;
  if (sessionId) {
    try {
      await verne.gate.sessions.revoke(sessionId);
    } catch (err) {
      // 404: the session is already gone. Anything else is worth knowing about.
      if (!(err instanceof VerneAPIError && err.status === 404)) throw err;
    }
  }
  jar.delete(TOKEN_COOKIE);
  jar.delete(SESSION_ID_COOKIE);
}

What each answer means is in the reference: a rejected password or an address that is already taken comes back as 400 with the reason in ui.messages (Registration); wrong credentials are 400 with message 4000006 (Completing a login). messagesOf() shows Kratos' own sentence for any of them.

Sign-out revokes the session at Gate, so a copied cookie stops working too. It does not recall a signed token you may have issued from the session; those expire on their own within 15 minutes.

4. Server actions

'use server';

import { redirect } from 'next/navigation';
import { signIn, signOut, signUp } from '@/lib/gate';

export type FormState = { error?: string };

function field(form: FormData, name: string) {
  return String(form.get(name) ?? '');
}

export async function signUpAction(_: FormState, form: FormData): Promise<FormState> {
  const result = await signUp(field(form, 'email'), field(form, 'password'));
  if (!result.ok) return { error: result.error };
  redirect('/dashboard');
}

export async function signInAction(_: FormState, form: FormData): Promise<FormState> {
  const result = await signIn(field(form, 'email'), field(form, 'password'));
  if (!result.ok) return { error: result.error };
  redirect('/dashboard');
}

export async function signOutAction() {
  await signOut();
  redirect('/sign-in');
}

5. The sign-up and sign-in pages

One small client component renders both forms and shows the error the action returns. It is the only 'use client' file, and it imports nothing from lib/.

'use client';

import { useActionState } from 'react';
import type { FormState } from './actions';

type Props = {
  action: (state: FormState, form: FormData) => Promise<FormState>;
  submitLabel: string;
};

export function AuthForm({ action, submitLabel }: Props) {
  const [state, formAction, pending] = useActionState(action, {});
  return (
    <form action={formAction}>
      <input name="email" type="email" autoComplete="email" required placeholder="Email" />
      <input name="password" type="password" autoComplete="current-password" required placeholder="Password" />
      <button type="submit" disabled={pending}>{submitLabel}</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}
import Link from 'next/link';
import { signUpAction } from '../actions';
import { AuthForm } from '../auth-form';

export default function SignUpPage() {
  return (
    <main>
      <h1>Create an account</h1>
      <AuthForm action={signUpAction} submitLabel="Sign up" />
      <Link href="/sign-in">I already have an account</Link>
    </main>
  );
}

6. A page only a signed-in user can open

import { redirect } from 'next/navigation';
import { currentUserId } from '@/lib/gate';
import { signOutAction } from '../actions';

export default async function DashboardPage() {
  const userId = await currentUserId();
  if (!userId) redirect('/sign-in');

  return (
    <main>
      <h1>Dashboard</h1>
      <p>Signed in as user {userId}</p>
      <form action={signOutAction}>
        <button type="submit">Sign out</button>
      </form>
    </main>
  );
}

currentUserId() asks Gate each time the page renders. That is what makes a revoked session, a deactivated user or an expired session stop working on the very next request, with nothing cached in your app. The identity id it returns is the key to keep your own data under — a users table of yours, an order, a document.

To show the user's address, read it once with verne.gate.identities.get(userId) (it is traits.email) and keep it with your own data, rather than on every page view: the identity endpoints are limited to 60 calls a minute per account (Rate Limits).

7. Run it

npm run dev

Open http://localhost:3000/sign-up, create an account, and you land on /dashboard. Sign out, and /dashboard sends you back to /sign-in. The new user is in Users in your dashboard, as your tenant's end user.

Where to go next

  • Confirm the address and reset a forgotten password — Recovery and verification: the same flow pattern, with a code mailed to the user.
  • Sign in with a code instead of a password — Sign in with a one-time code.
  • Two-factor authentication — when currentUserId() gets mfa_required, send the user through the step-up flow in MFA / TOTP.
  • Social login — Social Auth has a complete Express example of the return URL and the code exchange; the same three calls work in a route handler.
  • Teams and roles — sessions.check() already returns the user's organizations in orgs. See Organizations and Roles.
  • Change the password or the address — Settings Flow.
  • See your users in the dashboard — what the dashboard shows: the users, their sessions, the audit log, and what it leaves to the API.

Sign-in and users is part of Nautilus by Verne Software, together with webhooks, scheduled jobs and Telegram sign-in, in one plan priced by active users and hosted in France. Create a free account or see the plans.