How I replaced tRPC with effect rpc in a Next.js app router application

Titouan CREACH - Aug 21 - - Dev Community

I recently fell in love with Effect and began incorporating it into my development projects. As my codebase grew, I found myself using Effect more and more throughout the application.

However, I encountered frustration when one of the layers of my application didn’t integrate well with the others.

While almost every part of my application was using Effect schemas, I discovered that tRPC doesn’t play well with Effect schema transformations. The issue lies in how tRPC infers types.

The input type in tRPC is determined by the return type of the validator function, which is also used by the client to send data to the procedure. However, with Effect schemas, the output of the validator function (decodeUnknownSync) can differ from its input. This is because, in addition to validation, Effect schemas can encode and decode schemas into different types.

I faced a similar issue with the output type. tRPC infers the output type based on the return type of my handler, but I wanted the type to be inferred after the encoding process.

Quick example of this issue:

import * as S from "@effect/schema/Schema";

const MySchema = S.Struct({
  createdAt: DateFromNumber
})

const MyRouter = createTRPCRouter({
  myProcedure: t.procedure
    .input(S.decodeUnknownSync(MySchema))
    .query(async ({ input }) => {
    //                👆 { readonly createdAt: Date }
    })
});

// ❌ But in the client :
api.myRouter.myProcedure.useQuery({ createdAt: new Date() })
//                                                 👆 but here createdAt is required of type Date instead of type number.

Enter fullscreen mode Exit fullscreen mode

To address this issue, the solution I found was to manually encode and decode data on both the client and server sides whenever necessary.

However, this approach proved to be very error-prone, and a significant portion of my code ended up being dedicated to encoding and decoding rather than focusing on the core business logic.

RPC Layer

Effect offers an RPC layer called @effect/rpc. Instead of directly calling functions from the frontend, you define requests with schemas and send them. These requests are automatically encoded and decoded at the appropriate stages.

Create an rpc router

The first step is to create an RPC router. To start, let's define a request using a schema. These schemas should be placed in their own file, as they will be imported by both the backend and frontend.

import * as S from "@effect/schema/Schema";

export class SayHelloReq extends S.TaggedRequest<SayHelloReq>()("SayHelloReq", {
  payload: { name: S.NonEmptyString },
  success: S.NonEmptyString,
  failure: S.Never,
}) {}


Enter fullscreen mode Exit fullscreen mode

Next, create a service to handle the business logic. I recommend doing this so that you can call the service directly from the backend without relying on the RPC layer.


const make = {
  sayHello: (name: string) => {
    return Effect.succeed(`Hello ${name}`);
  },
};

export class HelloService extends Context.Tag("HelloService")<
  HelloService,
  typeof make
>() {
  static Live = Layer.succeed(this, make);
}



Enter fullscreen mode Exit fullscreen mode

Afterward, add the service to your managed runtime. If you don't have a managed runtime yet, I suggest creating one and including all the dependencies your application requires.

Finally, set up the router in /app/api/hello/route.ts.

import { Rpc, RpcRouter } from "@effect/rpc";
import { Effect } from "effect";

export const helloRouter = RpcRouter.make(
  Rpc.effect(SayHelloReq, ({ name }) =>
    HelloService.pipe(Effect.andThen((_) => _.sayHello(name))),
  ),
);


export type HelloRouter = typeof helloRouter;
const handler = RpcRouter.toHandlerNoStream(helloRouter);



Enter fullscreen mode Exit fullscreen mode

Api route to handle rpc requests

Once the router is created, we need to route the HTTP requests to it. To do this, we need to export a POST function from our /app/api/hello/route.ts file.

import { NextRequest, NextResponse } from "next/server";
import { Effect } from "effect";
import { ServerRuntime } from "~/server/ServerRuntime"; // my managed runtime

export const POST = async (req: NextRequest) => {
  const data = await req.json();

  return await handler(data).pipe(
    Effect.andThen(Response.json),
    Effect.tapErrorCause(Effect.logError),
    Effect.andThen(ServerRuntime.runPromise)
  )
}


Enter fullscreen mode Exit fullscreen mode

Now, we have an endpoint on /api/hello that can handle a SayHelloReq.

Client side

On the client side, we need to create an RPC client. A custom HTTP client is necessary because we must explicitly pass cookies; otherwise, authentication won't work during SSR.

Here is the constructor for that client:

"use client";

import { HttpClient, HttpClientRequest } from "@effect/platform";
import * as HttpResolver from "@effect/rpc-http/HttpRpcResolverNoStream";
import { Config, Effect } from "effect";
import * as Resolver from "@effect/rpc/RpcResolver";


// juste because Next.js inline those variables, but we should probably use another config provider

const frontEndConfigHost = Config.succeed(process.env.NEXT_PUBLIC_HOST);

const makeHelloClient = ({ cookies }: { cookies: string }) => {
  const helloClient = Config.string("HOST").pipe(
    Config.orElse(() => frontEndConfigHost),
    Effect.andThen((host) =>
      HttpResolver.make<HelloRouter>(
        HttpClient.fetchOk.pipe(
          HttpClient.mapRequest(
            HttpClientRequest.prependUrl(`${host}/api/hello`),
          ),
          HttpClient.mapRequest(HttpClientRequest.setHeader("cookie", cookies)),
        ),
      ).pipe(Resolver.toClient),
    ),
  );

  return helloClient;
};


Enter fullscreen mode Exit fullscreen mode

Finally, create an instance of the client and pass it through React Context.


export const HelloClientContext = createContext<
  ReturnType<typeof makeHelloClient>
>(makeHelloClient({ cookies: "" }));

export default function RpcClientProvider({
  cookies,
  children,
}: {
  cookies: string;
  children: React.ReactNode;
}) {
  const [client] = useState(() => makeHelloClient({ cookies }));

  return (
    <HelloClientContext.Provider value={client}>
      {children}
    </HelloClientContext.Provider>
  );
}

Enter fullscreen mode Exit fullscreen mode

Then, use the client we just created with react query directly:

import { HelloClientContext } from "./RpcClientProvider";

export default function MyComponent({ name }: { name: string }) {
  const rpcClient = React.useContext(HelloClientContext);
  const queryClient = useQueryClient();

  const message = useSuspenseQuery({
    queryKey: ["hello", name],
    queryFn: () => {
      return rpcClient.pipe(
        Effect.andThen((client) => client(new SayHelloReq({ name: "Foo" }))),
        Effect.runPromise,
      );
    },
  });

  return <div>{message}</div>;
}

Enter fullscreen mode Exit fullscreen mode

And that’s all.

I would also recommend wrapping queries in custom hooks and considering wrapping client creation if you have multiple clients.

Here are some links to code that inspired me:

Thanks for reading

. . .
Terabox Video Player