Skip to content

Authenticated SSR

A server render has no browser session: the internal HTTP client starts unauthenticated, so queries that depend on ctx.auth see an anonymous caller and your page renders signed-out. ssr.authToken is the hook that fixes this — it hands the server render a JWT taken from the incoming request.

provideConvex(convexUrl, {
ssr: {
authToken: () => readTokenFromCurrentRequest(),
},
});

The factory is resolved at most once per render and applied with setAuth before the first query goes out. See Configuration for the exact evaluation rules.

There is one HTTP client per server render, so setAuth is applied once and every query fetched during that render is authenticated — including queries that do not need auth. That is usually what you want (the render is on behalf of one user), but it means the “authenticated” gate is render-global: if a token was applied, transferAuthenticatedResults: false suppresses the transfer for all results of that render, not only the user-specific ones.

If you would rather not embed user data in the markup at all, set:

provideConvex(convexUrl, {
ssr: {
authToken: () => readTokenFromCurrentRequest(),
transferAuthenticatedResults: false,
},
});

The server still fetches with the token and still renders real content, so there is no layout shift and the page is complete for crawlers and for users with JavaScript disabled. Only the TransferState payload is skipped. The trade-off is on the client: with nothing to seed from, each query starts pending after hydration and shows its loading branch until the live subscription delivers — a brief flash rather than none.

Approach Server HTML Data in the payload Client after hydration Cacheability
authToken only (default transfer) User’s content Yes Instant, no loading state Private / no-store only
authToken + transferAuthenticatedResults: false User’s content No Brief loading state Still private — the HTML itself is user-specific
No authToken Signed-out view Yes (public data) Instant, no loading state Cacheable like any anonymous page

provideConvex() validates its own placement eagerly at bootstrap. Registering it twice in the same injector throws:

`provideConvex(...)` was registered more than once in the same injector. Register it exactly once in your root application providers (for example, in `app.config.ts`).

Registering it in a child injector below one that already registered it throws:

`provideConvex(...)` must be configured only in your root application providers (for example, in `app.config.ts`). Remove nested or route-level registrations.

This matters for authenticated SSR because the obvious wiring does not work. In the Angular CLI’s SSR scaffold, app.config.server.ts merges a server-only config into the shared one:

app.config.server.ts — does NOT work
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
// Both configs are merged into the SAME root injector, so this is a
// second registration and throws at bootstrap.
provideConvex(environment.convexUrl, { ssr: { authToken } }),
],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);

mergeApplicationConfig concatenates providers into one root injector, so the merged result contains two provideConvex(...) registrations and fails the duplicate check.

Approach 1 — parameterize the shared config

Section titled “Approach 1 — parameterize the shared config”

Export the root config as a factory that takes ConvexSsrOptions. The browser entry point calls it with nothing; the server entry point calls it with an authToken. There is still exactly one provideConvex(...) call.

app.config.ts
import { ApplicationConfig } from '@angular/core';
import { ConvexSsrOptions, provideConvex } from 'convex-angular';
// Stand-in for your environment file or build-time define.
const environment = { convexUrl: 'https://your-deployment.convex.cloud' };
/**
* One shared root configuration, parameterized by the SSR options.
*
* `provideConvex(...)` is root-only and may be registered exactly once, so the
* server cannot add a second call on top of the browser config. Passing the
* options in instead keeps a single registration: the browser entry point
* calls `createAppConfig()` with no SSR options, and the server entry point
* calls it with an `authToken` closed over the incoming request.
*/
export function createAppConfig(ssr: ConvexSsrOptions = {}): ApplicationConfig {
return {
providers: [
// provideClientHydration() from '@angular/platform-browser' goes here too.
provideConvex(environment.convexUrl, { ssr }),
],
};
}
// Browser entry point: no server-side options.
export const appConfig = createAppConfig();

Then build the configuration per request, closing over the request itself:

server.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideServerRendering, renderApplication } from '@angular/platform-server';
import { App } from './app/app';
import { createAppConfig } from './app/app.config';
app.get('**', async (req, res) => {
const token = readTokenFromCookies(req);
const html = await renderApplication(
() =>
bootstrapApplication(App, {
providers: [provideServerRendering(), ...createAppConfig({ authToken: () => token }).providers],
}),
{ document: indexHtml, url: req.originalUrl },
);
// The response is now one user's private data.
res.set('Cache-Control', 'private, no-store');
res.send(html);
});

If you use @angular/ssr’s standard node handler and cannot build the config per request, keep the shared config’s authToken stable and have it read from an AsyncLocalStorage store that the request handler populates. The store follows the async call chain into the render, and unlike a module-level variable it stays correct under concurrent requests.

server.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export const requestStore = new AsyncLocalStorage<{ token: string | null }>();
app.use('**', (req, res, next) => {
requestStore.run({ token: readTokenFromCookies(req) }, () => {
if (requestStore.getStore()?.token) {
res.set('Cache-Control', 'private, no-store');
}
angularApp
.handle(req)
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next);
});
});
app.config.ts
provideConvex(environment.convexUrl, {
ssr: {
// Stable closure, per-request value. Never call inject() here — the
// factory runs outside any injection context.
authToken: () => requestStore.getStore()?.token ?? null,
},
});

requestStore is server-only code; keep it behind the server entry point so node:async_hooks never reaches the browser bundle.

Approach 3 — do not server-render private data

Section titled “Approach 3 — do not server-render private data”

The blunt option is fetchOnServer: false. The server renders the shell, every query loads live after hydration, and no user data ever enters the HTML. It costs a loading state on every page, but it removes the caching hazard entirely and needs no request plumbing.

If authToken rejects — an expired session, an unreachable identity provider — the failure is memoized for that render and every query fetch fails with it. Each helper reports status() === 'error', nothing is transferred, and the browser retries live after hydration, where the user’s real session applies. Render an error branch that degrades gracefully rather than assuming the server fetch always succeeds.