The Angular client for Convex.
- 🔌 Core providers:
provideConvex,injectQuery,injectMutation,injectAction,injectPaginatedQuery, andinjectConvex - 🔐 Authentication: Built-in support for Clerk, Auth0, and custom auth providers via
injectAuth - 🛡️ Route Guards: Protect routes with
convexAuthGuard - 🎯 Auth Directives:
*cvaAuthenticated,*cvaUnauthenticated,*cvaAuthLoading - 📄 Pagination: Built-in support for paginated queries with
loadMoreandreset - ⏭️ Conditional Queries: Use
skipTokento conditionally skip queries - 📡 Signal Integration: Angular Signals for reactive state
- 🧹 Auto Cleanup: Automatic lifecycle management
Requirements: Angular >= 20, Convex >= 1.31, RxJS >= 7.8.
- Install the dependencies:
npm install convex convex-angular- Add
provideConvexto yourapp.config.tsfile:
import { ApplicationConfig } from '@angular/core';
import { provideConvex } from 'convex-angular';
export const appConfig: ApplicationConfig = {
providers: [provideConvex('https://<your-convex-deployment>.convex.cloud')],
};- 🎉 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 }));
}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)="addTodo.mutate({ title: 'Buy groceries' })">
Add Todo
</button>
`,
})
export class AppComponent {
readonly addTodo = injectMutation(api.todos.addTodo);
}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)="completeAllTodos.run({})">
Complete All Todos
</button>`,
})
export class AppComponent {
readonly completeAllTodos = injectAction(api.todoFunctions.completeAllTodos);
}Use injectPaginatedQuery for infinite scroll or "load more" patterns.
Your Convex query must accept a paginationOpts argument.
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 more items are availableisExhausted()- 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
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 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 ('unauthenticated') {
<app-login></app-login>
}
}
`,
})
export class AppComponent {
readonly auth = injectAuth();
}The auth state provides:
isLoading()- True while auth is initializingisAuthenticated()- True when user is authenticatederror()- Authentication error, if anystatus()-'loading' | 'authenticated' | 'unauthenticated'
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 }) {
try {
return (await this.clerk.session?.getToken(options)) ?? null;
} catch {
return null;
}
}
}
export const appConfig: ApplicationConfig = {
providers: [
provideConvex('https://<your-convex-deployment>.convex.cloud'),
{ provide: CLERK_AUTH, useExisting: ClerkAuthService },
provideClerkAuth(),
],
};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(),
],
};For other auth providers, implement the ConvexAuthProvider interface and use provideConvexAuth().
// 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);
constructor() {
// Initialize your auth provider
myAuthProvider.onStateChange((state) => {
this.isLoading.set(false);
this.isAuthenticated.set(state.loggedIn);
});
}
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.
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.
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 auth is loading -->
<div *cvaAuthLoading>
<p>Checking authentication...</p>
</div>Import the directives in your component:
import {
CvaAuthLoadingDirective,
CvaAuthenticatedDirective,
CvaUnauthenticatedDirective,
} from 'convex-angular';
@Component({
imports: [
CvaAuthenticatedDirective,
CvaUnauthenticatedDirective,
CvaAuthLoadingDirective,
],
// ...
})
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. 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' },
},
],
};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