tyler4949
tyler49493w ago

Creating Service Layer in Next App Router

I have a NextJS application that was built with the t3 stack a few years ago on the pages router. I am finally getting around to migrating to the app router and am running into some confusion. TLDR: I would like to build a service layer (basically just a set of functions) to call trpc endpoints that can be reused/called from both my server and client components. I followed the "Set up with React Server Components" on the trpc v11 docs, and it looks like there are essentially 2 "instances" of trpc that are created, 1 for client components and 1 for server components (trpc/client.tsx and trpc/server.tsx, respectively). Since each of these has a unique way of interacting with the trpc endpoint, and the server.tsx file is "server-only" and cant be used in my client components, I'm not sure how I can go about making a service layer that is reused between client and server components. My existing service layer handled the trpc call as well as other conversions to FE models, so I would really like to avoid duplicating this work from client and server components if possible. Does anyone know how I can achieve this?
6 Replies
reed
reed3w ago
The way I've done it is to have some set of server-side functions that the client and/or server want to use, typically for data IO. I wire those server-side IO functions into tRPC router procedures. Then when I want to use a tRPC router procedure: * Client-side use the tRPC router that wires into React Query, and passes data via the HTTP route handler function (e.g. app/api/trpc). * Server-side use server tRPC instance and call it directly, like to get initial data to use in a server component (often in an app router page.ts). So maybe what solves your problem is taking advantage of calling the same tRPC router and procedures on both client and server.
tyler4949
tyler4949OP3w ago
Thanks for the response! Do you happen to have a code example of this I can view? I think I can picture what you're suggesting, but it's not necessarily how I was hoping to use it. Definitely curious, though. It feels strange to me calling trpc directly from components, rather than a function I can control (by "I" I mean the frontend, I'd like to separate the components from the backend trpc implementation)
reed
reed3w ago
I don't have any public repos I can share, sorry. But I'm happy to chat through some examples here. This create-t3-turbo repo has a tRPC setup example. Because the example repo is using Turborepo the pieces are spread across the repo a bit more. Here they have a blog post router: https://github.com/t3-oss/create-t3-turbo/blob/main/packages/api/src/router/post.ts The difference procedures here are performing database IO. Here they're using tRPC on the server to fetch all of the posts for the homepage before sending the initial HTML to the client: https://github.com/t3-oss/create-t3-turbo/blob/main/apps/nextjs/src/app/page.tsx Here they're using the post.create procedure in the client when the user creates a new post: https://github.com/t3-oss/create-t3-turbo/blob/main/apps/nextjs/src/app/_components/posts.tsx#L37 That's actually using new syntax. I know the tRPC team has been proposing new ways to wire into React Query, so I'll need to look into this example a bit more. The way that I've historically done it is via a tRPC instance that wires directly into a React Query provider, and then I would have used it like
const createPost = trpc.post.create.useMutation()
const createPost = trpc.post.create.useMutation()
I will say that on big projects I've found it convenient to create some kind of react-query layer in my front-end code that calls the tRPC integration pieces, but has control over the react-query integration so that I can be consistent about which mutations invalidate which pieces of the react-query cache, and things like that.
function useCreatePost(args) {
return api.post.create.useMutation(args);
}
function useCreatePost(args) {
return api.post.create.useMutation(args);
}
That strategy would give you that extra layer of functions between tRPC and your client component. In my opinion, the main point of RPC is to call the procedures from environment A and have them execute in environment B, e.g. calling tRPC on the client and having it execute on the server. The server-side tRPC client is more of a nice-to-have so that you can use the same code, logic, router procedures on the server that you already built for the client. Also, at the risk of overexplaining stuff lol, I'll say that I've also found it useful to create a layer on the server that controls my database IO, integrations with external APIs, etc. And my tRPC router procedures call these functions. That way I keep my tRPC code focused on communication over the network, and a consistent, composable router API. The create-t3-turbo is simple because it's an example, but in a "real" codebase this code
create: protectedProcedure
.input(CreatePostSchema)
.mutation(({ ctx, input }) => {
return ctx.db.insert(Post).values(input);
}),
create: protectedProcedure
.input(CreatePostSchema)
.mutation(({ ctx, input }) => {
return ctx.db.insert(Post).values(input);
}),
Would look something like this instead
create: protectedProcedure
.input(CreatePostSchema)
.mutation(createPost),
create: protectedProcedure
.input(CreatePostSchema)
.mutation(createPost),
And this hypothetical createPost function would come from a layer that creates my server-side functionality. That keeps tRPC feeling like a thin communication+API layer to me.
tyler4949
tyler4949OP2w ago
First of all, not over explaining at all haha. I genuinely appreciate the thoughtful response. The create-t3-trubo examples are actually kind of why I originally asked this question though. I am using the create-t3-app CLI. Yes, they both use/call the trpc router endpoints, but the trpc "instance" (not sure if thats even the correct word to use here) is imported from separate files (react.tsx and server.ts) depending on which context theyre using. In a vacuum, this doesnt really matter because the route is set up the same regardless of the instance it uses, but I like to try to isolate potential changes to where they have as minimal as an impact as possible. For example, if I ever move away from trpc, I'd have to go into every component/hook/provider I set up to change these from trpc calls to lets say fetch() calls. I also have some conversion from backend models to frontend models in my current service layer (again in case theres ever a change in backend, my frontend is largely untouched other than service layer/conversion methods). Your suggestion about wrapping these calls in a separated useCreatePost() func is kinda what I was looking for, but that is still specific to a single trpc instance. I suppose a way around this would just be to set up 2 service files (ex. recipe.client-service.ts and recipe.server-service.ts) and then have the correct trpc instance imported for their context. Still feels duplicative but not quite as bad. I do like moving the mutation logic on the router side to createPost but that almost feels like a readability improvement more than anything since the router is shared between approaches. All that to say, I could be completely wrong on all this and this may be me completely overthinking it. I'm brand new to learning server components and just trying to understand why I can't share similar functionality between client and server components, when I think I could if I used something simple like the fetch() api. @Nick Im sorry for the ping but any chance you could take a look at this and let me know if Im missing something obvious? TLDR: I would like to build a service layer (basically just a set of functions) to call trpc endpoints that can be reused/called from both my server and client components.
Nick
Nick2w ago
I really don't know much about nextjs/rcs or t3 at all tbh
tyler4949
tyler4949OP2w ago
@julius apologies for the ping but know you're also involved with the t3 stack. Anyway to look at the TLDR above and see if Im missing something obvious?

Did you find this page helpful?