Auth in your app
Render and route on auth state with a component, a hook, or a route guard, and store your own data for a user.
There is no special concept to learn. convex-logto plugs into Convex's normal
auth, so you get all the standard ways to react to auth state:
useLogtoAuth(): a hook returning{ isAuthenticated, isLoading, user, signIn, signOut }.- Convex's
<Authenticated>/<Unauthenticated>/<AuthLoading>anduseConvexAuth()fromconvex/react, which work unchanged.
isAuthenticated / isLoading from useLogtoAuth() come from useConvexAuth(),
so they're authoritative. isAuthenticated is true only once Convex has
accepted the token. Gate on these and there's no flash of the wrong state.
The snippets import from convex-logto/react-session. In bridge
mode import from convex-logto/react instead; the five
fields above are the same. Session mode's hook adds signOutEverywhere() and
the device list, covered in Session mode.
Storing data for a user
You usually don't need a users table. Every Convex function already has the
signed-in user's identity from the token, and identity.subject is their stable
Logto id, so attach your data to your own tables, keyed by it:
import { v } from "convex/values";
import { mutation } from "./_generated/server";
export const createPost = mutation({
args: { body: v.string() },
handler: async (ctx, { body }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("unauthenticated");
await ctx.db.insert("posts", { body, authorId: identity.subject }); // the link
},
});The user isn't a row; their data points at identity.subject. That covers "the
current user and the things they own."
Reach for a synced users table only when you need users who aren't the current
caller (an admin list, another user's display name) or Logto-owned profile and
lifecycle fields mirrored into Convex (email, name, suspended, deleted). App-owned
data the token doesn't carry, such as preferences, plan, and feature flags, can
stay in your own tables keyed by identity.subject. See Webhook sync.
Declarative gating
The simplest app shell. Each branch renders only in its state; queries inside
<Authenticated> never run before auth settles.
import { Authenticated, Unauthenticated, AuthLoading } from "convex/react";
function App() {
return (
<>
<AuthLoading>
<Spinner />
</AuthLoading>
<Unauthenticated>
<SignInButton />
</Unauthenticated>
<Authenticated>
<Dashboard />
</Authenticated>
</>
);
}The hook
useLogtoAuth() is plain state. Call it at the top of your component, then use
the values and signIn / signOut anywhere: in return, event handlers,
effects, or conditionals.
import { useLogtoAuth } from "convex-logto/react-session";
function AuthButton() {
const { isAuthenticated, isLoading, user, signIn, signOut } = useLogtoAuth();
if (isLoading) return <Spinner />;
return isAuthenticated ? (
<button onClick={() => void signOut()}>Sign out ({user?.email ?? user?.sub})</button>
) : (
<button onClick={() => void signIn()}>Sign in</button>
);
}Route guards (TanStack Router)
For redirect-based protection you need auth outside render, in a route's
beforeLoad. Lift useLogtoAuth() into the router context and guard there.
This is the standard TanStack Router auth pattern; it works because
useLogtoAuth() is just state.
import { useEffect } from "react";
import { ConvexReactClient } from "convex/react";
import { ConvexLogtoSessionProvider, useLogtoAuth } from "convex-logto/react-session";
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./router";
import { api } from "../convex/_generated/api";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL, {
initialAuthTokenReuse: true,
});
// Lives inside the provider so useLogtoAuth() has its context.
function InnerApp() {
const auth = useLogtoAuth();
// Re-run every route's beforeLoad when the authn outcome flips (loading to
// settled, sign in or out). Key on the primitive booleans so it fires on those
// changes only, not on every token refresh or profile (`user`) update.
useEffect(() => {
void router.invalidate();
}, [auth.isLoading, auth.isAuthenticated]);
return <RouterProvider router={router} context={{ auth }} />;
}
root.render(
<ConvexLogtoSessionProvider
client={convex}
sessionApi={api.auth}
navigate={(to) => void router.navigate({ to, replace: true })}
>
<InnerApp />
</ConvexLogtoSessionProvider>,
);import {
createRootRouteWithContext,
createRoute,
createRouter,
redirect,
Outlet,
} from "@tanstack/react-router";
import type { useLogtoAuth } from "convex-logto/react-session";
type RouterAuthContext = { auth: ReturnType<typeof useLogtoAuth> };
const rootRoute = createRootRouteWithContext<RouterAuthContext>()({
component: () => <Outlet />,
});
// Protected layout route, guarded outside render in beforeLoad.
const authedRoute = createRoute({
getParentRoute: () => rootRoute,
id: "_authed",
beforeLoad: ({ context }) => {
if (context.auth.isLoading) return; // still settling; don't redirect yet
if (!context.auth.isAuthenticated) throw redirect({ to: "/signin" });
},
pendingComponent: () => <p>Checking access…</p>,
component: () => <Outlet />,
});
export const router = createRouter({
routeTree: rootRoute.addChildren([signinRoute, authedRoute.addChildren([dashboardRoute])]),
context: { auth: undefined! }, // <RouterProvider context> injects the real value
defaultPendingMs: 0, // no delay before pendingComponent while auth settles
});A few things here are load-bearing:
- Check
isLoadingfirst.isAuthenticatedisfalsewhile auth is still settling, so without theif (context.auth.isLoading) returnyou'd redirect a signed-in user on every reload, and bounce a just-returned user mid sign-in callback. The provider keepsisLoadingtrueacross that whole validation window, so the guard waits instead of redirecting. - Keep the
router.invalidate()effect. It re-runs the guard when auth resolves. Depend on[auth.isLoading, auth.isAuthenticated]. - Give the route a
pendingComponent(withdefaultPendingMs: 0) so the loading window shows it instead of a flash of protected content. - Pass
navigateto the provider so post-sign-in is a soft router navigation, and make it replace-style so the callback URL leaves history. - A guard decides what to render, not who may read. The Convex function
still checks
ctx.auth.getUserIdentity()itself. In session mode,assertSubjectHasActiveSession(ctx, components.logto)also makes it fail the moment the session is revoked.