← Back to DevBytes

Server-Side Rendering with tRPC: SSR, SSG, ISR

Server-Side Rendering with tRPC: SSR, SSG, ISR

tRPC has become one of the most popular choices for building type-safe APIs in TypeScript applications. When paired with a meta-framework like Next.js, tRPC truly shines because it enables end-to-end type safety while still leveraging powerful rendering strategies such as Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR). In this tutorial, we will explore how to combine tRPC with these rendering strategies to build fast, SEO-friendly, and type-safe applications.

What Is tRPC and Why Rendering Strategy Matters

tRPC is a TypeScript-first RPC framework that lets you build APIs without schemas, code generation, or any of the boilerplate typically associated with REST or GraphQL. The server and client share types directly, which means a change on the server is immediately reflected on the client at compile time.

However, when building modern web applications, the way data is fetched and rendered is just as important as type safety. Next.js offers several rendering strategies:

By combining tRPC with these strategies, you get the best of both worlds: type-safe data fetching and optimal rendering performance.

Setting Up the Project

Let us start by creating a Next.js application with tRPC installed. We will use the Next.js App Router, which is the recommended approach for modern Next.js applications.

npx create-next-app@latest my-trpc-app
cd my-trpc-app
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson

Next, create the tRPC server context and router. The context is where you define what information is available to all your procedures, such as database connections or user sessions.

// src/server/trpc.ts
import { initTRPC } from '@trpc/server';
import superjson from 'superjson';

export const createTRPCContext = async (opts: Record<string, unknown>) => {
  return {
    ...opts,
  };
};

const t = initTRPC.context(createTRPCContext).create({
  transformer: superjson,
});

export const router = t.router;
export const publicProcedure = t.procedure;

Now let us define a simple router with a procedure that fetches posts from a mock data source.

// src/server/routers/post.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';

const posts = [
  { id: 1, title: 'Understanding tRPC', content: 'A deep dive into tRPC.' },
  { id: 2, title: 'SSR vs SSG', content: 'When to use which strategy.' },
  { id: 3, title: 'ISR Explained', content: 'Incremental Static Regeneration basics.' },
];

export const postRouter = router({
  getAll: publicProcedure.query(() => {
    return posts;
  }),
  getById: publicProcedure
    .input(z.object({ id: z.number() }))
    .query(({ input }) => {
      return posts.find((post) => post.id === input.id) ?? null;
    }),
});
// src/server/routers/index.ts
import { router } from '../trpc';
import { postRouter } from './post';

export const appRouter = router({
  post: postRouter,
});

export type AppRouter = typeof appRouter;

Creating the API Handler

Next.js needs an API route to serve tRPC requests. In the App Router, this is done using a route handler.

// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
import { createTRPCContext } from '@/server/trpc';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: createTRPCContext,
  });

export { handler as GET, handler as POST };

Server-Side Rendering (SSR) with tRPC

SSR is ideal for pages that display user-specific or frequently changing data. With the Next.js App Router, server components can call tRPC procedures directly on the server without going through the client. This is done using the createCaller function from tRPC, which lets you invoke procedures as if you were a client but entirely on the server.

// src/app/posts/page.tsx
import { appRouter } from '@/server/routers';
import { createTRPCContext } from '@/server/trpc';

