Usage
Learn how to consume your API endpoints in the frontend using Tanstack Query for type-safe, efficient data fetching.
Query Client Configuration
The project uses a global QueryClient that is configured in modules/shared/lib/query-client.ts. The client is set up with default options including a 60-second stale time for queries and automatic retry disabled for failed requests.
Provider Setup
The application is wrapped with an ApiClientProvider component (located in modules/shared/components/ApiClientProvider.tsx) that provides the QueryClient context to all child components. This provider should be placed at the root of your application to enable Tanstack Query functionality throughout your app.
oRPC Client
Client Configuration
The oRPC client is configured in modules/shared/lib/orpc-client.ts:
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { ApiRouterClient } from "@repo/api/orpc/router";
const link = new RPCLink({
url: `${getBaseUrl()}/api/rpc`,
headers: async () => {
// Auto-include cookies and headers
if (typeof window !== "undefined") {
return {};
}
const { headers } = await import("next/headers");
return Object.fromEntries(await headers());
},
});
export const orpcClient: ApiRouterClient = createORPCClient(link);Tanstack Query Integration
Create type-safe query utilities using modules/shared/lib/orpc-query-utils.ts:
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import { orpcClient } from "./orpc-client";
export const orpc = createTanstackQueryUtils(orpcClient);Using Mutations
Basic Mutation
For POST, PUT, DELETE operations, use useMutation:
For example:
"use client";
import { orpc } from "@shared/lib/orpc-query-utils";
import { useMutation } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
export function ContactForm() {
const contactFormMutation = useMutation(
orpc.contact.submit.mutationOptions(),
);
const form = useForm<ContactFormValues>({
defaultValues: {
name: "",
email: "",
subject: "support",
message: "",
},
});
const onSubmit = form.handleSubmit(async (values) => {
try {
await contactFormMutation.mutateAsync(values);
// Form submitted successfully
} catch (error) {
form.setError("root", {
message: "Failed to submit form",
});
}
});
return (
<form onSubmit={onSubmit}>
{/* Form fields */}
<button
type="submit"
disabled={form.formState.isSubmitting}
>
Submit
</button>
</form>
);
}Using Queries
Basic Query
For GET operations, use useQuery:
"use client";
import { orpc } from "@shared/lib/orpc-query-utils";
import { useQuery } from "@tanstack/react-query";
export function UserProfile() {
const { data, isLoading, error } = useQuery(
orpc.users.getProfile.queryOptions()
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}Query with Parameters
Pass parameters to your queries:
export function OrganizationList() {
const { data: organizations } = useQuery(
orpc.organizations.list.queryOptions({
page: 1,
limit: 10,
search: "tech",
})
);
return (
<ul>
{organizations?.map(org => (
<li key={org.id}>{org.name}</li>
))}
</ul>
);
}Conditional Queries
Enable/disable queries based on conditions:
export function UserOrganization({ userId }) {
const { data } = useQuery({
...orpc.organizations.get.queryOptions({ userId }),
enabled: !!userId, // Only fetch when userId exists
});
return <div>{data?.name}</div>;
}Custom Query Hooks
Creating Reusable Hooks
import { useQuery, useMutation } from "@tanstack/react-query";
import { authClient } from "@repo/auth/client";
import { orpcClient } from "@shared/lib/orpc-client";
// Query key for cache management
export const organizationListQueryKey = ["user", "organizations"] as const;
// Custom hook for fetching organizations
export const useOrganizationListQuery = () => {
return useQuery({
queryKey: organizationListQueryKey,
queryFn: async () => {
const { data, error } = await authClient.organization.list();
if (error) {
throw new Error(
error.message || "Failed to fetch organizations",
);
}
return data;
},
});
};
// Dynamic query key
export const activeOrganizationQueryKey = (slug: string) =>
["user", "activeOrganization", slug] as const;
// Hook with parameters
export const useActiveOrganizationQuery = (
slug: string,
options?: { enabled?: boolean }
) => {
return useQuery({
queryKey: activeOrganizationQueryKey(slug),
queryFn: async () => {
const { data, error } =
await authClient.organization.getFullOrganization({
query: { organizationSlug: slug },
});
if (error) {
throw new Error(error.message);
}
return data;
},
enabled: options?.enabled,
});
};Custom Mutation Hook
// Mutation key
export const createOrganizationMutationKey =
["create-organization"] as const;
// Custom mutation hook
export const useCreateOrganizationMutation = () => {
return useMutation({
mutationKey: createOrganizationMutationKey,
mutationFn: async ({
name,
metadata,
}: {
name: string;
metadata?: OrganizationMetadata;
}) => {
// Generate slug first
const { slug } = await orpcClient.organizations.generateSlug({
name,
});
// Create organization
const { error, data } = await authClient.organization.create({
name,
slug,
metadata,
});
if (error) {
throw error;
}
return data;
},
});
};Using Custom Hooks
export function CreateOrganizationForm() {
const createMutation = useCreateOrganizationMutation();
const queryClient = useQueryClient();
const handleCreate = async (name: string) => {
try {
const org = await createMutation.mutateAsync({ name });
// Invalidate organizations list to refetch
queryClient.invalidateQueries({
queryKey: organizationListQueryKey,
});
console.log("Created:", org);
} catch (error) {
console.error("Failed:", error);
}
};
return (
<button
onClick={() => handleCreate("My Organization")}
disabled={createMutation.isPending}
>
Create
</button>
);
}Cache Management
Invalidating Queries
Refetch data after mutations:
import { useQueryClient } from "@tanstack/react-query";
export function UpdateUserForm() {
const queryClient = useQueryClient();
const updateMutation = useMutation(
orpc.users.update.mutationOptions()
);
const handleUpdate = async (data) => {
await updateMutation.mutateAsync(data);
// Invalidate and refetch
queryClient.invalidateQueries({
queryKey: ["user", "profile"],
});
};
return <form onSubmit={handleUpdate}>...</form>;
}Setting Query Data
Manually update cache:
const queryClient = useQueryClient();
// Set data
queryClient.setQueryData(["user", userId], newUserData);
// Get data
const userData = queryClient.getQueryData(["user", userId]);
// Remove data
queryClient.removeQueries({ queryKey: ["user", userId] });Query Keys
Creating Query Keys
Use consistent patterns for query keys:
// Simple key
export const userProfileKey = ["user", "profile"] as const;
// Key with parameters
export const userKey = (id: string) => ["user", id] as const;
// Nested key
export const organizationMembersKey = (orgId: string) =>
["organization", orgId, "members"] as const;
// Key with filters
export const postsKey = (filters: {
page?: number;
tag?: string;
}) => ["posts", filters] as const;Query Key Helper
From modules/shared/lib/query-client.ts:
export function createQueryKeyWithParams(
key: string | string[],
params: Record<string, string | number>,
) {
return [
...(Array.isArray(key) ? key : [key]),
Object.entries(params)
.reduce((acc, [key, value]) => {
acc.push(`${key}:${value}`);
return acc;
}, [] as string[])
.join("_"),
] as const;
}
// Usage
const key = createQueryKeyWithParams("posts", {
page: 1,
limit: 10,
});
// Result: ["posts", "page:1_limit:10"]Loading States
Handling Different States
export function DataComponent() {
const { data, isLoading, isError, error } = useQuery(
orpc.data.get.queryOptions()
);
if (isLoading) {
return <Spinner />;
}
if (isError) {
return <ErrorMessage error={error} />;
}
return <div>{data.content}</div>;
}Suspense Mode
Use React Suspense for loading states:
export function DataComponent() {
const { data } = useQuery({
...orpc.data.get.queryOptions(),
suspense: true,
});
// No need to check isLoading
return <div>{data.content}</div>;
}
// Wrap with Suspense
export function Page() {
return (
<Suspense fallback={<Spinner />}>
<DataComponent />
</Suspense>
);
}Server Components
Prefetching Data
Prefetch data in Server Components:
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { orpcClient } from "@shared/lib/orpc-client";
export default async function Page() {
const queryClient = new QueryClient();
// Prefetch on server
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: () => orpcClient.posts.list({ page: 1 }),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostsList />
</HydrationBoundary>
);
}