The Angular client for Convex. Build real-time Angular applications with Signals, dependency injection, automatic subscription cleanup, authentication, pagination, and server-side rendering.
readonly todos = injectQuery(api.todos.list, () => ({}));
readonly addTodo = injectMutation(api.todos.create);- Signal-first query, mutation, action, pagination, auth, and connection state.
- Fully inferred types from generated Convex function references.
- Reactive arguments and
skipTokenfor conditional subscriptions. - Automatic cleanup when the owning Angular scope is destroyed.
- Angular SSR and hydration through
TransferState. - Built-in Clerk, Auth0, and Better Auth integration points.
- A public testing package for driving Convex behavior without a backend.
- Angular
>=21.2.17 - Convex
>=1.42.1 - RxJS
>=7.8.0
Install Convex and convex-angular:
pnpm add convex convex-angular
# or: npm install convex convex-angularRegister one Convex client in your root application providers:
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideConvex } from 'convex-angular';
export const appConfig: ApplicationConfig = {
providers: [provideConvex('https://<your-deployment>.convex.cloud')],
};provideConvex() must be registered exactly once at the application root. Do not repeat it in route or component providers.
Use generated Convex function references directly in a component:
import { Component } from '@angular/core';
import { injectMutation, injectQuery } from 'convex-angular';
import { api } from '../convex/_generated/api';
@Component({
selector: 'app-todos',
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>
}
}
}
<button (click)="add()" [disabled]="createTodo.isLoading()">Add todo</button>
`,
})
export class TodoListComponent {
readonly todos = injectQuery(api.todos.list, () => ({}));
readonly createTodo = injectMutation(api.todos.create);
async add() {
await this.createTodo.mutate({ title: 'Buy groceries' });
}
}Query arguments are reactive: any Signal read by the argument function automatically resubscribes the query when it changes.
| API | Purpose |
|---|---|
provideConvex() |
Register the Convex client once at the application root. |
injectQuery() |
Subscribe to one reactive query. |
injectQueries() |
Manage a dynamic keyed group of queries. |
injectPaginatedQuery() |
Fetch a paginated query and load more results. |
injectPrewarmQuery() |
Warm a query before navigation or UI reveal. |
convexQueryResolver() |
Resolve a route after its first query result is available. |
injectMutation() |
Run mutations with reactive state and optimistic updates. |
injectAction() |
Run actions with reactive state. |
injectConvex() |
Access the provided ConvexClient. |
injectConvexConnectionState() |
Observe WebSocket and in-flight request state. |
injectAuth() |
Read Convex authentication state. |
skipToken |
Skip a query without losing type safety. |
Reactive helpers expose readonly Signals and clean up their own subscriptions. Query-like helpers expose data(), error(), status(), and focused state Signals. Mutations and actions expose mutate() or run() plus data(), error(), isLoading(), isSuccess(), status(), and reset().
Return skipToken until the required input exists:
import { injectQuery, skipToken } from 'convex-angular';
readonly user = injectQuery(
api.users.get,
() => this.userId() ? { id: this.userId()! } : skipToken,
);The query reports a 'skipped' status and opens no subscription.
Use injectQueries() for a reactive keyed collection:
readonly dashboard = injectQueries(() => ({
user: this.userId() ? { query: api.users.get, args: { id: this.userId()! } } : skipToken,
todos: { query: api.todos.list, args: {} },
}));Read keyed values from results(), errors(), and statuses(). Keys removed from the definition are removed from all three records.
readonly todos = injectPaginatedQuery(
api.todos.listPaginated,
() => ({ category: this.category() }),
{ initialNumItems: 20 },
);Use results(), loadMore(count), canLoadMore(), isLoadingMore(), and reset(). Paginated optimistic updates are available through optimisticallyUpdateValueInPaginatedQuery(), insertAtTop(), insertAtBottomIfLoaded(), insertAtPosition(), and sortByField().
injectPrewarmQuery() starts a short-lived background subscription so a later query can read warm local data:
readonly prewarmProfile = injectPrewarmQuery(api.users.get);
openProfile(id: string) {
void this.prewarmProfile.prewarm({ id });
void this.router.navigate(['/users', id]);
}Use convexQueryResolver() when navigation should wait for that first result instead:
export const routes: Routes = [
{
path: 'users/:id',
loadComponent: () => import('./user-profile').then((m) => m.UserProfileComponent),
resolve: {
profile: convexQueryResolver(api.users.get, (route) => ({
id: route.paramMap.get('id')!,
})),
},
},
];Resolver failures do not cancel navigation; the component's query exposes the error reactively.
Convex function errors flow through each helper's error() Signal and rejected mutation/action promises. Narrow application errors with the re-exported ConvexError:
import { ConvexError } from 'convex-angular';
try {
await this.createTodo.mutate({ title });
} catch (error) {
if (error instanceof ConvexError) {
console.error(error.data);
}
}injectAuth() reports 'loading', 'authenticated', 'refreshing', or 'unauthenticated'. Authentication becomes active only after both the external provider and the Convex backend confirm the token.
readonly auth = injectAuth();| Integration | Registration |
|---|---|
| Clerk | Provide CLERK_AUTH with an existing ClerkAuthProvider, then call provideClerkAuth(). |
| Auth0 | Provide AUTH0_AUTH with an existing Auth0AuthProvider, then call provideAuth0Auth(). |
| Better Auth | Call provideBetterAuth() from convex-angular/better-auth. |
| Custom | Implement ConvexAuthProvider and call provideConvexAuthFromExisting(). |
These integration helpers already include provideConvexAuth(); do not register it separately. When manually aliasing a provider, use useExisting rather than useClass so Angular does not create a second auth-service instance.
The convex-angular/better-auth entry point is structurally typed and adds no Better Auth package dependency. Create and own one Better Auth client, then give it to the provider:
// app.config.ts
import { provideConvex } from 'convex-angular';
import { provideBetterAuth } from 'convex-angular/better-auth';
import { authClient } from './auth-client';
export const appConfig: ApplicationConfig = {
providers: [provideConvex(environment.convexUrl), provideBetterAuth(() => authClient)],
};Use injectBetterAuth() for session state and refresh/clear operations. Sign-in, sign-up, and sign-out remain on your own client instance.
Four standalone structural directives cover common auth UI states:
<app-dashboard *cvaAuthenticated></app-dashboard>
<app-login *cvaUnauthenticated></app-login>
<app-spinner *cvaAuthLoading></app-spinner>
<p *cvaAuthRefreshing>Reconnecting…</p>Protect lazy routes with canMatch so unauthenticated users do not download the protected bundle:
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./dashboard').then((m) => m.DashboardComponent),
canMatch: [convexAuthGuard],
},
];Use convexUnauthGuard for signed-out-only pages and createConvexAuthGuard() for claim-based authorization.
Angular SSR works without extra Convex configuration. On the server, query helpers fetch over HTTP; Angular waits for them, renders the results, and transfers the data to the browser. After hydration, live subscriptions take over without a loading flash.
export const appConfig: ApplicationConfig = {
providers: [provideClientHydration(), provideConvex(environment.convexUrl)],
};Useful options:
provideConvex(environment.convexUrl, {
ssr: {
fetchOnServer: true,
authToken: () => readTokenFromRequest(),
transferAuthenticatedResults: true,
},
});- Set
fetchOnServer: falseto load queries only after hydration. - Return a JWT from
authTokento render authenticated queries. - Set
transferAuthenticatedResults: falseto keep authenticated results out of theTransferStatepayload.
Authenticated HTML is private data. If authToken returns a token, serve the response with Cache-Control: private or no-store; never place it in a shared cache.
Mutations and actions are user interactions and throw if invoked during server rendering. Prewarming is a server-side no-op.
Use the convex-angular/testing entry point to test Angular code without a live deployment:
import { TestBed } from '@angular/core/testing';
import { MockConvexClient, provideConvexTesting } from 'convex-angular/testing';
const convex = new MockConvexClient();
TestBed.configureTestingModule({
providers: [provideConvexTesting(convex)],
});
const fixture = TestBed.createComponent(TodoListComponent);
fixture.detectChanges();
convex.lastQuerySubscription()!.emit([{ _id: 'todo-1', title: 'Buy groceries' }]);
fixture.detectChanges();MockConvexClient can emit query pages and errors, settle captured mutations/actions, update connection state, seed warm query results, and emulate a disabled SSR client.
Every public inject* helper accepts an optional injectRef. Pass an EnvironmentInjector when creating a helper outside the initial Angular injection context:
const mutation = injectMutation(api.todos.create, {
injectRef: this.environmentInjector,
});Prefer creating helpers during normal field initialization when possible.
pnpm install
pnpm dev:backend # Convex backend
pnpm dev:frontend # Angular demo
pnpm test:library
pnpm build:libraryBefore opening a pull request:
- Run a targeted Jest test and
pnpm verify:quickfor localized changes. - Run
pnpm verify:fullfor broad, configuration, dependency, or public-API changes. The full gate includes mutation testing and takes several minutes. - Run
pnpm typecheck:specafter changing specs.
See the repository guide for conventions and the changelog for release history.