GitHub - azhukaudev/convex-angular: The Angular client for Convex.

convex-angular

NPM version GitHub license NPM downloads

The Angular client for Convex.

✨ Features

  • πŸ”Œ Core providers: provideConvex, injectQuery, injectMutation, injectAction, injectPaginatedQuery, and injectConvex
  • πŸ” Authentication: Built-in support for Clerk, Auth0, and custom auth providers via convex-angular/auth
  • πŸ›‘οΈ Route Guards: Protect routes with convex-angular/router
  • 🎯 Auth Directives: *cvaAuthenticated, *cvaUnauthenticated, *cvaAuthLoading
  • πŸ“„ Pagination: Built-in support for paginated queries with loadMore and reset
  • ⏭️ Conditional Queries: Use skipToken to conditionally skip queries
  • πŸ“‘ Signal Integration: Angular Signals for reactive state
  • 🧹 Auto Cleanup: Automatic lifecycle management

πŸš€ Getting Started

Requirements: Angular >= 20, Convex >= 1.31, RxJS >= 7.8.

  1. Install the dependencies:
npm install convex convex-angular
  1. Add provideConvex to your app.config.ts file:
import { ApplicationConfig } from '@angular/core';
import { provideConvex } from 'convex-angular';

export const appConfig: ApplicationConfig = {
  providers: [provideConvex('https://<your-convex-deployment>.convex.cloud')],
};
  1. πŸŽ‰ That's it! You can now use the injection providers in your app.

πŸ“– Usage

Note: In the examples below, api refers to your generated Convex function references (usually from convex/_generated/api). Adjust the import path to match your project structure.

Fetching data

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 }));
}

Mutating data

Use injectMutation to mutate the database. Pass { injector } if you need to create the mutation result outside an active injection context.

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);
}

Running actions

Use injectAction to run actions. Pass { injector } if you need to create the action result outside an active injection context.

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);
}

Paginated queries

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 pages
  • isLoadingFirstPage() - True when loading the first page
  • isLoadingMore() - True when loading additional pages
  • canLoadMore() - True when more items are available
  • isExhausted() - True when all items have been loaded
  • isSkipped() - True when the query is skipped via skipToken
  • isSuccess() - True when the first page has loaded successfully
  • status() - 'pending' | 'success' | 'error' | 'skipped'
  • error() - Error if the query failed
  • loadMore(n) - Load n more items
  • reset() - Reset pagination and reload from the beginning

For simple cases, pass a static options object. If initialNumItems or callbacks need to react to signals, you can still pass a function. If you need to call injectPaginatedQuery outside an injection context, use the static options form and include injector.

Conditional queries with skipToken

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

Using the Convex client

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, {});
  }
}

πŸ” Authentication

Using injectAuth

Use injectAuth to access the authentication state in your components.

import { Component } from '@angular/core';
import { injectAuth } from 'convex-angular/auth';

@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 initializing or Convex is still validating the token
  • isAuthenticated() - True only after the auth provider and Convex both confirm the session
  • error() - Token fetch or token validation error, if any
  • status() - 'loading' | 'authenticated' | 'unauthenticated'

Clerk Integration

To integrate with Clerk, create a service that implements ClerkAuthProvider and register it with provideClerkAuth(). Leave token fetch errors unhandled in getToken() so they can surface through injectAuth().error().

// 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 { provideConvex } from 'convex-angular';
import {
  CLERK_AUTH,
  ClerkAuthProvider,
  provideClerkAuth,
} from 'convex-angular/integrations/clerk';

@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(),
  ],
};

Auth0 Integration

To integrate with Auth0, create a service that implements Auth0AuthProvider and register it with provideAuth0Auth(). Leave token fetch errors unhandled in getAccessTokenSilently() so they can surface through injectAuth().error().

// 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 { provideConvex } from 'convex-angular';
import {
  AUTH0_AUTH,
  Auth0AuthProvider,
  provideAuth0Auth,
} from 'convex-angular/integrations/auth0';
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 await 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(),
  ],
};

Custom Auth Providers

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 { provideConvex } from 'convex-angular';
import {
  CONVEX_AUTH,
  ConvexAuthProvider,
  provideConvexAuthFromExisting,
} from 'convex-angular/auth';

@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. Internally, the auth integrations and provideConvexAuth() share the same Convex auth setup path.

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.

Convex Auth (@convex-dev/auth)

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/auth';

@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. injectAuth().status() remains 'loading' until Convex confirms the token with the backend.

Auth Directives

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/auth';

@Component({
  imports: [
    CvaAuthenticatedDirective,
    CvaUnauthenticatedDirective,
    CvaAuthLoadingDirective,
  ],
  // ...
})
export class AppComponent {}

Route Guards

Protect routes that require authentication using convexAuthGuard.

// app.routes.ts
import { Routes } from '@angular/router';
import { convexAuthGuard } from 'convex-angular/router';

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/router';

export const appConfig: ApplicationConfig = {
  providers: [
    // ... other providers
    {
      provide: CONVEX_AUTH_GUARD_CONFIG,
      useValue: { loginRoute: '/auth/signin' },
    },
  ],
};

🀝 Contributing

Contributions are welcome! Please feel free to submit a pull request.

Repo development

pnpm install
pnpm dev:backend
pnpm dev:frontend
pnpm test:library
pnpm build:library

βš–οΈ License

MIT