convex-logto

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.

Use NEXT_PUBLIC_CONVEX_URL, not import.meta.env.

Session mode

app/providers.tsx
"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

app/callback/page.tsx
// 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>;
}

App Router route handlers take and return standard Request/Response, so the handler mounts as a catch-all:

app/api/logto/[route]/route.ts
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:

server/logto-cookie.ts
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:

app/page.tsx
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 Authorization header 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:
proxy.ts
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.

app/providers.tsx
"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.

On this page