Using TRPC in Astro and its (React) islands 8 Mar 2023 | 7 min read
| View count:
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
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
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
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.
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.
---
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.
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:
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 .