Integrating tRPC for Dynamic Data Fetching in a Gatsby Static Site Generation Pipeline


The core architectural tension in building complex applications with a Static Site Generator (SSG) like Gatsby revolves around the dichotomy of build-time data and runtime data. Gatsby’s GraphQL data layer is exceptionally powerful for sourcing content from files, CMSs, and APIs during the build process to generate static assets. This model ensures excellent performance for public, largely unchanging content. The problem arises when an application requires significant, user-specific dynamic data post-deployment. A common pattern is to fetch this data from client-side REST or GraphQL endpoints, but this approach frequently results in a loss of the end-to-end type safety that modern development teams have come to expect.

The challenge is therefore to devise a data-fetching architecture that preserves the benefits of Gatsby’s SSG model for static content while introducing a runtime data layer that is equally robust, type-safe, and offers a superior developer experience for dynamic interactions.

Solution A: The Canonical GraphQL-everywhere Approach

A conventional solution within the Gatsby ecosystem is to double down on GraphQL. The static content is handled by Gatsby’s internal GraphQL server, and dynamic data is fetched from a separate, public-facing GraphQL API. Gatsby pages would use a client-side library like Apollo or urql to query this second API.

Analysis of Merits:

  1. Unified Query Language: Developers use GraphQL for both build-time and runtime data, providing a consistent mental model.
  2. Ecosystem Tooling: The GraphQL ecosystem is mature, with established tooling for caching, state management, and code generation (graphql-codegen).

Analysis of Deficiencies:

  1. Type-Safety Friction: Achieving end-to-end type safety requires a dedicated code generation step. graphql-codegen must be configured to introspect the remote GraphQL schema and generate TypeScript types and typed query hooks. This adds a layer of complexity and a potential point of failure in the CI/CD pipeline. If the API schema and the client-side code generation drift out of sync, it leads to runtime errors that TypeScript should have caught.
  2. Developer Experience Overhead: Writing GraphQL queries as strings in client-side code, even when using fragments, feels disconnected from the TypeScript environment. There is no direct autocompletion of available queries or mutations from the IDE without specialized plugins that themselves require careful configuration.
  3. API Layer Boilerplate: Building a robust, spec-compliant GraphQL server involves significant boilerplate. Resolvers, schemas, and type definitions must be meticulously crafted and maintained. For applications where the API is consumed by a single, known client (the Gatsby app itself), the full flexibility of GraphQL can be overkill.

In a real-world project, this path often leads to a fragile setup where the perceived benefit of a single query language is outweighed by the operational overhead of maintaining schema synchronization and code generation tooling.

Solution B: A Hybrid Architecture with tRPC for Runtime Data

An alternative architecture embraces the split between build-time and runtime concerns. We continue to leverage Gatsby’s GraphQL layer for what it does best: transforming source data into static pages. For all dynamic, client-side data requirements, we introduce tRPC.

tRPC (TypeScript Remote Procedure Call) allows for the creation of fully type-safe APIs without any code generation. The API’s “schema” is simply the TypeScript type definition of the tRPC router on the server. The client can import this type definition directly, enabling full type safety and autocompletion when calling API procedures.

Analysis of Merits:

  1. Absolute Type Safety: Because the client directly infers types from the server’s router definition, it is impossible for the client and server to be out of sync. This eliminates an entire class of bugs and removes the need for a separate code generation step.
  2. Superior Developer Experience: Calling a tRPC procedure from the client feels like calling a local asynchronous function. The developer gets full IntelliSense for procedure names, inputs, and outputs directly within their editor.
  3. Reduced Boilerplate: A tRPC server is lightweight. The focus is on writing TypeScript functions (procedures), not on managing schemas or resolvers. Input validation is handled cleanly with libraries like Zod.

