
Complete guide for using @sstraatemans/sw_trpcclient with Next.js 13+ App Router, including Server Components, Client Components, and React Query integration.
First, install the required dependencies:
pnpm add @sstraatemans/sw_trpcclient superjson zod
pnpm add -D @trpc/react-query @tanstack/react-query
Create a .env.local file in your Next.js project root:
# .env.local
NEXT_PUBLIC_TRPC_SERVER_URL=https://playground-trpcserver.vercel.app/trpc/v1
Create a client utility file:
src/utils/trpc-client.ts
import { createClient } from '@sstraatemans/sw_trpcclient';
export const trpcClient = createClient({
url: process.env.NEXT_PUBLIC_TRPC_SERVER_URL!,
});
// Export type for use in components
export type TrpcClient = typeof trpcClient;
Server Components can directly use the tRPC client without React Query.
app/albums/page.tsx
import { trpcClient } from "@/utils/trpc-client";
export default async function AlbumsPage() {
// Direct query in Server Component
const albums = await trpcClient.albums.all.query({
offset: 0,
limit: 20,
});
return (
<div>
<h1>Albums ({albums.totalCount})</h1>
<div className="grid gap-4">
{albums.data.map((album) => (
<div key={album.id} className="border p-4 rounded">
<h2>{album.title}</h2>
<p>Released: {album.date}</p>
</div>
))}
</div>
</div>
);
}
app/albums/[id]/page.tsx
import { trpcClient } from "@/utils/trpc-client";
import { notFound } from "next/navigation";
import type { AlbumType } from "@sstraatemans/sw_trpcclient";
interface PageProps {
params: {
id: string;
};
}
export default async function AlbumDetailPage({ params }: PageProps) {
const albumId = parseInt(params.id);
if (isNaN(albumId)) {
notFound();
}
const album = await trpcClient.albums.getAlbumById.query(albumId);
if (!album) {
notFound();
}
const characters = await trpcClient.albums.getAlbumCharactersById.query(albumId);
return (
<div>
<h1>{album.title}</h1>
<p>Released: {album.date}</p>
{album.description && (
<p className="mt-4">{album.description}</p>
)}
<h2 className="mt-8">Characters</h2>
<ul>
{characters.map((character) => (
<li key={character.id}>{character.name}</li>
))}
</ul>
</div>
);
}
Fetch multiple queries in parallel:
import { trpcClient } from "@/utils/trpc-client";
export default async function DashboardPage() {
// Fetch all data in parallel
const [albums, characters, artists, collections] = await Promise.all([
trpcClient.albums.count.query(),
trpcClient.characters.count.query(),
trpcClient.artists.count.query(),
trpcClient.collections.count.query(),
]);
return (
<div className="grid grid-cols-4 gap-4">
<StatCard title="Albums" count={albums} />
<StatCard title="Characters" count={characters} />
<StatCard title="Artists" count={artists} />
<StatCard title="Collections" count={collections} />
</div>
);
}
function StatCard({ title, count }: { title: string; count: number }) {
return (
<div className="border p-4 rounded">
<h3>{title}</h3>
<p className="text-3xl font-bold">{count}</p>
</div>
);
}
For interactive features, use Client Components with React Query.
app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
src/hooks/use-albums.ts
'use client';
import { useQuery } from '@tanstack/react-query';
import { trpcClient } from '@/utils/trpc-client';
export function useAlbums(offset = 0, limit = 20) {
return useQuery({
queryKey: ['albums', 'all', { offset, limit }],
queryFn: () => trpcClient.albums.all.query({ offset, limit }),
});
}
export function useAlbum(id: number) {
return useQuery({
queryKey: ['albums', 'byId', id],
queryFn: () => trpcClient.albums.getAlbumById.query(id),
enabled: id > 0,
});
}
export function useAlbumCharacters(albumId: number) {
return useQuery({
queryKey: ['albums', albumId, 'characters'],
queryFn: () => trpcClient.albums.getAlbumCharactersById.query(albumId),
enabled: albumId > 0,
});
}
components/albums-list.tsx
"use client";
import { useState } from "react";
import { useAlbums } from "@/hooks/use-albums";
export function AlbumsList() {
const [page, setPage] = useState(0);
const pageSize = 20;
const offset = page * pageSize;
const { data, isLoading, isError, error } = useAlbums(offset, pageSize);
if (isLoading) {
return <div>Loading albums...</div>;
}
if (isError) {
return <div>Error: {error.message}</div>;
}
if (!data) {
return null;
}
const totalPages = Math.ceil(data.totalCount / pageSize);
return (
<div>
<h2>Albums ({data.totalCount})</h2>
<div className="grid gap-4">
{data.data.map((album) => (
<div key={album.id} className="border p-4 rounded">
<h3>{album.title}</h3>
<p>Released: {album.date}</p>
</div>
))}
</div>
<div className="flex gap-2 mt-4">
<button
onClick={() => setPage(page - 1)}
disabled={page === 0}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
Previous
</button>
<span className="px-4 py-2">
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage(page + 1)}
disabled={page >= totalPages - 1}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
Next
</button>
</div>
</div>
);
}
Create a centralized hooks file for all queries:
src/hooks/use-trpc.ts
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { trpcClient } from '@/utils/trpc-client';
// Albums
export const useTrpc = {
// Albums
albums: {
count: () =>
useQuery({
queryKey: ['albums', 'count'],
queryFn: () => trpcClient.albums.count.query(),
}),
all: (offset = 0, limit = 20) =>
useQuery({
queryKey: ['albums', 'all', { offset, limit }],
queryFn: () => trpcClient.albums.all.query({ offset, limit }),
}),
byId: (id: number) =>
useQuery({
queryKey: ['albums', 'byId', id],
queryFn: () => trpcClient.albums.getAlbumById.query(id),
enabled: id > 0,
}),
characters: (albumId: number) =>
useQuery({
queryKey: ['albums', albumId, 'characters'],
queryFn: () => trpcClient.albums.getAlbumCharactersById.query(albumId),
enabled: albumId > 0,
}),
},
// Characters
characters: {
count: () =>
useQuery({
queryKey: ['characters', 'count'],
queryFn: () => trpcClient.characters.count.query(),
}),
all: (offset = 0, limit = 20) =>
useQuery({
queryKey: ['characters', 'all', { offset, limit }],
queryFn: () => trpcClient.characters.all.query({ offset, limit }),
}),
byId: (id: number) =>
useQuery({
queryKey: ['characters', 'byId', id],
queryFn: () => trpcClient.characters.getCharacterById.query(id),
enabled: id > 0,
}),
},
// Artists
artists: {
count: () =>
useQuery({
queryKey: ['artists', 'count'],
queryFn: () => trpcClient.artists.count.query(),
}),
all: (offset = 0, limit = 20) =>
useQuery({
queryKey: ['artists', 'all', { offset, limit }],
queryFn: () => trpcClient.artists.all.query({ offset, limit }),
}),
byId: (id: number) =>
useQuery({
queryKey: ['artists', 'byId', id],
queryFn: () => trpcClient.artists.getArtistById.query(id),
enabled: id > 0,
}),
},
// Collections
collections: {
count: () =>
useQuery({
queryKey: ['collections', 'count'],
queryFn: () => trpcClient.collections.count.query(),
}),
all: (offset = 0, limit = 20) =>
useQuery({
queryKey: ['collections', 'all', { offset, limit }],
queryFn: () => trpcClient.collections.all.query({ offset, limit }),
}),
byId: (id: string) =>
useQuery({
queryKey: ['collections', 'byId', id],
queryFn: () => trpcClient.collections.getCollectionById.query(id),
enabled: Boolean(id),
}),
},
};
Usage:
"use client";
import { useTrpc } from "@/hooks/use-trpc";
export function MyComponent() {
const { data: albumCount } = useTrpc.albums.count();
const { data: albums } = useTrpc.albums.all(0, 10);
return <div>Total: {albumCount}</div>;
}
Use tRPC client in Next.js Server Actions:
app/actions/albums.ts
'use server';
import { revalidatePath } from 'next/cache';
import { trpcClient } from '@/utils/trpc-client';
export async function getAlbumDetails(albumId: number) {
try {
const album = await trpcClient.albums.getAlbumById.query(albumId);
return { success: true, data: album };
} catch (error) {
return { success: false, error: 'Failed to fetch album' };
}
}
export async function prefetchAlbums() {
// Prefetch data for the page
const albums = await trpcClient.albums.all.query({ offset: 0, limit: 20 });
// Optionally revalidate
revalidatePath('/albums');
return albums;
}
Generate static pages at build time:
app/albums/[id]/page.tsx
import { trpcClient } from "@/utils/trpc-client";
// Generate static params at build time
export async function generateStaticParams() {
const albums = await trpcClient.albums.all.query({ offset: 0, limit: 100 });
return albums.data.map((album) => ({
id: album.id.toString(),
}));
}
// Revalidate every hour
export const revalidate = 3600;
export default async function AlbumPage({
params,
}: {
params: { id: string };
}) {
const album = await trpcClient.albums.getAlbumById.query(
parseInt(params.id)
);
return <div>{album?.title}</div>;
}
// Revalidate every 10 minutes
export const revalidate = 600;
export default async function AlbumsPage() {
const albums = await trpcClient.albums.all.query({ offset: 0, limit: 20 });
return <div>{/* render albums */}</div>;
}
Force dynamic rendering:
// Force dynamic
export const dynamic = "force-dynamic";
export default async function AlbumsPage() {
const albums = await trpcClient.albums.all.query({ offset: 0, limit: 20 });
return <div>{/* render albums */}</div>;
}
app/albums/error.tsx
"use client";
export default function AlbumsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
"use client";
import { useTrpc } from "@/hooks/use-trpc";
import { TRPCClientError } from "@trpc/client";
export function AlbumsList() {
const { data, error, isError } = useTrpc.albums.all(0, 20);
if (isError) {
if (error instanceof TRPCClientError) {
if (error.data?.code === "SERVICE_UNAVAILABLE") {
return <div>Service temporarily unavailable. Please try again.</div>;
}
return <div>Error: {error.message}</div>;
}
return <div>An unexpected error occurred</div>;
}
return <div>{/* render albums */}</div>;
}
src/
utils/
trpc-client.ts # Server & Client
hooks/
use-trpc.ts # Client only
components/
albums-list.tsx # "use client"
app/
albums/
page.tsx # Server Component
import { Suspense } from "react";
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<AlbumsList />
</Suspense>
);
}
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { trpcClient } from "@/utils/trpc-client";
import Link from "next/link";
export function AlbumLink({ id, title }: { id: number; title: string }) {
const queryClient = useQueryClient();
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ["albums", "byId", id],
queryFn: () => trpcClient.albums.getAlbumById.query(id),
});
};
return (
<Link href={`/albums/${id}`} onMouseEnter={prefetch}>
{title}
</Link>
);
}
Here's a complete example with all patterns:
app/albums/page.tsx (Server Component)
import { trpcClient } from "@/utils/trpc-client";
import { AlbumsClient } from "./albums-client";
export default async function AlbumsPage() {
// Initial server-side data fetch
const initialData = await trpcClient.albums.all.query({
offset: 0,
limit: 20,
});
return (
<div>
<h1>Suske en Wiske Albums</h1>
<AlbumsClient initialData={initialData} />
</div>
);
}
app/albums/albums-client.tsx (Client Component)
"use client";
import { useState } from "react";
import { useAlbums } from "@/hooks/use-albums";
import type { AlbumType } from "@sstraatemans/sw_trpcclient";
import Link from "next/link";
interface Props {
initialData: {
totalCount: number;
data: AlbumType[];
};
}
export function AlbumsClient({ initialData }: Props) {
const [page, setPage] = useState(0);
const pageSize = 20;
const { data = initialData, isLoading } = useAlbums(
page * pageSize,
pageSize
);
const totalPages = Math.ceil(data.totalCount / pageSize);
return (
<div>
<div className="grid gap-4">
{data.data.map((album) => (
<Link
key={album.id}
href={`/albums/${album.id}`}
className="border p-4 rounded hover:bg-gray-50"
>
<h2 className="font-bold">{album.title}</h2>
<p className="text-gray-600">Released: {album.date}</p>
</Link>
))}
</div>
<div className="flex gap-2 mt-4 justify-center">
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 0 || isLoading}
>
Previous
</button>
<span>Page {page + 1} of {totalPages}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= totalPages - 1 || isLoading}
>
Next
</button>
</div>
</div>
);
}