🛳 Arriving on Fresh Islands: tRPC!

Inspired by the tRPC usage in Create-T3-App, I wondered how this awesome package could be utilized in Island Components in the Deno ‘Fresh’ Framework. Come follow me along on the journey!

To distinquish the framework ‘Fresh’ from the word ‘fresh’, I will write it from now on with a capital F.

TL;DR

You can look directly at my example repo here: https://github.com/codemonument/trpc10-in-fresh-poc

It is also hosted at: https://trpc10-in-fresh.deno.dev/

(The surface is not very exciting, it simply prints “Hello World”. But under the hood it’s very cool, which is why I’m writing this blogpost!)

For the eager amongst you, here are the basic ingredients for integrating tRPC with Fresh Islands. However, keep in mind, that these steps need some adjustments to run in Deno and Fresh:

For an exact description of the journey I took, go to this Github Discussion: https://github.com/denoland/fresh/discussions/866

Or, simply finish reading this post to learn exactly, how this can be implemented! 😉

The Parts

tRPC

If you need a quick refresher, what tRPC is doing and why it is awesome, watch this Youtube Short by Ben Holmes (Twitter: @BHolmesDev):

https://www.youtube.com/embed/YLwtF4yxWrY

Fresh

Fresh is a frontend framework for Deno, which ships zero js per default to the client and provides a way to have interactive ‘islands’ components on the clients. These island components are preact components, and by shipping only the needed js to the client, the loading times are cut to a minimum.

Furthermore, Fresh Apps are fully written in Typescript and mostly server rendered. This makes it a great fit for tRPC, which has the biggest impact when server and client are both written in Typescript. It also helps a lot that Deno can run Typescript directly and out of the box, which makes the integration feel great!

The Integration

Preparation

As preparation I added the following import mapping to my import_map.json:

{
  "imports": {
    "@/": "./"
  }
}

This allows me to import files from the current repo by referencing them from the root of the git repo like this: import {smth} from '@/src/someFolder/someFile.ts'.

The tRPC Router in server.ts

To connect to an API, we first have to define it! Therefore start by creating a src/trpc folder inside your Fresh repo and add a server.ts file to it. You should not put this file somewhere below the routes folder of Fresh, because the files inside will not render as routes.

This file is very similar to the one the tRPC Tutorials, but because it needs a special extra for this integration, we’ll go through it again. Note, that I’m using tRPC V10 here, since it is mostly stable and will be final soon.

First, here is the whole file:

import { initTRPC } from "@trpc/server";
import { Context } from "./fetch-context.ts";
import { z } from "zod";

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

// This export is needed for integrating tRPC with Fresh in `/routes/trpc`
// It is only used on the server!
export const appRouter = t.router({
  hello: t.procedure.input(z.string()).query((req) => {
    return `Hello ${req.input}`;
  }),
});

// This export only export the *type signature* of the trpc router!
// This avoids accidentally importing the full Router into client-side code
export type AppRouter = typeof appRouter;

What is this Context?

We see, that the first import is initTRPC, like in the tRPC tutorials.
But in the second line, we need something extra. This imports a Context type which is defined in the ./fetch-context.ts in the src/trpc folder:

import { inferAsyncReturnType } from "@trpc/server";
import { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";

export function createContext({ req }: FetchCreateContextFnOptions) {
  return { req };
}

export type Context = inferAsyncReturnType<typeof createContext>;

But what exactly is that Context now?
When we go into the tRPC docs for the tRPC fetch-integration (which we are using), we see the following:

Then you need a context that will be created for each request.

This basically means, tRPC allows us to enrich the context which is passed to each api handler with extra information per request! Since their example is for running tRPC in Cloudflare Workers, their createContext Example looks like this:

export function createContext({ req }: FetchCreateContextFnOptions) {
  const user = { name: req.headers.get("username") ?? "anonymous" };
  return { req, user };
}

But we don’t need that functionality today, so we simply pass the request down to the tRPC handlers without changes.

The tRPC Client in client.ts

Now that we have a server, we need a tRPC Client! Since Fresh does the separation of server-side code in routes and client-side code in islands completely automatic, we can put this client.ts directly besides the server.ts into src/trpc:

import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import { IS_BROWSER } from "$fresh/runtime.ts";

import type { AppRouter } from "./server.ts";

/**
 * This guard check for IS_BROWSER is necessary,
 * since `location` is not defined in global scope on server and crashes on deno deploy.
 */
let host;
if (IS_BROWSER) {
  console.log("Origin: ", location?.origin);
  host = location?.origin;
}

export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: `${host}/trpc`,
    }),
  ],
});

The only thing special here is the IS_BROWSER check. This is needed to only read locaton?.origin when executing this file on the client, otherwise the server will crash while building the island component, where this client is used.

The Magic: Fresh Route for tRPC!

The last thing missing now is the link between the tRPC client and the tRPC router. Since we don’t control the server on which Fresh is running, we should integrate these two through Fresh.

Fortunately, Fresh provides us a way to set up a route and capture all following route parts at once, by using a syntax in the filename of a route similar to JS Rest params: [...path.ts].

With this knowledge, we create a file at routes/trpc/[...path].ts and add the following code to it:

import { HandlerContext, Handlers } from "$fresh/server.ts";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/src/trpc/server.ts";
import { createContext } from "@/src/trpc/fetch-context.ts";

export const handler: Handlers = {
  GET(_req: Request, ctx: HandlerContext) {
    return fetchRequestHandler({
      endpoint: "/trpc",
      req: _req,
      router: appRouter,
      createContext,
    });
  },
  POST(_req: Request, ctx: HandlerContext) {
    return fetchRequestHandler({
      endpoint: "/trpc",
      req: _req,
      router: appRouter,
      createContext,
    });
  },
};

Going quickly through this code, it shows:

Testing the tRPC Integration

To test this, create a new Island component in ./islands, called TrpcPlayground.tsx, fire it up and click the button:

import { useState } from "preact/hooks";
import { trpc } from "@/src/trpc/client.ts";

export default function TrpcPlayground() {
  const [greeting, setGreeting] = useState("");

  const fireQuery = () => {
    trpc.hello.query("World").then((res) => setGreeting(res));
  };

  return (
    <div>
      <button onClick={fireQuery}>Fire tRPC Query</button>
      <p>{greeting}</p>
    </div>
  );
}

What we see here:

Enjoy! 🥳

Congratulations, you now know how to use tRPC in Fresh Islands! Enjoy your back-to-front type safety without fiddling with OpenApi Specs or GraphQL Adapters!

If you have any thoughts, suggestions, comments or updates, please hit me up on Twitter at @codemonument!
And if you want to keep updated on my work and thoughts around webdev, deno, fresh and development in general, please leave a follow there too!
I’ve already more blogposts brewing for the future!

Credits

Header Base Photo by Mockup Graphics on Unsplash