Analysis of Deficiencies:

  1. Architectural Bifurcation: The primary trade-off is the introduction of two distinct data-fetching paradigms within the same application. Developers must understand when to use Gatsby’s useStaticQuery hook for build-time data and when to use a tRPC hook for runtime data. This requires clear team conventions.
  2. Increased Infrastructure Complexity: This model necessitates deploying and managing two artifacts: the static assets generated by Gatsby (typically on a CDN) and a stateful Node.js server to host the tRPC API.

The Decision: Embracing the Hybrid tRPC Model

For applications where the dynamic, user-specific functionality is non-trivial, the benefits of tRPC’s guaranteed type safety and streamlined developer experience present a compelling case. The architectural bifurcation, when managed deliberately, becomes a clean separation of concerns rather than a liability. Static, public content is managed by the SSG pipeline; dynamic, private data is managed by the API server. This separation can lead to a more resilient and maintainable system in the long run.

The following implementation details a production-grade setup for this hybrid architecture using a pnpm monorepo to facilitate type sharing between the Gatsby application and the tRPC server.

Core Implementation

The architecture is best managed within a monorepo structure to enable seamless sharing of TypeScript types.

Project Structure:

/
├── packages/
│   ├── gatsby-app/      # The Gatsby client application
│   │   ├── src/
│   │   ├── gatsby-config.ts
│   │   └── package.json
│   ├── api-server/      # The Node.js Express + tRPC server
│   │   ├── src/
│   │   └── package.json
│   └── trpc-shared/     # A shared package for router type definitions
│       ├── index.ts
│       └── package.json
├── package.json
└── pnpm-workspace.yaml

This structure isolates the three main components while allowing gatsby-app and api-server to depend on trpc-shared.

1. The tRPC API Server (packages/api-server)

This is a standard Express server with the tRPC middleware. It defines the API’s procedures and their logic.

packages/api-server/src/trpc.ts:

import { initTRPC, TRPCError } from '@trpc/server';
import type { CreateExpressContextOptions } from '@trpc/server/adapters/express';

// This context would be populated by auth middleware in a real app.
// It could contain the user's session, database connections, etc.
interface AppContext {
  user?: {
    id: string;
    role: 'user' | 'admin';
  };
}

// Context creation for each request
export const createContext = ({ req, res }: CreateExpressContextOptions): AppContext => {
  // For this example, we'll simulate a logged-in user.
  // In production, you would validate a JWT or session cookie here.
  const user = { id: 'user-123', role: 'admin' as const };
  return { user };
};

const t = initTRPC.context<AppContext>().create();

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

// Middleware to protect procedures that require authentication
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    // A common mistake is not throwing a specific TRPCError.
    // Generic errors won't be properly serialized for the client.
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      // The `user` is now guaranteed to be non-null in protected procedures.
      user: ctx.user,
    },
  });
});

export const protectedProcedure = t.procedure.use(isAuthed);

packages/api-server/src/routers/userRouter.ts:

import { z } from 'zod';
import { protectedProcedure, publicProcedure, router } from '../trpc';

// A mock database for demonstration purposes.
const mockDb = {
  users: [
    { id: 'user-123', name: 'Alice', email: '[email protected]' },
    { id: 'user-456', name: 'Bob', email: '[email protected]' },
  ],
};

export const userRouter = router({
  getUserById: publicProcedure
    .input(z.object({ userId: z.string().min(1) }))
    .query(({ input }) => {
      console.log(`[API] Fetching user with ID: ${input.userId}`);
      const user = mockDb.users.find(u => u.id === input.userId);
      if (!user) {
        // Return null for not found, tRPC will correctly type this on the client.
        return null;
      }
      return user;
    }),

  getCurrentUser: protectedProcedure.query(({ ctx }) => {
    // Thanks to the `isAuthed` middleware, `ctx.user` is guaranteed to exist.
    const user = mockDb.users.find(u => u.id === ctx.user.id);
    return user ?? null;
  }),

  updateProfile: protectedProcedure
    .input(z.object({ name: z.string().optional(), email: z.string().email().optional() }))
    .mutation(async ({ input, ctx }) => {
      const userIndex = mockDb.users.findIndex(u => u.id === ctx.user.id);
      if (userIndex === -1) {
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Could not find user to update.',
        });
      }

      const updatedUser = { ...mockDb.users[userIndex], ...input };
      mockDb.users[userIndex] = updatedUser;
      
      console.log(`[API] User profile updated for ${ctx.user.id}`, updatedUser);
      return { success: true, user: updatedUser };
    }),
});

