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