Using TRPC in Astro and its (React) islands
Tags: astro, react, javascript, webdev
Published: 2023-03-09 19:24:35


I started using [TRPC](https://trpc.io) in some Next.js projects at work, and really liked the end-to-end type safety this gives you as a developer when working with APIs.
So I decided to also implement TRPC on my own website, which is using [Astro](https://astro.build).
There's a few steps that need to be taken to start using TRPC in Astro.

## Installing the required packages

```
npm install @tanstack/react-query @trpc/client @trpc/server @trpc/react-query
```

## Setting up the TRPC context

```ts
// /src/server/context.ts

import { getUser } from '@astro-auth/core';
import type { inferAsyncReturnType } from '@trpc/server';
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';

export function createContext({
  req,
  resHeaders,
}: FetchCreateContextFnOptions) {
  const user = getUser({ server: req });
  return { req, resHeaders, user };
}

export type Context = inferAsyncReturnType<typeof createContext>;
```

Because I want to check the currently logged in user when a TRPC route is called, I add the `getUser()` call from `@astro-auth`. By adding this to the context, I can use the user later on in my middleware (see below).

## Setting up the TRPC server

```ts
// src/server/router.ts

import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import { prisma } from '../lib/prisma';
import type { Comment } from '@prisma/client';
import type { Context } from './context';

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

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

const isAdmin = middleware(async ({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      user: ctx.user,
    },
  });
});

export const adminProcedure = publicProcedure.use(isAdmin);

export const appRouter = t.router({
  getCommentsForBlog: publicProcedure
    .input(z.string())
    .query(async ({ input }) => {
      const blogUrl = input.replace('src/content', '').replace('.mdx', '');
      const commentsForBlogUrl = await prisma?.post.findFirst({
        where: { url: (blogUrl as string) ?? undefined },
        include: { Comment: { orderBy: { createdAt: 'desc' } } },
      });
      const allCommentsInDbForPost = commentsForBlogUrl?.Comment;
      return allCommentsInDbForPost ?? null;
    }),
  createCommentForBlog: publicProcedure
    .input(
      z.object({
        comment: z.string(),
        author: z.string(),
        blogUrl: z.string(),
      })
    )
    .mutation(async ({ input }) => {
      const { comment, blogUrl, author } = input;
      let commentInDb: Comment | undefined;
      const blog = await prisma?.post.findFirst({
        where: { url: blogUrl },
      });
      try {
        commentInDb = await prisma?.comment.create({
          data: {
            author: author ?? '',
            text: comment ?? '',
            post: {
              connectOrCreate: {
                create: {
                  url: blogUrl ?? '',
                },
                where: {
                  id: blog?.id ?? 0,
                },
              },
            },
          },
        });
      } catch (err) {
        console.error('Error saving comment', err);
        return { status: 'error', error: 'Error saving comment' };
      }
      if (!commentInDb) {
        return { status: 'error', error: 'Error saving comment' };
      }
      return { status: 'success' };
    }),
  deleteCommentForBlog: adminProcedure
    .input(z.object({ id: z.number() }))
    .mutation(async ({ input }) => {
      let deleteComment;
      try {
        deleteComment = await prisma?.comment.delete({
          where: {
            id: input.id,
          },
        });
      } catch (e) {
        return { status: 'error', error: 'Error deleting comment' };
      }
      if (!deleteComment) {
        return { status: 'error', error: 'Error deleting comment' };
      }

      return { status: 'success' };
    }),
  sendContactForm: publicProcedure
    .input(
      z.object({ email: z.string().nullable(), message: z.string().nullable() })
    )
    .mutation(async ({ input }) => {
      if (input.email && input.message) {
        await fetch(import.meta.env.FORMSPREE_URL!, {
          method: 'post',
          headers: {
            Accept: 'application/json',
          },
          body: JSON.stringify(input),
        }).catch(e => {
          console.error(e);
          return { status: 'error' };
        });
        return { status: 'success' };
      }
      return { status: 'missingdata' };
    }),
});

export type AppRouter = typeof appRouter;
```

I created a separate procedure `adminProcedure`, on which I applied the middleware. This will make sure that any route on this procedure can only be called if there is a logged in user.
After creating the procedures, I declare the different routes. Check the `deleteCommentForBlog` route which is the route behind the `adminProcedure`.

## Setting up the API Route in Astro

```ts
// /src/pages/api/trpc/[trpc].ts

import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import type { APIRoute } from 'astro';
import { createContext } from '../../../server/context';
import { appRouter } from '../../../server/router';

export const all: APIRoute = ({ request }) => {
  return fetchRequestHandler({
    endpoint: '/api/trpc',
    req: request,
    router: appRouter,
    createContext,
  });
};
```

I'll be using the [fetch adapter](https://trpc.io/docs/fetch) to handle the requests from the client side to the TRPC router.
This is possible because Astro uses the built-in Web Platform APIs `Response` & `Request`.
We link the router and the context, and we're ready to set up the TRPC client.

## Setting up the TRPC client

Because I want to use TRPC both in client side scripts on Astro pages, as well as on [islands](https://docs.astro.build/en/concepts/islands/).
My islands are created using React, to I'll set up the TRPC React client too.

```ts
// /src/client/index.ts

import { createTRPCReact } from '@trpc/react-query';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';

const trpcReact = createTRPCReact<AppRouter>();

const trpcAstro = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/api/trpc',
    }),
  ],
});

export { trpcReact, trpcAstro };
```

## Using the TRPC client in .astro files

I'll be using the Astro TRPC client to communicate from my `<script>` tag on the client to the TRPC routes.

```astro
// /src/pages/context/index.astro export const prerender = true; import Layout
from '../../layouts/Layout.astro';

<form id="contactForm">
  <label class="flex flex-col gap-2 mb-4" for="email">
    Your e-mail
    <input
      class="py-2 px-4 bg-white border-secondary border-2 rounded-lg"
      id="email"
      type="email"
      name="email"
      placeholder="info@example.com"
      required
    />
  </label>
  <label class="flex flex-col gap-2" for="message">
    Your message
    <textarea
      class="py-2 px-4 bg-white border-secondary border-2 rounded-lg"
      rows={3}
      id="message"
      name="message"
      placeholder="Hey, I would like to get in touch with you"
      required></textarea>
  </label>

  <button
    class="px-8 mt-4 py-4 bg-secondary text-white rounded-lg lg:hover:scale-[1.04] transition-transform disabled:opacity-50"
    type="submit"
    id="submitBtn"
  >
    Submit
  </button>
  <div id="missingData" class="text-red-500 font-bold hidden">
    Something went from while processing the contact form. Try again later.
  </div>
  <div id="error" class="text-red-500 font-bold hidden">
    Something went from while processing the contact form. Try again later.
  </div>
</form>
<script>
  import { trpcAstro } from '../../client';
  const form = document.getElementById('contactForm') as HTMLFormElement | null;
  form?.addEventListener('submit', async e => {
    e.preventDefault();
    const formData = new FormData(form);
    const result = await trpcAstro.sendContactForm.mutate({
      message: formData.get('message') as string | null,
      email: formData.get('email') as string | null,
    });
    if (result.status === 'success') {
      window.location.href = '/contact/thanks';
    }
  });
</script>
```

Because I'm using the TRPC client, I get autocompletion on the code, and I know exactly what is expected as input for the route and what will be returned!

## Using the TRPC client in React islands

I decided to work with `@tanstack/react-query` to facilitate easier fetching/mutating in my React code.
Because of this, I needed to instantiate the TRPC client **and** a `QueryClient` for `react-query`.
This I did in a wrapper component, which wraps the actual component which will be doing the calls to the TRPC routes.

```tsx
// /src/components/CommentOverviewWrapper.tsx

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

const CommentsOverviewWrapper = () => {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpcReact.createClient({
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/api/trpc',
        }),
      ],
    })
  );
  return (
    <trpcReact.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <CommentOverview />
      </QueryClientProvider>
    </trpcReact.Provider>
  );
};
export default CommentsOverviewWrapper;
```

The actual component ended up looking like this:

```tsx
// /src/components/CommentOverview.tsx

import type { Comment } from '@prisma/client';
import { trpcReact } from '../client';

const CommentOverview = () => {
  const upToDateCommentsQuery = trpcReact.getAllComments.useQuery();
  const { mutate: deleteComment } = trpcReact.deleteCommentForBlog.useMutation({
    onError: () => {
      console.error('Error deleting comment');
    },
    onSuccess: res => {
      if (res.status === 'error') {
        console.log('Succesfully deleted comment');
      }
    },
    onSettled: () => {
      upToDateCommentsQuery.refetch();
    },
  });

  const commentsReduced = upToDateCommentsQuery?.data?.reduce<{
    [key: string]: typeof upToDateCommentsQuery.data;
  }>(
    (acc, cur) => ({
      ...acc,
      [cur.post.url]: [...(acc[cur.post.url] || []), cur],
    }),
    {}
  );
  return (
    <div className="grid lg:grid-cols-2 gap-6">
      {commentsReduced
        ? Object.entries(commentsReduced).map(([key, val]) => {
            return (
              <div key={key}>
                <h2 className="font-bold mb-4 text-xl">{key}</h2>
                <ul className="flex flex-col gap-y-2">
                  {val.map(comment => (
                    <div className="flex gap-x-2" key={comment.id}>
                      <button
                        type="button"
                        onClick={() => {
                          deleteComment({ id: comment.id });
                        }}
                      >
                        <svg
                          xmlns="http://www.w3.org/2000/svg"
                          fill="none"
                          viewBox="0 0 24 24"
                          strokeWidth={1.5}
                          stroke="currentColor"
                          className="min-w-[1.5rem] h-6 text-red-600"
                        >
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
                          />
                        </svg>
                      </button>
                      <li>
                        <span className="font-bold">{comment.author}</span> :{' '}
                        {comment.text}
                      </li>
                    </div>
                  ))}
                </ul>
              </div>
            );
          })
        : null}
    </div>
  );
};

export default CommentOverview;
```

So fetching all comments is as easy as adding `const upToDateCommentQuery = trpcReact.getAllComments.useQuery()`.
Deleting a comment is done by adding `const { mutate: deleteComment } = trpcReact.deleteCommentForBlog.useMutation` and then in my `<button>`'s click handler calling `deleteComment({ id: comment.id });`.

Hope this was helpful!
Code can be found on [my GitHub](https://github.com/thomasledoux1/website-thomas-astro/tree/feature/with-trpc).