packages/api-server/src/root.ts:

import { router } from './trpc';
import { userRouter } from './routers/userRouter';

// This is the root router that combines all sub-routers.
// Its type definition will be shared with the client.
export const appRouter = router({
  user: userRouter,
  // ... other routers can be added here
});

// Export only the type of the AppRouter.
// This is the crucial piece for type safety.
export type AppRouter = typeof appRouter;

packages/api-server/src/server.ts:

import express from 'express';
import cors from 'cors';
import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter } from './root';
import { createContext } from './trpc';

const app = express();
const PORT = process.env.PORT || 4000;

// In a production environment, the CORS origin should be
// restricted to the domain of your Gatsby site.
app.use(cors({ origin: 'http://localhost:8000' }));

app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext,
    onError: ({ path, error }) => {
      console.error(`[API ERROR] tRPC failed on ${path}:`, error);
    },
  }),
);

app.listen(PORT, () => {
  console.log(`🚀 API server listening on http://localhost:${PORT}`);
});

2. The Shared Type Package (packages/trpc-shared)

This package’s sole purpose is to export the AppRouter type from the server. It contains almost no code.

packages/trpc-shared/index.ts:

// This file re-exports the type from the server implementation.
// The Gatsby client will import this type without importing any server-side code.
import type { AppRouter } from '../../api-server/src/root';

export type { AppRouter };

Its package.json would be minimal, and gatsby-app will list it as a dependency.

3. The Gatsby Client Integration (packages/gatsby-app)

Now, we configure the Gatsby app to consume the tRPC API.

packages/gatsby-app/src/lib/trpc.ts:

import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from 'trpc-shared';

// Create a tRPC hook that is strongly typed with our AppRouter.
export const trpc = createTRPCReact<AppRouter>();

packages/gatsby-app/src/api/trpc-provider.tsx:

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

const API_URL = 'http://localhost:4000/trpc';

export const TRPCProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
    const [queryClient] = useState(() => new QueryClient({
      defaultOptions: {
        queries: {
          refetchOnWindowFocus: false, // Disables aggressive refetching
          staleTime: 1000 * 60 * 5, // 5 minutes
        }
      }
    }));

    const [trpcClient] = useState(() =>
      trpc.createClient({
        links: [
          httpBatchLink({
            url: API_URL,
            // You can pass custom headers here, e.g., for authentication.
            async headers() {
              return {
                // authorization: getAuthCookie(),
              };
            },
          }),
        ],
      })
    );
    
    return (
        <trpc.Provider client={trpcClient} queryClient={queryClient}>
            <QueryClientProvider client={queryClient}>
                {children}
            </QueryClientProvider>
        </trpc.Provider>
    );
};

This provider needs to wrap the root of the application.

packages/gatsby-app/gatsby-browser.tsx and packages/gatsby-app/gatsby-ssr.tsx:

import React from 'react';
import { TRPCProvider } from './src/api/trpc-provider';

export const wrapRootElement = ({ element }) => {
    return <TRPCProvider>{element}</TRPCProvider>;
};

Finally, we use the tRPC hooks within a component. This is best done on a page designated as a client-only route, as it contains purely dynamic data.

packages/gatsby-app/gatsby-node.js:

// This configures any page under /dashboard/ to be a client-only route.
// Gatsby will skip rendering it at build time and let React take over in the browser.
exports.onCreatePage = async ({ page, actions }) => {
  const { createPage } = actions;
  if (page.path.match(/^\/dashboard/)) {
    page.matchPath = "/dashboard/*";
    createPage(page);
  }
};

