DEV Community

Cover image for Using TRPC in Astro and its (React) islands
Thomas Ledoux
Thomas Ledoux

Posted on • Originally published at thomasledoux.be

Using TRPC in Astro and its (React) islands

I started using TRPC 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.
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
Enter fullscreen mode Exit fullscreen mode

Setting up the TRPC context

// /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>;
Enter fullscreen mode Exit fullscreen mode

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

// 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;
Enter fullscreen mode Exit fullscreen mode

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

// /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,
  });
};
Enter fullscreen mode Exit fullscreen mode

I'll be using the fetch adapter 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.
My islands are created using React, to I'll set up the TRPC React client too.

// /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 };
Enter fullscreen mode Exit fullscreen mode

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.

// /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>
Enter fullscreen mode Exit fullscreen mode

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.

// /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;
Enter fullscreen mode Exit fullscreen mode

The actual component ended up looking like this:

// /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;
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)