Our tRPC patterns with nextjs app directory

How we use tRPC with nextjs App directory

Technical

tRPC completely changed the way I write my front end code. By having so many features out of the box, we can quickly add new features to our project that include loading states, error handling, and so much more. Nextjs 13 introduced server components that are fully rendered on the server and can use server only functions. One of our first challenges was figuring out how to properly integrate tRPC between our client components and server components in a way that made sense.

The reason we want to use client and server components together is to get rid of the dreaded ‘pop-in’. If you are only loading your data on the client side, the client must send a request on initial page load to render the rest of the page. The time between first paint of the page and the time it takes for the request to finish, and load the page data causes this pop-in. Even though client components are technically server side rendered, they cannot fetch their initial data during this step.

Before going into the solution, lets set the stage. First, we define our router

//trpc.ts
export const createTRPCContext = async () => {
  // Get the session from the server using the getServerSession wrapper function
  const session = await getServerSession(auth);

  return {
    session,
    db,
  };
};

const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

export const router = t.router;

const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: {
      // infers the `session` as non-nullable
      db: ctx.db,
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

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

//root.ts
export const appRouter = router({
  user: userRouter,
	...
});

// export type definition of API
export type AppRouter = typeof appRouter;

Second, we wrap our project in a client component that gives us access to our tRPC client using the type definition from our appRouter

export const Providers: React.FC<{ children: React.ReactNode }> = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: { queries: { staleTime: 5000 } },
      }),
  );
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        loggerLink({
          enabled: () => false,
        }),
        httpBatchLink({
          url: getBaseUrl(),
          fetch: async (input, init?) => {
            const fetch = getFetch();
            return fetch(input, {
              ...init,
              credentials: "include",
            });
          },
        }),
      ],
      transformer: superjson,
    }),
  );
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <SessionProvider>{children}</SessionProvider>
      </QueryClientProvider>
    </trpc.Provider>
  );
};

This is mostly boilerplate, and generally consistent with new tRPC projects. Now, lets look at an example where we fix pop-in. In Papertrail, we want to show the user a list of all the books in their library. We have a tRPC route that fetches the json object needed to render these books, a client component that actually renders the mapped books, and a server component that gets the data for the initial server render. First, lets look at the server component.

export default async function Page() {
  const trpc = appRouter.createCaller({
    db: db,
    session: await getServerSession(auth),
  });
  const books = await trpc.user.current.library.get();
  return (
    <>
      <LibraryGrid initialData={books} />
    </>
  );
}

So, whats going on here? First, we need to create our server side tRPC instance. Since this is a server component, it does not have access to our front end’s tRPC client. the tRPC here is not the same as the tRPC client on the front end. The front end client uses react query, and will use fetch requests to get new data and has all the state associated with the page as a whole. This new server side tRPC is not even a client, just utility we can use to manually call our tRPC routes directly as if they were functions in our backed (because that's what they really are). Part of this setup process is recreating our ctx. This is needed since our tRPC functions use ctx to query our database, or access the user session. Above, we simply import our db connection, and get our server session just like we did when setting up the context in our tRPC middle ware for incoming http requests. Since we are in a server component, we can use things like await and also pass our db instance without fear of leaking anything to the client.

The next step is to actually call our tRPC route. Instead of the normal useMutation or useQuery, we just call the function directly. From here, we pass the results to our client component as the initial data.

'use client'

type Books = inferRouterOutputs<AppRouter>["user"]["current"]["library"]["get"];

export default function LibraryGrid({ initialData }: { initialData: Books }) {
  const { data: books } = trpc.user.current.library.get.useQuery(undefined, {
    initialData: initialData,
    refetchOnMount: false,
    refetchOnReconnect: false,
  });
  return (
    <div className="grid grid-cols-5 p-4">
      {books.map((b) => (
        <div key={b.book.id} className="flex gap-4">
          {b.book.image && (
            <Image
              className="h-52 w-40 rounded shadow-md"
              src={b.book.image}
              alt="image"
              width={1080}
              quality={95}
              height={1920}
            />
          )}
          <div className="flex flex-col justify-center gap-4">
            <div>
              <h3 className="text-xl font-semibold text-gray-900">
                {b.book.title}
              </h3>

              <p className="text-gray-600">by {b.book.authors.join(", ")}</p>
            </div>
          </div>
        </div>
      ))}
    </div>
  );
}

The magic happens here, in our client component. We take our initial data that was passed from our server component, and use it as our initial data in our tRPC caller. Since we have the initial data, we also don't need to fetch when the component mounts, and we don't need to fetch on reconnect either. We can also easily get the type of our tRPC route by using the tRPC type helpers. From here, we can render the page as we normally would using the result of the tRPC query. The key here is that because we have data during the SSR phase, next is actually able to render the entire page on the server, before it gets to the client. Below we can see the HTML sent from the server on our initial page load. Note that in dev tools it is not styled since the CSS is not part of this request.

Untitled.png

Now, when we update data on the client side, we can simply invalidate the query and our client component will smoothly re-render with the new data, and our initial page load has no flicker. It should also be noted that this server side tRPC caller we made earlier is not affected by our front end tRPC client at all. This includes invalidate , refetch, etc. This is not a problem though, as we just need to set the proper caching protocol on the server component (generally force-dynamic). Some libraries like clerk, (and i believe next-auth) will automatically make files dynamic since they are using authentication functions (This is my best guess, I’m not actually sure on this. I do know that our server components that contain getServerSession behave as dynamic without explicitly being marked as such, and without being part of any sort of dynamic routing).

So now in summary, when a user loads our page we first gather the data in our server component, pass it to an SSR client component, and send back the HTML with our initial data rendered out. When a user makes changes, the data will seamlessly update thanks to tRPC. If the user hard reloads the page for any reason, the entire process simply starts over, with the updated initial data.