Documentation

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:

modules/marketing/home/components/ContactForm.tsx
"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

modules/saas/organizations/lib/api.ts
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>
  );
}