Back to guides

Updated 8 August 2026

Own Auth with Next.js App Router


Add authentication to a Next.js App Router application by mounting createOwnAuthHandler in one catch-all Route Handler. It validates auth requests, sets HttpOnly session and MFA cookies, applies CSRF checks, and returns the responses consumed by the Own Auth React client.

Install Own Auth

Terminal
npm install own-auth

Connect Postgres

.env.local
DATABASE_URL=postgres://user:password@localhost:5432/myapp
Terminal
npx own-auth migrate

Create the auth instance

.env.local
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
lib/auth.ts
import "server-only";
import { createOwnAuth } from "own-auth";

const tokenPepper = process.env.OWN_AUTH_TOKEN_PEPPER!;

export const auth = createOwnAuth({
  tokenPepper,
});

Mount the HTTP handler

app/api/auth/[...path]/route.ts
import { createOwnAuthHandler } from "own-auth/http";

import { auth } from "@/lib/auth";

const handler = createOwnAuthHandler(auth);

export const GET = handler;
export const POST = handler;

The route exposes the Own Auth HTTP API under /api/auth. Sign-up, sign-in, sign-out, session reads, password recovery, magic links, OAuth, passkeys, and MFA all use this handler.

Add the React client

lib/auth-client.ts
"use client";

import { createOwnAuthReactClient } from "own-auth/react";

export const authClient = createOwnAuthReactClient();

The client calls /api/auth and uses the cookies set by the handler. Raw session and MFA challenge tokens never enter Client Component state; the MFA result carries the available methods and expiry.

Add email and password

lib/email-password.ts
"use client";

import { authClient } from "@/lib/auth-client";

export function signUp(name: string, email: string, password: string) {
  return authClient.signUpEmailPassword({ name, email, password });
}

export async function signIn(email: string, password: string) {
  const result = await authClient.signInEmailPassword({ email, password });

  if (result.status === "mfa_required") {
    return { ...result, next: "/mfa" };
  }

  const { id, email, name, imageUrl } = result.user;
  return { next: "/dashboard", user: { id, email, name, imageUrl } };
}

Build the sign-in form

Submit with the React client, then navigate with the App Router. The form stays in-app instead of reloading the document, and an MFA result continues to the challenge screen.

app/signin/sign-in-form.tsx
"use client";

import { type FormEvent, useState } from "react";
import { useRouter } from "next/navigation";

import { signIn } from "@/lib/email-password";

export function SignInForm() {
  const router = useRouter();
  const [error, setError] = useState<string | null>(null);

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setError(null);

    const data = new FormData(event.currentTarget);

    try {
      const result = await signIn(
        String(data.get("email") ?? ""),
        String(data.get("password") ?? ""),
      );

      router.push(result.next);
      router.refresh();
    } catch {
      setError("Unable to sign in with those credentials.");
    }
  }

  return (
    <form onSubmit={submit}>
      <label>
        Email
        <input name="email" type="email" autoComplete="email" required />
      </label>
      <label>
        Password
        <input
          name="password"
          type="password"
          autoComplete="current-password"
          required
        />
      </label>
      {error ? <p role="alert">{error}</p> : null}
      <button type="submit">Sign in</button>
    </form>
  );
}

Read the current session

Server Components and Server Actions can verify the handler's session cookie directly with auth.getCurrentSession.

lib/current-auth.ts
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { defaultSessionCookieName } from "own-auth/http";

import { auth } from "@/lib/auth";

export const getCurrentAuth = cache(async () => {
  const token = (await cookies()).get(defaultSessionCookieName)?.value;
  return token ? auth.getCurrentSession(token) : null;
});

Protect a Server Component

app/dashboard/page.tsx
import { redirect } from "next/navigation";

import { getCurrentAuth } from "@/lib/current-auth";

export default async function DashboardPage() {
  const current = await getCurrentAuth();
  if (!current) redirect("/signin");

  return <h1>Welcome, {current.user.name ?? current.user.email}</h1>;
}

Protect a Server Action

Pass the handler's session cookie into server-only Own Auth methods. This password action validates only that both strings are present; Own Auth enforces the password policy.

app/actions/change-password.ts
"use server";

import { cookies } from "next/headers";
import { AuthError } from "own-auth";
import { defaultSessionCookieName } from "own-auth/http";

import { auth } from "@/lib/auth";

export async function changePassword(formData: FormData) {
  const token = (await cookies()).get(defaultSessionCookieName)?.value;
  if (!token) throw new Error("Unauthorized");

  const currentPassword = String(formData.get("currentPassword") ?? "");
  const newPassword = String(formData.get("newPassword") ?? "");

  if (!currentPassword || !newPassword) {
    return { error: "Both password fields are required" };
  }

  try {
    await auth.changePassword({
      sessionToken: token,
      currentPassword,
      newPassword,
    });
    return { changed: true };
  } catch (error) {
    if (error instanceof AuthError) {
      return { error: error.safeMessage };
    }
    throw error;
  }
}

Sign out

components/sign-out-button.tsx
"use client";

import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth-client";

export function SignOutButton() {
  const router = useRouter();

  async function signOut() {
    await authClient.signOut();
    router.push("/signin");
    router.refresh();
  }

  return <button onClick={signOut}>Sign out</button>;
}

Add magic links

Email-and-password authentication does not need APP_URL. Add it with magic links because Own Auth puts the public application origin in the emailed sign-in URL. Pass it as baseUrl in the existing auth instance and configure the application's email provider before requesting a link.

.env.local
APP_URL=https://app.example.com
lib/auth.ts
import "server-only";
import { createOwnAuth } from "own-auth";

const tokenPepper = process.env.OWN_AUTH_TOKEN_PEPPER!;
const appUrl = process.env.APP_URL!;

export const auth = createOwnAuth({
  tokenPepper,
  baseUrl: appUrl,
});

Request and verify links through the same browser client. Complete verification sets the HttpOnly session cookie.

lib/magic-links.ts
"use client";

import { authClient } from "@/lib/auth-client";

export async function requestMagicLink(email: string) {
  return authClient.requestMagicLink({ email });
}

export async function verifyMagicLink(token: string) {
  const result = await authClient.verifyMagicLink({ token });

  if (result.status === "mfa_required") {
    return { ...result, next: "/mfa" };
  }

  const { id, email, name, imageUrl } = result.user;
  return { next: "/dashboard", user: { id, email, name, imageUrl } };
}

Add OAuth

Once a provider is configured on the Own Auth instance, start its redirect flow from the client. The callback returns through /api/auth and sets the same session cookie.

lib/google-sign-in.ts
"use client";

import { authClient } from "@/lib/auth-client";

export function signInWithGoogle() {
  return authClient.signInWithOAuth({
    provider: "google",
    destination: "/dashboard",
  });
}