The Angular client for Convex.
- ✨ Features
- 🚀 Getting Started
- 📖 Usage
- Fetching data —
injectQuery - Fetching multiple queries —
injectQueries - Prewarming queries —
injectPrewarmQuery - Preloading route data —
convexQueryResolver - Mutating data —
injectMutation - Running actions —
injectAction - Handling Convex errors —
ConvexError - Paginated queries —
injectPaginatedQuery - Optimistic paginated updates —
insertAtTop,insertAtPosition, ... - Conditional queries with skipToken
- Using the Convex client —
injectConvex - Monitoring connection state —
injectConvexConnectionState - Creating helpers outside the initial injection context
- Fetching data —
- 🔐 Authentication
- 🖥️ Server-side rendering
- 🧪 Testing
- 🤝 Contributing
- ⚖️ License
- 🔌 Core providers:
provideConvex,injectQuery,injectQueries,injectPrewarmQuery,injectMutation,injectAction,injectPaginatedQuery,injectConvex, andinjectConvexConnectionState - 🔐 Authentication: Built-in support for Clerk, Auth0, and custom auth providers via
injectAuth - 🛡️ Route Guards: Protect routes with
convexAuthGuard - 🧭 Route Resolvers: Preload query data before navigation with
convexQueryResolver - 🎯 Auth Directives:
*cvaAuthenticated,*cvaUnauthenticated,*cvaAuthLoading,*cvaAuthRefreshing - 📄 Pagination: Built-in support for paginated queries with
loadMoreandreset - ⚡ Optimistic pagination helpers:
insertAtTop,insertAtBottomIfLoaded,insertAtPosition - ⏭️ Conditional Queries: Use
skipTokento conditionally skip queries - 📡 Signal Integration: Angular Signals for reactive state
- 🖥️ Server-side rendering: zero-config Angular SSR/hydration support — queries are fetched on the server, transferred via
TransferState, and seeded without a loading flash - 🧹 Auto Cleanup: Automatic lifecycle management for subscriptions and helper-owned reactive state
- Install the dependencies:
npm install convex convex-angular- Add
provideConvexonce to your rootapp.config.tsproviders:
import { ApplicationConfig } from '@angular/core';
import { provideConvex } from 'convex-angular';
export const appConfig: ApplicationConfig = {
providers: [provideConvex('https://<your-convex-deployment>.convex.cloud')],
};provideConvex(...) must be configured only once at the root application level.
Do not register it again in nested or route-level providers.
- 🎉 That's it! You can now use the injection providers in your app.
Note: In the examples below,
apirefers to your generated Convex function references (usually fromconvex/_generated/api). Adjust the import path to match your project structure.
Use injectQuery to fetch data from the database.
import { Component } from '@angular/core';
import { injectQuery } from 'convex-angular';
// Adjust the import path to match your project structure.
import { api } from '../convex/_generated/api';
@Component({
selector: 'app-root',
template: `
@if (todos.isLoading()) {
<p>Loading...</p>
}
@if (todos.error()) {
<p>Error: {{ todos.error()?.message }}</p>
}
<ul>
@for (todo of todos.data() ?? []; track todo._id) {
<li>{{ todo.title }}</li>
}
</ul>
`,
})
export class AppComponent {
readonly todos = injectQuery(api.todos.listTodos, () => ({ count: 10 }));
}data() is typed as T | undefined. Handle the initial/skipped state with
?. or ?? until the first successful result arrives.
Use injectQueries when you need to subscribe to a dynamic set of keyed queries
and read their results together.
import { Component, signal } from '@angular/core';
import { injectQueries, skipToken } from 'convex-angular';
import { api } from '../convex/_generated/api';
@Component({
selector: 'app-dashboard',
template: `
@if (queries.isLoading()) {
<p>Loading dashboard...</p>
}
@if (queries.statuses().user === 'success') {
<p>Welcome back, {{ queries.results().user?.name }}</p>
}
<ul>
@for (todo of queries.results().todos ?? []; track todo._id) {
<li>{{ todo.title }}</li>
}
</ul>
`,
})
export class DashboardComponent {
readonly userId = signal<string | null>('user-1');
readonly queries = injectQueries(() => ({
user: this.userId() ? { query: api.users.getProfile, args: { userId: this.userId() } } : skipToken,
todos: { query: api.todos.listTodos, args: { count: 10 } },
}));
}The multi-query result provides:
results()- Keyed query resultserrors()- Keyed query errorsstatuses()- Keyed query statusesisLoading()- True while any active query is pending
Use injectPrewarmQuery to warm the local Convex cache before a route
transition or other UI work that is likely to need a query soon.
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { injectPrewarmQuery } from 'convex-angular';
import { api } from '../convex/_generated/api';
@Component({
selector: 'app-users',
template: ` <button (click)="openProfile('user-1')">Open profile</button> `,
})
export class UsersComponent {
private readonly router = inject(Router);
readonly prewarmProfile = injectPrewarmQuery(api.users.getProfile);
openProfile(userId: string) {
this.prewarmProfile.prewarm({ userId });
void this.router.navigate(['/users', userId]);
}
}By default the warm subscription stays alive for 5 seconds. Override that with
extendSubscriptionFor when needed.
Use convexQueryResolver to block navigation until a query's first result is
available locally. By the time the routed component is created, its
injectQuery(...) for the same query and args reads the warm cache and renders
without a loading state.
// app.routes.ts
import { Routes } from '@angular/router';
import { convexQueryResolver } from 'convex-angular';
import { api } from '../convex/_generated/api';
export const routes: Routes = [
{
path: 'users/:id',
loadComponent: () => import('./user-profile.component').then((m) => m.UserProfileComponent),
resolve: {
profile: convexQueryResolver(api.users.getProfile, (route) => ({
userId: route.paramMap.get('id')!,
})),
},
},
];
// user-profile.component.ts — renders instantly from the warm cache
export class UserProfileComponent {
private readonly route = inject(ActivatedRoute);
readonly profile = injectQuery(api.users.getProfile, () => ({
userId: this.route.snapshot.paramMap.get('id')!,
}));
}Resolution never blocks navigation on failure: subscription errors resolve
undefined and the component's own injectQuery surfaces the error
reactively. The resolver keeps its subscription warm for 5 seconds after
resolving (configurable via keepSubscribedFor) so the component's
subscription deduplicates onto it. During server-side rendering the resolver
fetches over HTTP and transfers the result to the browser, like injectQuery.
Use injectMutation to mutate the database.
import { Component } from '@angular/core';
import { injectMutation } from 'convex-angular';
import { api } from '../convex/_generated/api';
@Component({
selector: 'app-root',
template: ` <button (click)="addTodoItem()">Add Todo</button> `,
})
export class AppComponent {
readonly addTodo = injectMutation(api.todos.addTodo);
async addTodoItem() {
try {
await this.addTodo.mutate({ title: 'Buy groceries' });
} catch (error) {
console.error(error);
}
}
}mutate() rejects on failure. error() and status() are still updated, and
onError still runs before the promise rejects.
data() is typed as T | undefined and stays undefined until the first
successful mutation result or after reset().
If the owning Angular scope is destroyed while a mutation is in flight, the
returned promise still settles, but the helper stops updating its reactive
state and stops firing onSuccess / onError.
Use injectAction to run actions.
import { Component } from '@angular/core';
import { injectAction } from 'convex-angular';
import { api } from '../convex/_generated/api';
@Component({
selector: 'app-root',
template: `<button (click)="completeAll()">Complete All Todos</button>`,
})
export class AppComponent {
readonly completeAllTodos = injectAction(api.todoFunctions.completeAllTodos);
async completeAll() {
try {
await this.completeAllTodos.run({});
} catch (error) {
console.error(error);
}
}
}run() rejects on failure. error() and status() are still updated, and
onError still runs before the promise rejects.
data() is typed as T | undefined and stays undefined until the first
successful action result or after reset().
If the owning Angular scope is destroyed while an action is in flight, the
returned promise still settles, but the helper stops updating its reactive
state and stops firing onSuccess / onError.
Every helper's error() signal (and onError callback) is typed as Error,
but errors thrown by your Convex functions via ConvexError carry a typed
data payload. Narrow with instanceof to read it — ConvexError is
re-exported from convex-angular for convenience:
import { ConvexError, injectMutation } from 'convex-angular';
readonly addTodo = injectMutation(api.todos.addTodo, {
onError: (err) => {
if (err instanceof ConvexError) {
// Typed application error from your Convex function
this.toast.error(err.data.message);
} else {
// Transport or unexpected error
this.toast.error('Something went wrong');
}
},
});Use injectPaginatedQuery for infinite scroll or "load more" patterns.
Your Convex query must accept a paginationOpts argument.
Note: injectPaginatedQuery currently relies on Convex's experimental
paginated subscription client APIs. Check convex-angular release notes before
upgrading convex to make sure your client version is still supported — this
release is tested against convex 1.41.x.
import { Component } from '@angular/core';
import { injectPaginatedQuery } from 'convex-angular';
import { api } from '../convex/_generated/api';
@Component({
selector: 'app-root',
template: `
<ul>
@for (todo of todos.results(); track todo._id) {
<li>{{ todo.title }}</li>
}
</ul>
@if (todos.canLoadMore()) {
<button (click)="todos.loadMore(10)">Load More</button>
}
@if (todos.isExhausted()) {
<p>All items loaded</p>
}
`,
})
export class AppComponent {
readonly todos = injectPaginatedQuery(api.todos.listTodosPaginated, () => ({}), { initialNumItems: 10 });
}The paginated query returns:
results()- Accumulated results from all loaded pagesisLoadingFirstPage()- True when loading the first pageisLoadingMore()- True when loading additional pagescanLoadMore()- True when the current subscription can load another pageisExhausted()- True when all items have been loadedisSkipped()- True when the query is skipped viaskipTokenisSuccess()- True when the first page has loaded successfullystatus()-'pending' | 'success' | 'error' | 'skipped'error()- Error if the query failedloadMore(n)- Loadnmore itemsreset()- Reset pagination and reload from the beginning; also use this to retry first-page failures
Use the paginated optimistic helpers inside injectMutation(..., { optimisticUpdate })
to keep infinite lists feeling instant.
import { Component } from '@angular/core';
import { injectMutation, insertAtTop } from 'convex-angular';
import { api } from '../convex/_generated/api';
@Component({
selector: 'app-root',
template: `<button (click)="createTodo()">Add Todo</button>`,
})
export class AppComponent {
readonly addTodo = injectMutation(api.todos.addTodo, {
optimisticUpdate: (localStore, args) => {
insertAtTop({
paginatedQuery: api.todos.listTodosPaginated,
argsToMatch: {},
localQueryStore: localStore,
item: {
_id: 'optimistic-id',
_creationTime: Date.now(),
title: args.title,
},
});
},
});
async createTodo() {
await this.addTodo.mutate({ title: 'Buy groceries' });
}
}Available helpers:
optimisticallyUpdateValueInPaginatedQuery(...)- update matching items across loaded pagesinsertAtTop(...)- prepend an item to the first loaded pageinsertAtBottomIfLoaded(...)- append an item only when the final page is loadedinsertAtPosition(...)- insert based on the same sort key/order as the server query
When using insertAtPosition(...), make sure sortKeyFromItem matches the server
query sort exactly. Including a stable tie-breaker such as _creationTime is recommended.
Use skipToken to conditionally skip a query when certain conditions aren't met.
import { Component, signal } from '@angular/core';
import { injectQuery, skipToken } from 'convex-angular';
import { api } from '../convex/_generated/api';
@Component({
selector: 'app-root',
template: `
@if (user.isSkipped()) {
<p>Select a user to view profile</p>
} @else if (user.isLoading()) {
<p>Loading...</p>
} @else {
<p>{{ user.data()?.name }}</p>
}
`,
})
export class AppComponent {
readonly userId = signal<string | null>(null);
// Query is skipped when userId is null
readonly user = injectQuery(api.users.getProfile, () => (this.userId() ? { userId: this.userId() } : skipToken));
}This is useful when:
- Query arguments depend on user selection
- You need to wait for authentication before fetching data
- A parent query must complete before running a dependent query
Use injectConvex to get full flexibility of the Convex client.
import { Component } from '@angular/core';
import { injectConvex } from 'convex-angular';
import { api } from '../convex/_generated/api';
@Component({
selector: 'app-root',
template: `<button (click)="completeAllTodos()">Complete All Todos</button>`,
})
export class AppComponent {
readonly convex = injectConvex();
completeAllTodos() {
this.convex.action(api.todoFunctions.completeAllTodos, {});
}
}Use injectConvexConnectionState to react to online/offline and reconnecting changes.
import { Component } from '@angular/core';
import { injectConvexConnectionState } from 'convex-angular';
@Component({
selector: 'app-connection-indicator',
template: `
@if (!connectionState().isWebSocketConnected) {
<p>Reconnecting to Convex...</p>
}
`,
})
export class ConnectionIndicatorComponent {
readonly connectionState = injectConvexConnectionState();
}If you need to create a Convex helper later from plain code, capture an
EnvironmentInjector in DI and pass it as injectRef.
import { Component, EnvironmentInjector, inject } from '@angular/core';
import { injectMutation } from 'convex-angular';
import { api } from '../convex/_generated/api';
@Component({
selector: 'app-root',
template: `<button (click)="submit()">Save</button>`,
})
export class AppComponent {
private readonly injectRef = inject(EnvironmentInjector);
async submit() {
const mutation = injectMutation(api.todos.addTodo, {
injectRef: this.injectRef,
});
try {
await mutation.mutate({ title: 'Created outside the initial scope' });
} catch (error) {
console.error(error);
}
}
}This works for all public inject* helpers, including injectQuery,
injectQueries, injectPrewarmQuery, injectPaginatedQuery,
injectMutation, injectAction, injectConvex,
injectConvexConnectionState, and injectAuth.
Use injectAuth to access the authentication state in your components.
import { Component } from '@angular/core';
import { injectAuth } from 'convex-angular';
@Component({
selector: 'app-root',
template: `
@switch (auth.status()) {
@case ('loading') {
<p>Loading...</p>
}
@case ('authenticated') {
<app-dashboard></app-dashboard>
}
@case ('refreshing') {
<app-dashboard></app-dashboard>
<p>Reconnecting your session…</p>
}
@case ('unauthenticated') {
<app-login></app-login>
}
}
`,
})
export class AppComponent {
readonly auth = injectAuth();
}The auth state provides:
isLoading()- True while the auth provider is loading or Convex is still validating the current token with the backendisAuthenticated()- True only after the auth provider reports an authenticated user and Convex confirms the token. Stays true during a refresh so the UI does not flicker to a signed-out stateisRefreshing()- True when the server rejected a previously-confirmed token and Convex paused the socket while fetching a replacement. Only ever true whileisAuthenticated()is also true; routine background token rotation does not trigger iterror()- The most recent unexpected provider, token, or auth-sync failurestatus()-'loading' | 'authenticated' | 'refreshing' | 'unauthenticated'getAuth()- Snapshot of the JWT currently used by the Convex client together with its decoded claims, or undefined when no token is set. A method rather than a signal: the client emits no token-change events, so read it on demand (for example right before calling an external API that reuses the Convex token)
Use the *cvaAuthRefreshing directive to layer a "reconnecting" affordance on top of authenticated content:
<app-dashboard *cvaAuthenticated></app-dashboard>
<div *cvaAuthRefreshing class="reconnecting-banner">Reconnecting your session…</div>Returning null from fetchAccessToken(...) is treated as a normal
unauthenticated outcome. It does not populate error().
To integrate with Clerk, create a service that implements ClerkAuthProvider and register it with provideClerkAuth().
// clerk-auth.service.ts
import { Injectable, Signal, computed, inject } from '@angular/core';
import { Clerk } from '@clerk/clerk-js'; // Your Clerk instance
// app.config.ts
import { CLERK_AUTH, ClerkAuthProvider, provideClerkAuth, provideConvex } from 'convex-angular';
@Injectable({ providedIn: 'root' })
export class ClerkAuthService implements ClerkAuthProvider {
private clerk = inject(Clerk);
readonly isLoaded = computed(() => this.clerk.loaded());
readonly isSignedIn = computed(() => !!this.clerk.user());
readonly orgId = computed(() => this.clerk.organization()?.id);
readonly orgRole = computed(() => this.clerk.organization()?.membership?.role);
async getToken(options?: { template?: string; skipCache?: boolean }) {
return (await this.clerk.session?.getToken(options)) ?? null;
}
}
export const appConfig: ApplicationConfig = {
providers: [
provideConvex('https://<your-convex-deployment>.convex.cloud'),
{ provide: CLERK_AUTH, useExisting: ClerkAuthService },
provideClerkAuth(),
],
};provideClerkAuth() already includes provideConvexAuth(), so do not add both.
If your Clerk service exposes upstream failures, forward them via the optional
error signal so injectAuth().error() can surface them. Clerk integrations
can also expose reactive auth context like orgId/orgRole; provideClerkAuth()
uses that state to refresh the token when organization context changes.
Return null only when the user is signed out or no token is available. Let
real token-fetch failures throw so injectAuth().error() can surface them.
To integrate with Auth0, create a service that implements Auth0AuthProvider and register it with provideAuth0Auth().
// auth0-auth.service.ts
import { Injectable, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { AuthService } from '@auth0/auth0-angular';
// app.config.ts
import { AUTH0_AUTH, Auth0AuthProvider, provideAuth0Auth, provideConvex } from 'convex-angular';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class Auth0AuthService implements Auth0AuthProvider {
private auth0 = inject(AuthService);
readonly isLoading = toSignal(this.auth0.isLoading$, { initialValue: true });
readonly isAuthenticated = toSignal(this.auth0.isAuthenticated$, {
initialValue: false,
});
async getAccessTokenSilently(options?: { cacheMode?: 'on' | 'off' }) {
return firstValueFrom(this.auth0.getAccessTokenSilently({ cacheMode: options?.cacheMode }));
}
}
export const appConfig: ApplicationConfig = {
providers: [
provideConvex('https://<your-convex-deployment>.convex.cloud'),
{ provide: AUTH0_AUTH, useExisting: Auth0AuthService },
provideAuth0Auth(),
],
};provideAuth0Auth() already includes provideConvexAuth(), so do not add both.
If your Auth0 service can expose upstream auth failures, forward them via the
optional error signal so injectAuth().error() can surface them.
Unlike the Clerk integration, Auth0 has no automatic re-authentication when
organization context changes. If your app switches Auth0 organizations while
the user stays signed in, implement ConvexAuthProvider directly (see below)
and bump its reauthVersion signal on org changes to force a fresh token.
For other auth providers, implement the ConvexAuthProvider interface and use
provideConvexAuthFromExisting(...) as the default setup.
// custom-auth.service.ts
import { Injectable, signal } from '@angular/core';
// app.config.ts
import { CONVEX_AUTH, ConvexAuthProvider, provideConvex, provideConvexAuthFromExisting } from 'convex-angular';
@Injectable({ providedIn: 'root' })
export class CustomAuthService implements ConvexAuthProvider {
readonly isLoading = signal(true);
readonly isAuthenticated = signal(false);
readonly error = signal<Error | undefined>(undefined);
readonly reauthVersion = signal(0);
constructor() {
// Initialize your auth provider
myAuthProvider.onStateChange((state) => {
this.isLoading.set(false);
this.isAuthenticated.set(state.loggedIn);
});
myAuthProvider.onError?.((error) => {
this.error.set(error);
});
myAuthProvider.onOrganizationChange?.(() => {
this.reauthVersion.update((version) => version + 1);
});
}
async fetchAccessToken({ forceRefreshToken }: { forceRefreshToken: boolean }) {
return myAuthProvider.getToken({ refresh: forceRefreshToken });
}
}
export const appConfig: ApplicationConfig = {
providers: [
provideConvex('https://<your-convex-deployment>.convex.cloud'),
provideConvexAuthFromExisting(CustomAuthService),
],
};provideConvexAuthFromExisting(...) registers CONVEX_AUTH with useExisting and includes provideConvexAuth() internally.
Optional ConvexAuthProvider hooks:
reauthVersion- expose a signal that changes when account, tenant, or organization context changes require a fresh token while the user stays signed inerror- expose upstream auth failures so they flow throughinjectAuth().error()
Return null or undefined from fetchAccessToken(...) when the user is
signed out or no token is available. That keeps auth unauthenticated without
marking it as an error.
Let unexpected token-fetch failures throw so they become injectAuth().error()
instead of being treated as ordinary sign-out.
If you wire CONVEX_AUTH manually, use useExisting (not useClass) when the
auth provider is also injected elsewhere, otherwise you can end up with two
instances and auth signal updates won’t reach Convex auth sync.
When integrating @convex-dev/auth, implement fetchAccessToken to return the
Convex-auth JWT (return null when signed out).
import { Injectable, signal } from '@angular/core';
import { ConvexAuthProvider } from 'convex-angular';
@Injectable({ providedIn: 'root' })
export class ConvexAuthService implements ConvexAuthProvider {
readonly isLoading = signal(true);
readonly isAuthenticated = signal(false);
async fetchAccessToken({ forceRefreshToken }: { forceRefreshToken: boolean }) {
return myAuthProvider.getToken({ refresh: forceRefreshToken });
}
}With provideConvexAuth() registered, convex-angular will call
convex.setAuth(...) / convex.client.clearAuth() automatically when your
provider’s isAuthenticated changes. If your auth client can fail
independently, expose an optional error signal. If auth context can change
while the user stays signed in, expose reauthVersion to force a fresh token.
Use structural directives to conditionally render content based on auth state.
<!-- Show only when authenticated -->
<nav *cvaAuthenticated>
<span>Welcome back!</span>
<button (click)="logout()">Sign Out</button>
</nav>
<!-- Show only when NOT authenticated -->
<div *cvaUnauthenticated>
<p>Please sign in to continue.</p>
<button (click)="login()">Sign In</button>
</div>
<!-- Show while a rejected token is being refreshed (user stays authenticated) -->
<div *cvaAuthRefreshing>
<p>Reconnecting your session...</p>
</div>
<!-- Show while auth is loading -->
<div *cvaAuthLoading>
<p>Checking authentication...</p>
</div>Import the directives in your component:
import {
CvaAuthLoadingDirective,
CvaAuthRefreshingDirective,
CvaAuthenticatedDirective,
CvaUnauthenticatedDirective,
} from 'convex-angular';
@Component({
imports: [
CvaAuthenticatedDirective,
CvaUnauthenticatedDirective,
CvaAuthLoadingDirective,
CvaAuthRefreshingDirective,
],
// ...
})
export class AppComponent {}Protect routes that require authentication using convexAuthGuard.
// app.routes.ts
import { Routes } from '@angular/router';
import { convexAuthGuard } from 'convex-angular';
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component').then((m) => m.DashboardComponent),
canActivate: [convexAuthGuard],
},
{
path: 'profile',
loadComponent: () => import('./profile/profile.component').then((m) => m.ProfileComponent),
canActivate: [convexAuthGuard],
},
{
path: 'login',
loadComponent: () => import('./login/login.component').then((m) => m.LoginComponent),
},
];By default, unauthenticated users are redirected to /login with a
returnUrl query param preserving the blocked destination. For example,
visiting /profile?tab=security#sessions while signed out redirects to
/login?returnUrl=%2Fprofile%3Ftab%3Dsecurity%23sessions.
To customize the redirect route:
// app.config.ts
import { CONVEX_AUTH_GUARD_CONFIG } from 'convex-angular';
export const appConfig: ApplicationConfig = {
providers: [
// ... other providers
{
provide: CONVEX_AUTH_GUARD_CONFIG,
useValue: { loginRoute: '/auth/signin' },
},
],
};convex-angular works out of the box with Angular SSR (@angular/ssr) and hydration.
No extra configuration is required — when the app renders on the server:
- The WebSocket client is automatically disabled (no socket is opened on the server).
injectQueryandinjectQueriesfetch their data once over HTTP during the server render, so the generated HTML contains real content. Angular's SSR serialization waits for these fetches.- Results are transferred to the browser via
TransferStateand seeded into the same helpers after hydration, so the page renders instantly with the server's data — no loading flash — and the live WebSocket subscription takes over from there.
// app.config.ts
import { provideClientHydration } from '@angular/platform-browser';
import { provideConvex } from 'convex-angular';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(), // recommended for SSR apps
provideConvex(environment.convexUrl),
],
};To fetch user-specific data during the server render, provide an ssr.authToken
factory that returns a JWT (for example, read from the request cookies):
// app.config.server.ts
import { REQUEST } from '@angular/core';
import { provideConvex } from 'convex-angular';
export const serverConfig: ApplicationConfig = {
providers: [
provideConvex(environment.convexUrl, {
ssr: {
authToken: () => {
const request = inject(REQUEST);
return readSessionTokenFromCookies(request); // your cookie parsing
},
},
}),
],
};The token factory is resolved once per server render. Returning null or undefined
fetches unauthenticated. To disable server-side fetching entirely (helpers stay
pending in the server HTML and load live after hydration), pass
ssr: { fetchOnServer: false }.
| Helper | On the server |
|---|---|
injectQuery / injectQueries |
Fetch over HTTP, render data, transfer to the browser |
injectPaginatedQuery |
Fetches the first page over HTTP, renders and transfers it; loadMore becomes active once the live subscription syncs after hydration |
convexQueryResolver |
Fetches over HTTP (blocking the render) and transfers to the browser |
injectPrewarmQuery |
prewarm() is a no-op |
injectConvexConnectionState |
Reports a static disconnected state |
injectAuth |
Reports the provider's state; Convex token sync resumes in the browser |
injectMutation / injectAction |
Calling them during SSR throws (mutations/actions are user interactions) |
Design note: convex-angular intentionally exposes no
QueryJournalAPI. The journal's purpose inconvex/react(resuming a server-started subscription in the browser) is covered here by theTransferStatehandoff, and the underlyingConvexClientdoes not accept journals on its subscription API.
Unit-test components that use convex-angular helpers without a real Convex
deployment via the convex-angular/testing entry point. MockConvexClient
captures every subscription and invocation the helpers make so the test can
drive them: emit query results, settle mutations, change connection state, or
pre-seed the warm cache.
import { TestBed } from '@angular/core/testing';
import { MockConvexClient, provideConvexTesting } from 'convex-angular/testing';
describe('TodoListComponent', () => {
let convex: MockConvexClient;
beforeEach(() => {
convex = new MockConvexClient();
TestBed.configureTestingModule({
providers: [provideConvexTesting(convex)],
});
});
it('renders todos from the query', () => {
const fixture = TestBed.createComponent(TodoListComponent);
fixture.detectChanges();
// Drive the injectQuery subscription like the live WebSocket would.
convex.lastQuerySubscription()!.emit([{ _id: '1', title: 'Buy groceries' }]);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Buy groceries');
});
it('saves new todos', async () => {
const fixture = TestBed.createComponent(TodoListComponent);
fixture.detectChanges();
fixture.componentInstance.add('New todo');
expect(convex.mutationCalls[0].args).toEqual({ title: 'New todo' });
// Settle the captured mutation to drive status/data signals.
convex.mutationCalls[0].resolve('todo-id');
});
});new MockConvexClient({ disabled: true }) mirrors the server-side rendering
client (no subscriptions, throwing client getter) for SSR-behavior tests.
Contributions are welcome! Please feel free to submit a pull request.
pnpm install
pnpm dev:backend
pnpm dev:frontend
pnpm test:library
pnpm build:library