Tulip Logo IconTulip
Auth

Auth Pages

Use Tulip's precomposed auth pages or compose login, password reset, and first-user flows from small primitives.

Tulip provides two levels of auth page API:

  • Default pages for standard routes with minimal code.
  • Composable primitives when an app needs custom copy, fields, footers, passkeys, or behavior hooks.

All page primitives are client components exported from @tulip-systems/auth/components/client.

Default pages

Use the default pages when the built-in composition is enough.

import {
  CreateFirstUserPage,
  ForgetPasswordPage,
  LoginPage,
  ResetPasswordPage,
} from "@tulip-systems/auth/components/client";
import { CreateFirstUserPageGuard } from "@tulip-systems/auth/next/guards";
import { auth } from "@/server/auth/init";

export function LoginRoute() {
  return <LoginPage options={{ callbackURLFallback: "/auth/redirect" }} />;
}

export function ForgotPasswordRoute() {
  return <ForgetPasswordPage />;
}

export function ResetPasswordRoute(props: {
  searchParams: Promise<{ email: string; otp: string }>;
}) {
  return <ResetPasswordPage searchParams={props.searchParams} />;
}

export function FirstUserRoute() {
  return (
    <CreateFirstUserPageGuard auth={auth}>
      <CreateFirstUserPage />
    </CreateFirstUserPageGuard>
  );
}

LoginPage intentionally contains email/password login only. Add LoginPasskeyButton through the composable API when the app configures Better Auth's passkey plugin.

Composable login

Compose a login route when you need to change the structure or include optional actions.

import {
  LoginDescription,
  LoginEmailField,
  LoginFooter,
  LoginForgotPasswordLink,
  LoginForm,
  LoginHeader,
  LoginPasskeyButton,
  LoginPasswordField,
  LoginSubmitButton,
  LoginTitle,
  LoginView,
} from "@tulip-systems/auth/components/client";

export default function Page() {
  return (
    <LoginView>
      <LoginHeader>
        <LoginTitle>Welcome back</LoginTitle>
        <LoginDescription>Sign in to continue.</LoginDescription>
      </LoginHeader>

      <LoginForm options={{ callbackURLFallback: "/auth/redirect" }}>
        <LoginEmailField autoComplete="email" />
        <LoginPasswordField autoComplete="current-password" />
        <LoginSubmitButton>Sign in</LoginSubmitButton>
        <LoginPasskeyButton>Sign in with a passkey</LoginPasskeyButton>
      </LoginForm>

      <LoginFooter>
        <LoginForgotPasswordLink href="/auth/forget-password">
          Forgot password?
        </LoginForgotPasswordLink>
      </LoginFooter>
    </LoginView>
  );
}

Remove LoginPasskeyButton when the app does not support passkeys. There is no feature flag for it: component composition is the opt-in.

Other composable forms

The remaining flows use the same convention: a View, Header, title/description components, a behavior-aware Form, field primitives, a submit button, and an optional Footer.

FlowFormFields
Forgot passwordForgetPasswordFormForgetPasswordEmailField
Reset passwordResetPasswordFormResetPasswordField, ResetPasswordConfirmationField
First-user setupCreateFirstUserFormCreateFirstUserFirstNameField, CreateFirstUserLastNameField, CreateFirstUserEmailField, CreateFirstUserPasswordField

For example, reset-password composition receives the verified email and OTP from the route:

<ResetPasswordForm email={email} otp={otp} options={{ redirectTo: "/auth/login" }}>
  <ResetPasswordField />
  <ResetPasswordConfirmationField />
  <ResetPasswordSubmitButton>Set new password</ResetPasswordSubmitButton>
</ResetPasswordForm>

Form behavior options

Each form exposes an options object for behavior that should remain close to the auth request:

  • callbackURLFallback selects the fallback login or first-user destination.
  • redirectTo and redirectOnSuccess control reset-password navigation.
  • successMessage and, where available, successDescription customize or suppress toasts.
  • onSuccess, onError, and onSettled support analytics, custom UI, and error reporting.

Return true from onError when the app handled the error and the default error toast should not be shown.

<LoginForm
  options={{
    callbackURLFallback: "/auth/redirect",
    onSuccess: ({ method }) => analytics.track("login", { method }),
    onError: ({ error }) => {
      reportError(error);
      return false;
    },
  }}
>
  {/* Login fields and actions */}
</LoginForm>

Role-based destinations

Use one login route. Do not create separate login pages just to choose a post-login destination, and do not decide the destination from the client.

Use a server-side redirect route as the fallback destination, then resolve the authenticated user's allowed default area there.

// app/auth/redirect/page.tsx
import { getSession } from "@tulip-systems/auth/next/server";
import { redirect } from "next/navigation";
import { auth } from "@/server/auth/init";

export default async function Page() {
  const session = await getSession(auth);

  if (!session) {
    redirect("/auth/login");
  }

  if (session.user.role === "customer") {
    redirect("/portal");
  }

  redirect("/admin");
}

An explicit callbackURL should only be honored after confirming that it is an internal URL and the authenticated user can access its destination. Keep authorization on the server; the redirect route only chooses navigation.

On this page