export default async function PostsPage() {
  const caller = appRouter.createCaller(await createTRPCContext({}));

  // This runs entirely on the server
  const posts = await caller.post.getAll();

  return (
    <main>
      <h1>All Posts (SSR)</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.content}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

In this example, the page is rendered on every request, ensuring the latest data is always shown. The createCaller approach is clean because it avoids an extra HTTP round-trip — the server calls its own procedures directly in memory.

Static Site Generation (SSG) with tRPC

SSG pre-renders pages at build time. This is the fastest rendering strategy because the HTML is generated once and served from a CDN. To use SSG with tRPC in the App Router, you simply make your server component async and let Next.js statically generate it at build time. By default, server components without dynamic functions are statically rendered.

// src/app/posts/[id]/page.tsx
import { appRouter } from '@/server/routers';
import { createTRPCContext } from '@/server/trpc';
import { notFound } from 'next/navigation';

export async function generateStaticParams() {
  const caller = appRouter.createCaller(await createTRPCContext({}));
  const posts = await caller.post.getAll();

  return posts.map((post) => ({
    id: String(post.id),
  }));
}

export default async function PostPage({
  params,
}: {
  params: { id: string };
}) {
  const caller = appRouter.createCaller(await createTRPCContext({}));
  const post = await caller.post.getById({ id: Number(params.id) });

  if (!post) {
    notFound();
  }

  return (
    <main>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </main>
  );
}

The generateStaticParams function tells Next.js which dynamic routes to pre-render at build time. Each post page is generated once during the build and served as a static HTML file. This approach is perfect for blog posts, documentation pages, or any content that does not change frequently.

Incremental Static Regeneration (ISR) with tRPC

ISR bridges the gap between SSG and SSR. Pages are generated statically, but they are re-generated in the background at a specified interval. This means users get fast static pages while still seeing updated content periodically. In the App Router, you control ISR with the revalidate export.

// src/app/posts/isr/page.tsx
import { appRouter } from '@/server/routers';
import { createTRPCContext } from '@/server/trpc';

// Revalidate every 60 seconds
export const revalidate = 60;

export default async function ISRPostsPage() {
  const caller = appRouter.createCaller(await createTRPCContext({}));
  const posts = await caller.post.getAll();

  return (
    <main>
      <h1>All Posts (ISR - revalidates every 60s)</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.content}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

With this setup, the first request serves the cached static page. After 60 seconds, the next request triggers a background regeneration. The user still sees the cached version immediately, but subsequent requests will serve the freshly regenerated page. This is an excellent strategy for dashboards, news feeds, or product listings that update periodically.

You can also use on-demand revalidation with ISR, which lets you trigger a regeneration manually via an API route. This is useful when you know exactly when your data changes, such as after a CMS publish event.

// src/app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';

export async function POST(req: NextRequest) {
  const body = await req.json();
  const secret = body.secret;

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
  }

  revalidatePath('/posts/isr');
  return NextResponse.json({ revalidated: true, now: Date.now() });
}

Using tRPC with React Query for Client-Side Hydration

In many real-world applications, you want to fetch data on the server for the initial render but also keep it reactive on the client. This is where tRPC's React Query integration comes in. You can prefetch data on the server, dehydrate the cache, and hydrate it on the client.

// src/lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers';

export const trpc = createTRPCReact<AppRouter>();
// src/app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/lib/trpc';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
}
// src/app/hybrid-posts/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query';
import { appRouter } from '@/server/routers';
import { createTRPCContext } from '@/server/trpc';
import { trpc } from '@/lib/trpc';

export default async function HybridPostsPage() {
  const caller = appRouter.createCaller(await createTRPCContext({}));
  const queryClient = new QueryClient();

  // Prefetch on the server
  await queryClient.prefetchQuery({
    queryKey: [['post', 'getAll']],
    queryFn: () => caller.post.getAll(),
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PostsList />
    </HydrationBoundary>
  );
}

function PostsList() {
  const [posts] = trpc.post.getAll.useSuspenseQuery();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

This hybrid approach gives you the SEO benefits of server-side rendering while maintaining client-side interactivity. The data is fetched once on the server, serialized into the HTML, and then hydrated on the client without an additional fetch.

Best Practices

Conclusion

Combining tRPC with Next.js rendering strategies gives you a powerful toolkit for building modern, type-safe web applications. SSR provides fresh data on every request, SSG delivers blazing-fast static pages, and ISR offers a pragmatic middle ground for content that updates periodically. By using createCaller for direct server-side procedure calls and leveraging React Query hydration for client-side reactivity, you can build applications that are both performant and maintainable. The key is to understand your data requirements for each page and choose the rendering strategy that best fits those needs — all while letting tRPC's end-to-end type safety catch errors before they ever reach production.

— Ad —

Google AdSense will appear here after approval

← Back to all articles