Next.js
Mount the provider behind a "use client" boundary in the Next.js App Router. Session mode adds a cookie transport and server rendering with a real identity.
Finish Quick start steps 1 to 3, then mount the provider.
Both providers use hooks and window, so they live in a "use client"
component; app/layout.tsx stays a Server Component and just renders it.
NEXT_PUBLIC_CONVEX_URL, not import.meta.env.Session mode
"use client";
import { ConvexReactClient } from "convex/react";
import { ConvexLogtoSessionProvider } from "convex-logto/react-session";
import { useRouter } from "next/navigation";
import { api } from "../convex/_generated/api";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!, {
initialAuthTokenReuse: true,
});
export function Providers({ children }: { children: React.ReactNode }) {
const router = useRouter();
return (
<ConvexLogtoSessionProvider
client={convex}
sessionApi={api.auth}
navigate={(to) => router.replace(to)}
>
{children}
</ConvexLogtoSessionProvider>
);
}No Logto config reaches the bundle; session mode keeps every value on the Convex deployment.
Callback route
// Server Component; the provider in app/providers.tsx finishes the exchange
// under it and replace-navigates away.
export default function CallbackPage() {
return <p>Finishing sign in…</p>;
}Cookie transport
App Router route handlers take and return standard Request/Response, so the
handler mounts as a catch-all:
import { logtoCookieHandler } from "@/server/logto-cookie";
export const POST = logtoCookieHandler;
export const OPTIONS = logtoCookieHandler;Add cookieTransport={{ endpoint: "/api/logto" }} to the provider above. The
handler module and its allowedOrigins are in
the cookie transport section.
Do not call getInitialToken() from a layout or page. It rotates the session
cookie, and a Server Component cannot set cookies; Next.js only allows that in
Route Handlers, Server Actions, and the proxy (middleware before Next 16).
Next.js would drop the rotated cookie, leaving the browser holding a superseded
token. The browser eventually presents that token outside the reuse window, the
component reads it as token reuse, and that kills the session. Use
readLogtoIdTokenCookie below instead; getInitialToken() is still the right
call inside a Route Handler, Server Action, or the proxy, where you can
forward its headers.
Server-side rendering
To render authenticated content on the server you need an ID token, and the only credential a Server Component can reach is a cookie it does not have to rotate. Turn on the companion cookie:
export const logtoCookieHandler = createLogtoSessionCookieHandler({
sessionApi: api.auth,
action: (reference, args) => client.action(reference, args),
allowedOrigins: ["https://app.example.com"],
basePath: "/api/logto",
idTokenCookie: true,
});The route handler now writes the ID token to an HttpOnly cookie alongside the
session cookie, with Max-Age set from the token's own exp. The handler mints
nothing new and rotates nothing, so the reuse detection that protects the
session token is untouched. Read it anywhere:
import { readLogtoIdTokenCookie } from "convex-logto";
import { preloadQuery } from "convex/nextjs";
import { cookies } from "next/headers";
import { api } from "@/convex/_generated/api";
export default async function Page() {
const token = readLogtoIdTokenCookie(await cookies());
const preloaded = await preloadQuery(api.me.me, {}, token ? { token } : {});
return <Me preloaded={preloaded} />;
}readLogtoIdTokenCookie takes a Request, a raw Cookie header, or a store
shaped like Next's cookies(); there is no convex-logto/nextjs entry to keep
in step with Next's release train, and the same call works in TanStack Start or a
bare handler.
A token read here proves nothing on its own; it is a bearer Convex validates,
and null means "render the signed-out view and let the client take
over". Revocation lives where it always does. assertSubjectHasActiveSession
inside the function you call enforces it; this read does not. A cookie whose
token has expired is gone with it, so the worst case is an unauthenticated first
paint.
Two consequences worth knowing before you turn it on:
- The browser attaches a cookie to every same-origin request, so the ID
token reaches access logs and proxies that an
Authorizationheader does not. That is the custody trade-off, and it is why this is opt-in; see ADR 0002. - The cookie is only as fresh as the last client-side refresh. If you want every server render to have a live token, refresh in the proxy (Next 16's rename of middleware), which can set cookies:
import { NextResponse } from "next/server";
import { logtoCookieHandler } from "@/server/logto-cookie";
export default async function proxy(request: Request) {
const response = NextResponse.next();
// Documents only. Every call rotates the session token, and a matcher that
// also catches favicons, images and RSC prefetches fires several rotations
// for one page view. After that the generation the browser keeps may be
// older than the server's, and the next client refresh presents it outside
// its reuse window, which the component reads as theft.
if (request.headers.get("sec-fetch-dest") !== "document") return response;
const seed = await logtoCookieHandler.getInitialToken(request);
for (const cookie of seed.headers.getSetCookie()) {
response.headers.append("Set-Cookie", cookie);
}
// The seed also carries `Cache-Control: no-store`. Forward it. A per-user
// `Set-Cookie` on a response that something upstream believes it may cache
// is how one visitor's session reaches another.
for (const [name, value] of seed.headers) {
if (name.toLowerCase() !== "set-cookie") response.headers.set(name, value);
}
return response;
}
export const config = { matcher: ["/((?!_next|api).*)"] };The provider's SSR seed does not apply here. It takes initialToken and
initialSessionId as a pair, and only getInitialToken() produces the
session id, so a page render cannot obtain one. Render the identity on the
server with preloadQuery as above, and let the client establish its own auth
on mount.
Full app: examples/nextjs-session,
with the cookie transport mounted at app/api/logto/[route]/route.ts, the
identity rendered on the server with preloadQuery in app/page.tsx, and the
document-only refresh in proxy.ts.
Full walkthrough: Session mode.
Bridge mode
Bridge mode keeps the same client boundary and the same
callback page. The provider takes the two public Logto values from
NEXT_PUBLIC_* env.
"use client";
import { ConvexReactClient } from "convex/react";
import { ConvexLogtoProvider } from "convex-logto/react";
import { useRouter } from "next/navigation";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function Providers({ children }: { children: React.ReactNode }) {
const router = useRouter();
return (
<ConvexLogtoProvider
client={convex}
config={{
endpoint: process.env.NEXT_PUBLIC_LOGTO_ENDPOINT!,
appId: process.env.NEXT_PUBLIC_LOGTO_APP_ID!,
}}
navigate={(to) => router.replace(to)}
>
{children}
</ConvexLogtoProvider>
);
}The runnable examples/nextjs
resolves the same two values at runtime with configQuery={api.logto.config}
instead, so its build carries no Logto values. Either works; static config
skips the query round trip before first paint.
TanStack Start
One SSR-safe provider for TanStack Start, no client boundary. Session mode gets a cookie transport and an authenticated first paint.
Expo (React Native)
Session mode or bridge mode in a Convex Expo app. The system browser signs in and an app deep link brings the user back; there is no callback route.