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.
| Flow | Form | Fields |
|---|---|---|
| Forgot password | ForgetPasswordForm | ForgetPasswordEmailField |
| Reset password | ResetPasswordForm | ResetPasswordField, ResetPasswordConfirmationField |
| First-user setup | CreateFirstUserForm | CreateFirstUserFirstNameField, 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:
callbackURLFallbackselects the fallback login or first-user destination.redirectToandredirectOnSuccesscontrol reset-password navigation.successMessageand, where available,successDescriptioncustomize or suppress toasts.onSuccess,onError, andonSettledsupport 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.