Server-side rendering
Angular SSR works with convex-angular without any extra Convex configuration. During the
server render, query helpers fetch their data over HTTP, Angular waits for those fetches before it
serializes the HTML, and the results travel to the browser inside TransferState. The hydrated
application reads them back and renders the same content on its first change detection, then the
live WebSocket subscription takes over.
What you wire up
Section titled “What you wire up”import { ApplicationConfig } from '@angular/core';import { provideClientHydration } from '@angular/platform-browser';import { provideConvex } from 'convex-angular';
import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = { providers: [provideClientHydration(), provideConvex(environment.convexUrl)],};That is the whole setup. provideConvex() lives in the shared root config used by both the
browser and the server bootstrap — it is root-only and may be registered exactly once, so it must
not be repeated in a server-only config (see
Authenticated SSR).
provideClientHydration() is Angular’s DOM-reuse hydration. Query transfer itself only depends on
TransferState, so it works for any platform-server render, but without hydration the browser
discards the server DOM and you lose most of the benefit.
Nothing in a component changes for SSR:
import { Component } from '@angular/core';import { injectQuery } from 'convex-angular';
import { api } from './convex/api';
/** * There is no SSR-specific code in a component. On the server the query is a * one-shot HTTP fetch that Angular waits for; in the browser the same call * seeds from the transferred result and then opens the live subscription. */@Component({ selector: 'app-todo-page', template: ` @switch (todos.status()) { @case ('pending') { <p>Loading…</p> } @case ('error') { <p>{{ todos.error()?.message }}</p> } @case ('success') { @for (todo of todos.data() ?? []; track todo._id) { <p>{{ todo.title }}</p> } } } `,})export class TodoPageComponent { // Server render: 'pending' -> 'success' once the HTTP fetch settles. // Hydrated browser render: 'success' on the first change detection, because // the transferred result seeds the query synchronously. readonly todos = injectQuery(api.todos.list, () => ({ count: 20 }));}What happens on the server
Section titled “What happens on the server”-
The client is constructed disabled. On the server platform,
provideConvex()creates theConvexClientwithdisabled: true. No WebSocket is opened, andonUpdateregistration is a no-op — a live subscription cannot exist inside a one-shot render. -
Queries fetch over HTTP. Because there is no subscription, each query helper routes through an internal server loader that uses Convex’s
ConvexHttpClient. Fetches are deduplicated by query name plus serialized args, so ten components asking for the same data during one render produce one request — and one consistent error if it fails. -
Angular waits for the data. Every fetch registers a task with Angular’s
PendingTasks, so the application is not stable — and SSR does not serialize — until the fetch settles. The task is released in afinallyblock, so a rejected fetch cannot hang the render. -
Results are written to
TransferState. On success the loader stores the result under the keycva:<queryName>:<argsKey>, encoded withconvexToJsonso non-JSON Convex values (such asInt64) survive the round trip. A rejected fetch transfers nothing. -
The helper settles like an emission. The resolved value lands in
data()andstatus()becomes'success'; a rejection lands inerror()andstatus()becomes'error'. The server-rendered HTML therefore contains whichever branch your template renders for that status.
Unlike hydration seeding in the browser, the server fetch does fire onSuccess (or onError) —
it is a real result, not a seed.
What happens in the browser
Section titled “What happens in the browser”On the first change detection after hydration, each query helper looks for a transferred entry for
its query and args. A hit sets data(), clears error(), and reports status() === 'success'
synchronously — no loading flash, and the hydrated DOM matches the server HTML. The live
subscription is still opened immediately afterwards and silently replaces the seed as soon as it
syncs.
The seeding window closes permanently once ApplicationRef.whenStable() resolves. Everything after
that is an ordinary live query. The precedence rules, the paginated-query specifics, and the
key-matching footgun are covered in Hydration semantics.
Behavior per helper
Section titled “Behavior per helper”| Helper | During the server render | After hydration |
|---|---|---|
injectQuery() |
One-shot HTTP fetch; pending until it settles, then success or error. onSuccess/onError fire. |
Seeds from the transferred result, reports success immediately, then the live subscription takes over. |
injectQueries() |
Same, once per definition entry. | Same, once per key. |
injectPaginatedQuery() |
Fetches the first page over HTTP with paginationOpts: { numItems: initialNumItems, cursor: null }. |
Seeds the first page; loadMore() stays inert until a real emission arrives. |
convexQueryResolver() |
Fetches over HTTP through the same loader; a failure resolves undefined instead of blocking navigation. |
Subscribes live and keeps the subscription warm for the routed component. |
injectPrewarmQuery() |
prewarm() is a no-op and resolves false — a background subscription would only delay SSR stability. |
Normal prewarming. |
injectMutation() / injectAction() |
Throw ConvexClient is disabled if invoked. |
Normal. |
injectConvexConnectionState() |
A static disconnected snapshot that never updates. See Connection state. | Live connection state. |
injectAuth() |
Nothing is pushed to the (absent) socket; the disabled client is never touched. | Normal auth wiring. |
When a fetch fails
Section titled “When a fetch fails”A failed server fetch never aborts the render. The rejection is delivered to every consumer of that
query, the helper reports status() === 'error', and the error branch of your template is what gets
serialized. Nothing is written to TransferState, so the hydrated browser has no seed and simply
performs a normal pending load over the live subscription — which usually succeeds, since the
failure was often a server-side networking or auth problem.
- Configuration — the
ConvexSsrOptionsreference and the full degradation matrix. - Authenticated SSR — rendering user-specific pages, and the caching rule that comes with them.
- Hydration semantics — precedence, the seeding window, and key matching.