Skip to content

React to tRPC

Nx Plugin for AWS provides a generator to quickly integrate your tRPC API with a React website. It sets up all necessary configuration for connecting to your tRPC backends, including AWS IAM and Cognito authentication support and proper error handling. The integration provides full end-to-end type safety between your frontend and tRPC backend(s).

Before using this generator, ensure your React application has:

  1. A main.tsx file that renders your application
  2. An <App/> JSX element where the tRPC provider will be automatically injected
  3. A working tRPC API (generated using the tRPC API generator)
  4. Cognito Auth added via the ts#react-website-auth generator if connecting an API which uses Cognito or IAM auth
Example of required main.tsx structure
import { StrictMode } from 'react';
import * as ReactDOM from 'react-dom/client';
import App from './app/app';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement,
);
root.render(
<StrictMode>
<App />
</StrictMode>,
);
  1. Install the Nx Console VSCode Plugin if you haven't already
  2. Open the Nx Console in VSCode
  3. Click Generate (UI) in the "Common Nx Commands" section
  4. Search for @aws/nx-plugin - connection
  5. Fill in the required parameters
    • Click Generate
    Parameter Type Default Description
    sourceProject Required string - The source project
    targetProject Required string - The target project to connect to

    The generator creates the following structure in your React application:

    • Directorysrc
      • Directorycomponents
        • <ApiName>ClientProvider.tsx Sets up the tRPC clients and bindings to your backend schema(s). ApiName will resolve to the name of the API
        • QueryClientProvider.tsx TanStack React Query client provider
      • Directoryhooks
        • useSigV4.tsx Hook for signing HTTP requests with SigV4 (IAM only)
        • use<ApiName>.tsx A hook returning the tRPC options proxy for TanStack Query integration
        • use<ApiName>Client.tsx A hook returning the vanilla tRPC client for direct API calls

    Additionally, it installs the required dependencies:

    • @trpc/client
    • @trpc/tanstack-react-query
    • @tanstack/react-query
    • aws4fetch (if using IAM auth)
    • event-source-polyfill (if using REST API, for subscription support)

    The generator provides a use<ApiName> hook that returns a tRPC options proxy for use with TanStack Query hooks like useQuery and useMutation:

    import { useQuery, useMutation } from '@tanstack/react-query';
    import { useMyApi } from './hooks/useMyApi';
    function MyComponent() {
    const trpc = useMyApi();
    // Example query
    const { data, isLoading, error } = useQuery(trpc.users.list.queryOptions());
    // Example mutation
    const mutation = useMutation(trpc.users.create.mutationOptions());
    const handleCreate = () => {
    mutation.mutate({
    name: 'John Doe',
    email: 'john@example.com',
    });
    };
    if (isLoading) return <div>Loading...</div>;
    return (
    <ul>
    {data.map((user) => (
    <li key={user.id}>{user.name}</li>
    ))}
    </ul>
    );
    }

    The use<ApiName>Client hook provides access to the vanilla tRPC client, which is useful for imperative API calls and subscriptions:

    import { useState } from 'react';
    import { useMyApiClient } from './hooks/useMyApi';
    function MyComponent() {
    const client = useMyApiClient();
    const handleClick = async () => {
    const result = await client.echo.query({ message: 'Hello!' });
    console.log(result);
    const mutationResult = await client.users.create.mutate({ name: 'Jane' });
    console.log(mutationResult);
    };
    return <button onClick={handleClick}>Call API</button>;
    }

    The integration includes built-in error handling that properly processes tRPC errors:

    function MyComponent() {
    const trpc = useMyApi();
    const { data, error } = useQuery(trpc.users.list.queryOptions());
    if (error) {
    return (
    <div>
    <h2>Error occurred:</h2>
    <p>{error.message}</p>
    {error.data?.code && <p>Code: {error.data.code}</p>}
    </div>
    );
    }
    return (
    <ul>
    {data.map((user) => (
    <li key={user.id}>{user.name}</li>
    ))}
    </ul>
    );
    }

    When connecting to a REST API tRPC backend, the generated client is automatically configured with a splitLink that routes subscription operations through httpSubscriptionLink (using SSE) and regular queries/mutations through httpLink. This means subscriptions work out of the box with no additional configuration.

    For information on how to define subscription procedures in your backend, see the ts#trpc-api generator guide.

    You can consume subscriptions using the useSubscription hook with subscriptionOptions from the options proxy:

    import { useSubscription } from '@trpc/tanstack-react-query';
    import { useMyApi } from './hooks/useMyApi';
    function StreamingComponent() {
    const trpc = useMyApi();
    const subscription = useSubscription(
    trpc.myStream.subscriptionOptions(
    { query: 'hello' },
    {
    enabled: true,
    onStarted: () => {
    console.log('Subscription started');
    },
    onData: (data) => {
    console.log('Received:', data.text);
    },
    onError: (error) => {
    console.error('Subscription error:', error);
    },
    },
    ),
    );
    return (
    <div>
    <p>Status: {subscription.status}</p>
    {subscription.data && <p>Latest: {subscription.data.text}</p>}
    {subscription.error && <p>Error: {subscription.error.message}</p>}
    <button onClick={() => subscription.reset()}>Reset</button>
    </div>
    );
    }

    The subscription object provides:

    • subscription.data — the most recently received data
    • subscription.error — the most recently received error
    • subscription.status — one of 'idle', 'connecting', 'pending', or 'error'
    • subscription.reset() — resets the subscription (useful for recovering from errors)

    Alternatively, you can use the vanilla tRPC client via the use<ApiName>Client hook for more control over the subscription lifecycle:

    import { useState, useEffect } from 'react';
    import { useMyApiClient } from './hooks/useMyApi';
    function StreamingComponent() {
    const client = useMyApiClient();
    const [messages, setMessages] = useState<string[]>([]);
    useEffect(() => {
    const subscription = client.myStream.subscribe(
    { query: 'hello' },
    {
    onData: (data) => {
    setMessages((prev) => [...prev, data.text]);
    },
    onComplete: () => {
    console.log('Stream complete');
    },
    onError: (error) => {
    console.error('Stream error:', error);
    },
    },
    );
    // Clean up the subscription on unmount
    return () => subscription.unsubscribe();
    }, [client]);
    return (
    <ul>
    {messages.map((msg, i) => (
    <li key={i}>{msg}</li>
    ))}
    </ul>
    );
    }

    Always handle loading and error states for a better user experience:

    function UserList() {
    const trpc = useMyApi();
    const users = useQuery(trpc.users.list.queryOptions());
    if (users.isLoading) {
    return <LoadingSpinner />;
    }
    if (users.error) {
    return <ErrorMessage error={users.error} />;
    }
    return (
    <ul>
    {users.data.map((user) => (
    <li key={user.id}>{user.name}</li>
    ))}
    </ul>
    );
    }

    Use optimistic updates for a better user experience:

    import { useQueryClient, useQuery, useMutation } from '@tanstack/react-query';
    function UserList() {
    const trpc = useMyApi();
    const users = useQuery(trpc.users.list.queryOptions());
    const queryClient = useQueryClient();
    const deleteMutation = useMutation(
    trpc.users.delete.mutationOptions({
    onMutate: async (userId) => {
    // Cancel outgoing fetches
    await queryClient.cancelQueries(trpc.users.list.queryFilter());
    // Get snapshot of current data
    const previousUsers = queryClient.getQueryData(
    trpc.users.list.queryKey(),
    );
    // Optimistically remove the user
    queryClient.setQueryData(trpc.users.list.queryKey(), (old) =>
    old?.filter((user) => user.id !== userId),
    );
    return { previousUsers };
    },
    onError: (err, userId, context) => {
    // Restore previous data on error
    queryClient.setQueryData(
    trpc.users.list.queryKey(),
    context?.previousUsers,
    );
    },
    }),
    );
    return (
    <ul>
    {users.map((user) => (
    <li key={user.id}>
    {user.name}
    <button onClick={() => deleteMutation.mutate(user.id)}>Delete</button>
    </li>
    ))}
    </ul>
    );
    }

    Prefetch data for better performance:

    function UserList() {
    const trpc = useMyApi();
    const users = useQuery(trpc.users.list.queryOptions());
    const queryClient = useQueryClient();
    // Prefetch user details on hover
    const prefetchUser = async (userId: string) => {
    await queryClient.prefetchQuery(trpc.users.getById.queryOptions(userId));
    };
    return (
    <ul>
    {users.map((user) => (
    <li key={user.id} onMouseEnter={() => prefetchUser(user.id)}>
    <Link to={`/users/${user.id}`}>{user.name}</Link>
    </li>
    ))}
    </ul>
    );
    }

    Handle pagination with infinite queries:

    function UserList() {
    const trpc = useMyApi();
    const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery(
    trpc.users.list.infiniteQueryOptions(
    { limit: 10 },
    {
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    },
    ),
    );
    return (
    <div>
    {data?.pages.map((page) =>
    page.users.map((user) => <UserCard key={user.id} user={user} />),
    )}
    {hasNextPage && (
    <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
    {isFetchingNextPage ? 'Loading...' : 'Load More'}
    </button>
    )}
    </div>
    );
    }

    It is important to note that infinite queries can only be used for procedures with an input property named cursor.

    The integration provides complete end-to-end type safety. Your IDE will provide full autocompletion and type checking for all your API calls:

    function UserForm() {
    const trpc = useMyApi();
    // ✅ Input is fully typed
    const createUser = trpc.users.create.useMutation();
    const handleSubmit = (data: CreateUserInput) => {
    // ✅ Type error if input doesn't match schema
    createUser.mutate(data);
    };
    return <form onSubmit={handleSubmit}>{/* ... */}</form>;
    }

    The types are automatically inferred from your backend’s router and schema definitions, ensuring that any changes to your API are immediately reflected in your frontend code without the need to build.

    For more information, please refer to: