Skip to content

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.

app.config.ts
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:

todo-page.component.ts
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 }));
}
  1. The client is constructed disabled. On the server platform, provideConvex() creates the ConvexClient with disabled: true. No WebSocket is opened, and onUpdate registration is a no-op — a live subscription cannot exist inside a one-shot render.

  2. 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.

  3. 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 a finally block, so a rejected fetch cannot hang the render.

  4. Results are written to TransferState. On success the loader stores the result under the key cva:<queryName>:<argsKey>, encoded with convexToJson so non-JSON Convex values (such as Int64) survive the round trip. A rejected fetch transfers nothing.

  5. The helper settles like an emission. The resolved value lands in data() and status() becomes 'success'; a rejection lands in error() and status() 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.

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.

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.

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 ConvexSsrOptions reference 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.