Skip to content

Route resolver

convexQueryResolver() builds an Angular ResolveFn that holds navigation until a Convex query delivers its first result, then keeps that subscription warm for a grace period. It is the explicit-preloading counterpart to injectQuery — the equivalent of React’s preloadQuery / usePreloadedQuery flow.

function convexQueryResolver<Query extends QueryReference>(
query: Query,
argsFn?: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => Query['_args'] | SkipToken,
options?: ConvexQueryResolverOptions,
): ResolveFn<FunctionReturnType<Query> | undefined>;

argsFn maps the route snapshot to query arguments. Omit it and the query is called with {}.

Name Type Default Description
keepSubscribedFor number 5000 Milliseconds to keep the resolver’s subscription alive after the route resolves, so the routed component’s own injectQuery deduplicates onto it.
app.routes.ts
import { Component, inject } from '@angular/core';
import { ActivatedRoute, type ParamMap, type Routes } from '@angular/router';
import { convexQueryResolver, injectQuery } from 'convex-angular';
import { api } from './convex/api';
/** A route matched on `:id` always has one, but the type does not say so. */
function requireParam(paramMap: ParamMap, name: string): string {
const value = paramMap.get(name);
if (value === null) {
throw new Error(`Missing route parameter: ${name}`);
}
return value;
}
@Component({
selector: 'app-user-profile',
template: `
<!-- The warm-cache path keeps status() at 'pending' with
isRefetching() true until the live subscription confirms the
value, so render the data with a refreshing hint — not a skeleton. -->
@if (profile.data(); as user) {
<h1>{{ user.name }}</h1>
@if (profile.isRefetching()) {
<span aria-live="polite">Refreshing…</span>
}
} @else if (profile.status() === 'error') {
<p role="alert">{{ profile.error()?.message }}</p>
} @else {
<p>Loading…</p>
}
`,
})
export class UserProfileComponent {
private readonly route = inject(ActivatedRoute);
// The resolver's value is not read here. The component simply subscribes
// to the same query with the same args and hits the warm cache.
readonly profile = injectQuery(api.users.getProfile, () => ({
userId: requireParam(this.route.snapshot.paramMap, 'id'),
}));
}
export const routes: Routes = [
{
path: 'users/:id',
component: UserProfileComponent,
resolve: {
profile: convexQueryResolver(api.users.getProfile, (route) => ({ userId: requireParam(route.paramMap, 'id') }), {
keepSubscribedFor: 10_000,
}),
},
},
];

Reading route.snapshot.data['profile'] would work, but it gives you a static value that never updates and forfeits the whole point of Convex. Resolve for the timing, subscribe for the data.

keepSubscribedFor exists to cover the gap between the router resolving and the component’s effect running. The 5 second default is generous; shorten it only if you have a reason.

The warm-cache render is 'pending', not 'success'

Section titled “The warm-cache render is 'pending', not 'success'”

A warm cache hit is a prefill, not a settled result: the value is shown, but the helper still waits for the live subscription to confirm it.

Signal Value right after navigation
data() the resolved value, from the warm cache
status() 'pending'
isLoading() true
isRefetching() true
isSuccess() false
onSuccess not called yet — the first live emission fires it

Every failure path resolves undefined rather than rejecting, so navigation always proceeds and the component’s own injectQuery surfaces the real state reactively:

Situation Result
argsFn returns skipToken resolves undefined synchronously, no subscription, no network
Subscription error resolves undefined
Injector destroyed before a first result resolves undefined, subscription disposed
Disabled client resolves undefined immediately instead of hanging
Server-side rendering the query is fetched over HTTP through the SSR loader and transferred to the browser; a failed fetch resolves undefined

The skipToken case is worth calling out: it returns a plain undefined, not a promise, so a route whose query is conditionally irrelevant costs nothing at all.

Blocking navigation is a real cost — the user sits on the previous screen with no feedback. Use the resolver when an empty first paint would be actively wrong (a detail page whose entire layout depends on the document), and prefer a normal injectQuery with a loading state everywhere else.

For non-blocking warming driven by user intent, use injectPrewarmQuery instead.