6.1 KiB
Reading the user, protecting routes, and preventing flicker
When sign-in UI may render (hard rules)
- Connector / owner-data apps: never gate the data fetch behind a session. When the data comes from the app's connector grants, the fetch runs on the gate-injected connector token — a chain that does not involve the app session. Fetch first; use the session for personalization only (greeting, per-user rows). Requiring sign-in before fetching adds a click that changes nothing.
- A sign-in CTA may render only after the session check has RESOLVED to no
user — never while
isPendingis true (no CTA flash on load), and never as a reaction to a data error (theapp-dataskill's error mapping owns those states; only itsloginkind ever shows "Continue with Grok"). - In preview, gate identity signs the owner in with zero clicks — a
visible sign-in button for the owner in the preview is a bug to fix, not a
style choice (
grok-identity.md). - Use
<SignInGate>from@/lib/auth/gates—{ children, fallback? }: nothing while pending,childrenwhen signed in,fallback(or the standard provider buttons) only once signed out is known. Do not hand-roll sign-in CTAs fromuseCurrentUser().
Reading the user / protecting routes
@/lib/auth/use-current-user (with auth on these reflect the REAL session, so a
preview visitor is signed out until they sign in):
useCurrentUser()→AppUser | null— for display.nullmeans loading OR signed out, so never redirect on it alone.useCurrentUserState()→{ user, isPending }— for guards: wait forisPendingto clear before treatinguser: nullas signed out, or a hard reload bounces signed-in users to sign-in.
State components from @/lib/auth/gates: SignedIn, SignedOut,
SignInGate ({ children, fallback? } — nothing while pending, children
signed in, fallback or the standard provider buttons only once signed out is
known; prefer it over hand-rolled sign-in CTAs), RedirectToSignIn,
UserButton. (When auth is disabled via VITE_AUTH_ENABLED=false they apply
dev-user semantics so a non-auth app still renders.)
import { useCurrentUser, useCurrentUserState } from "@/lib/auth/use-current-user";
import { RedirectToSignIn, SignedIn, SignedOut, UserButton } from "@/lib/auth/gates";
function Navbar() {
const user = useCurrentUser(); // display only — null may just mean "loading"
return (
<>
<span>{user?.displayName ?? "Guest"}</span>
<SignedOut><a href="/login">Sign in</a></SignedOut>
<SignedIn><UserButton /></SignedIn>
</>
);
}
function AccountPage() {
const { user, isPending } = useCurrentUserState();
if (isPending) return null; // session still resolving
if (!user) return <RedirectToSignIn />; // client-side Navigate — not window.location
return <h1>Welcome, {user.displayName}</h1>;
}
Sign out with <UserButton /> or signOut() from @/lib/auth/client.
Session loading is the same in live preview and when deployed: wait for
isPending from useCurrentUserState() (backed by /api/auth/get-session).
The only live-preview difference is how sign-in starts (popup + bearer hand-off
instead of a full-page OAuth redirect) — not how guests vs signed-in users are
detected. Prefer <RedirectToSignIn /> (TanStack <Navigate>) over
window.location.href = "/login" so a signed-out redirect does not full-reload
the SPA.
Preventing auth flicker
useSession() resolves on the client, so a naive UI flashes signed-out →
signed-in on load. Rules:
-
Gate on
isPending, notuseralone — and render a same-sized skeleton. Showing the SAME placeholder whileisPending(server render + first client paint) makes it one clean swap (skeleton → content) with no flash and no SSR hydration mismatch. Don't returnnullin a slot that then grows — reserve the space:import { useCurrentUserState } from "@/lib/auth/use-current-user"; import { UserButton } from "@/lib/auth/gates"; function AuthSlot() { const { user, isPending } = useCurrentUserState(); if (isPending) return <div className="h-8 w-8 animate-pulse rounded-full bg-black/10" />; return user ? <UserButton /> : <a href="/login">Sign in</a>; } -
Guard at a layout boundary (nav / page shell), not in leaf components that mount/unmount —
useSessionis one shared store, so keep one stable consumer per region instead of re-gating everywhere. -
Zero-flash when deployed: SSR the session from the cookie. On a deployed app (and top-level navigations) the session cookie is same-origin, so the server already knows the user on the first request — resolve it in the root route and render the authed shell immediately:
// src/routes/__root.tsx (excerpt) import { createServerFn } from "@tanstack/react-start"; import { createRootRoute } from "@tanstack/react-router"; const fetchSessionUser = createServerFn({ method: "GET" }).handler(async () => { // Cookie path only — works when deployed / on top-level loads. const { getSessionUser } = await import("@/lib/auth/verify.server"); const u = await getSessionUser(); return u ? { id: u.id, email: u.email } : null; }); export const Route = createRootRoute({ beforeLoad: async () => ({ sessionUser: await fetchSessionUser() }), // Merge into the existing root — keep the existing head(). // component: prefer `sessionUser` for the FIRST paint when deployed, then // `useCurrentUserState()` for live in-page updates. });Sign-in/out navigate, so
beforeLoadre-runs and the context stays fresh; callrouter.invalidate()if you change auth state without navigating.In live preview the session often rides a bearer after popup sign-in, so cookie SSR may still return null until the client
useSession()runs with the bearer attached — still gate onisPending, same as when deployed.
The template already enables Better Auth's session.cookieCache, so /get-session
answers from a cookie when one is present (no DB round-trip).