Back to overview

Using TRPC in Astro and its (React) islands

| 7 min read

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 zod

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>;

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;

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,
  });
};

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 };

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>

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;

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;

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.

Did you like this post? Check out my latest blog posts:

Maven on EC2
28 Jul 2023

Installing Maven on Ec2 Instance

  • aws
  • maven
  • cloud
Complete CICD Pipeline Java with Jenkins, Nexus, Sonar and AWS services
27 Jul 2023

From Code to Deployment - A Complete CICD journey for Java Apps using Jenkins, Nexus, Sonarqube, AWS ECR & ECS

  • devops
  • aws
  • cicd
Person typing on a laptop
21 Jul 2023

🏗️How to Make Animated ✨GIFs✨ For Amazon Web Services (AWS) Architectures:🏗️ A Step-by-Step Tutorial🏗️

  • aws
  • powerpoint
  • infra
  • resume