Skip to content

azhukaudev/convex-angular

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

120 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

convex-angular

NPM version GitHub license NPM downloads

The Angular client for Convex.

📚 Quick Links

✨ Features

  • 🔌 Core providers: provideConvex, injectQuery, injectQueries, injectPrewarmQuery, injectMutation, injectAction, injectPaginatedQuery, injectConvex, and injectConvexConnectionState
  • 🔐 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 loadMore and reset
  • ⚡ Optimistic pagination helpers: insertAtTop, insertAtBottomIfLoaded, insertAtPosition
  • ⏭️ Conditional Queries: Use skipToken to 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

🚀 Getting Started

  1. Install the dependencies:
npm install convex convex-angular
  1. Add provideConvex once to your root app.config.ts providers:
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.

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

data() is typed as T | undefined. Handle the initial/skipped state with ?. or ?? until the first successful result arrives.

Fetching multiple queries

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 results
  • errors() - Keyed query errors
  • statuses() - Keyed query statuses
  • isLoading() - True while any active query is pending

Prewarming queries

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.

Preloading route data

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.

Mutating data

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.

Running actions

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.

Handling Convex errors

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

Paginated queries

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 pages
  • isLoadingFirstPage() - True when loading the first page
  • isLoadingMore() - True when loading additional pages
  • canLoadMore() - True when the current subscription can load another page
  • 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; also use this to retry first-page failures

Optimistic paginated updates

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 pages
  • insertAtTop(...) - prepend an item to the first loaded page
  • insertAtBottomIfLoaded(...) - append an item only when the final page is loaded
  • insertAtPosition(...) - 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.

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

Monitoring connection state

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

Creating helpers outside the initial injection context

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.

🔐 Authentication

Using 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 backend
  • isAuthenticated() - 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 state
  • isRefreshing() - True when the server rejected a previously-confirmed token and Convex paused the socket while fetching a replacement. Only ever true while isAuthenticated() is also true; routine background token rotation does not trigger it
  • error() - The most recent unexpected provider, token, or auth-sync failure
  • status() - '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().

Clerk Integration

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.

Auth0 Integration

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.

Custom Auth Providers

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 in
  • error - expose upstream auth failures so they flow through injectAuth().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.

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

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

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 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 {}

Route Guards

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' },
    },
  ],
};

🖥️ Server-side rendering

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).
  • injectQuery and injectQueries fetch 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 TransferState and 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),
  ],
};

Authenticated SSR

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

SSR behavior by helper

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 QueryJournal API. The journal's purpose in convex/react (resuming a server-started subscription in the browser) is covered here by the TransferState handoff, and the underlying ConvexClient does not accept journals on its subscription API.

🧪 Testing

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.

🤝 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