Trovella Wiki

Sign-In Flow

Step-by-step walkthrough of Google OAuth sign-in, session creation, and personal organization bootstrapping.

This page walks through the complete sign-in flow from the user's first click through to a fully bootstrapped dashboard session.

The Sign-In Page

The sign-in page at apps/web/src/app/auth/sign-in/page.tsx is a server component that checks for an existing session first:

export default async function SignInPage() {
  const session = await getSession();
  if (session) redirect("/");
  // ... render sign-in card
}

If the user is already authenticated, they are redirected to the home page. Otherwise, the page renders a card with the GoogleSignInButton client component.

Google OAuth Redirect

The GoogleSignInButton component (apps/web/src/components/auth/google-sign-in-button.tsx) is a "use client" component that calls Better Auth's social sign-in method:

await authClient.signIn.social({
  provider: "google",
  callbackURL: "/",
});

This redirects the browser to Google's OAuth consent screen. The scopes requested are openid, email, and profile -- the minimum needed to identify the user.

OAuth Callback

After the user consents, Google redirects back to Better Auth's callback endpoint. The catch-all route handler at apps/web/src/app/api/auth/[...all]/route.ts forwards all auth requests to Better Auth:

import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@repo/auth/server";

export const { GET, POST } = toNextJsHandler(auth);

Better Auth exchanges the authorization code for tokens, then creates or updates three database records:

TableWhat is created
userUser identity: id, name, email, image, emailVerified
accountOAuth link: providerId: "google", accessToken, refreshToken, idToken, scope
sessionActive session: id, token, userId, expiresAt, activeOrganizationId: null

A session cookie is set on the response, and the browser is redirected to the callbackURL (the home page /).

Personal Organization Bootstrapping

The home page renders the DashboardPage server component, which runs two idempotent helpers on every render:

export async function DashboardPage({ session, children }) {
  await ensurePersonalOrganization(session.user.id, session.user.name, session.user.email);
  await ensureActiveOrganization(
    session.session.id,
    session.user.id,
    session.session.activeOrganizationId,
  );
  // ... render providers and shell
}

ensurePersonalOrganization()

Checks if the user has any existing org membership. If they do, returns early (idempotent). If not (first sign-in), creates:

  1. An organization record with name: "{displayName}'s Space", a slugified slug, and metadata: { type: "personal" }
  2. A member record with role: "owner" linking the user to the new org

The slug is generated from the user's name (or email prefix if no name), lowercased, non-alphanumeric characters replaced with hyphens, truncated to 48 characters.

This function uses Better Auth's internal adapter (auth.$context then ctx.adapter) rather than the database hooks. The databaseHooks.user.create.after hook fires before the session is fully established, so auth.api.createOrganization (which needs a session context) cannot be used there.

ensureActiveOrganization()

If the session already has an activeOrganizationId, returns it immediately (idempotent). Otherwise:

  1. Queries the user's first membership
  2. Updates the session record to set activeOrganizationId to that membership's organizationId

This guarantees that single-org users (the common case) never need to manually select an organization. Without this, tenantProcedure would reject every tRPC request with "No active organization selected."

Returning User Flow

For users who already have an account and a valid session cookie:

  1. The sign-in page detects the session and redirects to /
  2. DashboardPage calls both helpers, which return early immediately (idempotent)
  3. The dashboard renders normally

For users with an expired session cookie, Better Auth returns null from getSession(), the page redirects to /auth/sign-in, and the flow starts over.

Sign-Out

Sign-out is handled by a POST route handler at apps/web/src/app/auth/sign-out/route.ts:

export async function POST() {
  await auth.api.signOut({ headers: await headers() });
  return NextResponse.redirect(new URL("/", process.env["BETTER_AUTH_URL"]));
}

Better Auth invalidates the session in the database and clears the session cookie. The user is redirected to the home page, which renders the landing page for unauthenticated visitors.

Flow Diagram

The complete sequence diagram is maintained in docs/architecture/04-auth-flow.md. It covers three scenarios: first-time sign-in (with org bootstrapping), subsequent request (with cookie cache), and sign-out.

On this page