packages/gatsby-app/src/pages/dashboard/profile.tsx:

import React, { useState } from 'react';
import type { PageProps } from 'gatsby';
import { trpc } from '../../lib/trpc';

const ProfilePage: React.FC<PageProps> = () => {
  // `useQuery` hook for fetching data.
  // The path `user.getCurrentUser` is fully typed and autocompleted.
  const { data: user, isLoading, error } = trpc.user.getCurrentUser.useQuery();

  const utils = trpc.useContext();
  // `useMutation` hook for sending data.
  const updateUserMutation = trpc.user.updateProfile.useMutation({
    onSuccess: (data) => {
      console.log('Update successful:', data);
      // A crucial pattern: invalidate the query cache after a successful mutation
      // to trigger a refetch and keep the UI in sync.
      utils.user.getCurrentUser.invalidate();
    },
    onError: (error) => {
      console.error('Update failed:', error.message);
      // Here you would show a toast notification or an error message to the user.
    }
  });

  const [name, setName] = useState('');

  if (isLoading) {
    return <div>Loading profile...</div>;
  }

  if (error) {
    // The error object is also fully typed.
    return <div>Error: {error.message}</div>;
  }

  const handleUpdate = (e: React.FormEvent) => {
    e.preventDefault();
    if (name.trim()) {
      // The input to `mutate` is typed according to the Zod schema on the server.
      updateUserMutation.mutate({ name });
    }
  };

  return (
    <div>
      <h1>User Profile</h1>
      {user ? (
        <div>
          <p><strong>ID:</strong> {user.id}</p>
          <p><strong>Name:</strong> {user.name}</p>
          <p><strong>Email:</strong> {user.email}</p>

          <form onSubmit={handleUpdate}>
            <h2>Update Name</h2>
            <input
              type="text"
              value={name}
              onChange={(e) => setName(e.target.value)}
              placeholder="New name"
              disabled={updateUserMutation.isLoading}
            />
            <button type="submit" disabled={updateUserMutation.isLoading}>
              {updateUserMutation.isLoading ? 'Updating...' : 'Update'}
            </button>
          </form>

        </div>
      ) : (
        <p>Could not load user data.</p>
      )}
    </div>
  );
};

export default ProfilePage;

Architectural Flow Diagram

This diagram visualizes the separation of the build-time and runtime data flows.

graph TD
    subgraph Build Time
        A[Content Files .md] --> B(Gatsby Build Process);
        B -- gatsby-source-filesystem --> C{Gatsby Internal GraphQL Data Layer};
        C --> D[Static HTML/JS/CSS];
    end

    subgraph Deployment
        D -- Deployed to --> K[CDN / Static Host];
        H[tRPC API Server] -- Deployed to --> L[Node.js Host / Serverless Function];
    end

    subgraph Runtime
        subgraph Browser
            E[User visits /dashboard/profile] --> F(React Component);
            F -- `trpc.user.getCurrentUser.useQuery()` --> G(tRPC Client);
        end
        G -- Type-safe HTTP Request --> H;
        H -- Business Logic / DB Query --> I[Database];
        I --> H;
        H -- JSON Response --> G;
        G -- Typed Data & State --> F;
        F --> J[Render Dynamic UI];
    end

    K -- Serves Initial Page Shell --> Browser;

This hybrid architecture successfully marries the performance of static site generation with the robustness of a fully type-safe, modern API layer. It isolates concerns effectively: Gatsby handles the public-facing, content-driven parts of the site, while tRPC and its server handle the interactive, data-driven application logic. The cost is an increase in operational complexity, as two distinct services must be deployed and maintained. However, for projects that demand both high performance for anonymous users and a rich, secure experience for authenticated users, this trade-off is often justified. The boundary of its applicability lies where the majority of an application’s content becomes dynamic; in such cases, a framework with a more integrated server-side rendering model, like Next.js, may prove to be a more direct solution.


  TOC