tRPC v10 Fast Hands-on

Last year I wrote a tRPC hands-on. And I’ve glad to read it from many developers.

However, tRPC is now v10 released, and there are many breaking changes, so the above hands-on can’t use.

Therefore, in this post, I’ll show you tRPC@10 hands-on. Please give it a try.

StackBlitz ready

If you click the bellow button, you can proceed with this hands-on on StackBlitz.(Of course, you can do it locally too.)

You should see a page like this when you click the button. This is the StackBlitz loading screen.

StackBlitz loading page

After about 20 seconds, you’ll see the page change like this. This is the starter template page.

running local server on StackBlitz

On this page, you will display some posts retrieved by tRPC and also allow you to create posts by building a typesafe API with tRPC.

Defining a backend router

To start, this API will only contain two endpoints with these TypeScript signatures:

posts() => { id: string; title: string; }
postCreate(data: { title: string }) => { id: string; title: string; }

Create a router instance.

First, let’s define an empty router in our server codebase. Create sever/routers/_app.ts as follows:

server/routers/_app.ts
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
 
export const appRouter = t.router({});
// Export type router type signature,
// NOT the router itself.
export type AppRouter = typeof appRouter;

Add a query procedure

Use the t.procedure.query() to add a query procedure/endpoint to the router. The two methods on this procedure are:

  • input(optional): When provided, this should be a function that validates and casts the input of this procedure. It should return a strongly typed value when the input is valid or throw an error if the input is invalid. We recommend using a TypeScript validation library like Zod, Superstruct or Yup for input validation.
  • query: This is the implementation of the procedure (a “resolver”). It’s a function with a single req argument to represent the incoming request. The validated (and strongly typed!) input is passed into req.input.

The following creates a query procedure called posts that returns posts object: (Highlight the changed line)

server/routers/_app.ts
import * as trpc from "@trpc/server";
import { z } from 'zod';
const t = initTRPC.create();
 
interface Post {
  id: string;
  title: string;
}
 
const postList: Post[] = [
  {
    id: "1",
    title: "Hello tRPC!!",
  },
  {
    id: "2",
    title: "How are you?",
  }
];
 
export const appRouter = t.router({
  posts: t.procedure
    .input(
      z
        .object({
          filter: z.string(),
        })
        .nullish()
    )
    .query(({ input }) => {
      if (input?.filter == null || input.filter == '') {
        return postList;
      }
      return postList.filter((post) => post.title.includes(input.filter));
    }),
});
 
// Export type router type signature,
// NOT the router itself.
export type AppRouter = typeof appRouter;

Add a mutation procedure

Similar to GraphQL, tRPC makes a distinction between query and mutation procedures.

The way a procedure works on the server doesn’t change much between a query and a mutation. The method name is different, and the way that the client will use this procedure changes - but everything else is the same!

Let’s add a postCreate mutation by adding it as a new property on our router object:

server/routers/_app.ts
import * as trpc from "@trpc/server";
import { z } from 'zod';
const t = initTRPC.create();
 
interface Post {
  id: string;
  title: string;
}
 
const postList: Post[] = [
  {
    id: "1",
    title: "Hello tRPC!!",
  },
  {
    id: "2",
    title: "How are you?",
  }
];
 
export const appRouter = t.router({
  posts: t.procedure
    .input(
      z
        .object({
          filter: z.string(),
        })
        .nullish()
    )
    .query(({ input }) => {
      if (input?.filter == null || input.filter == '') {
        return postList;
      }
      return postList.filter((post) => post.title.includes(input.filter));
    }),
  postCreate: t.procedure
    .input(z.object({ title: z.string() }))
    .mutation((req) => {
      const id = `${Math.random()}`;
 
      const post: Post = {
        id,
        title: req.input.title,
      };
 
      postList.push(post);
 
      return post;
    }),
});
 
// Export type router type signature,
// NOT the router itself.
export type AppRouter = typeof appRouter;

Export API handler

Next, expose your router as an API of Next.js.

Create pages/api/trpc/[trpc].ts and do the following:

pages/api/trpc/[trpc].ts
import * as trpcNext from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
 
// export API handler
export default trpcNext.createNextApiHandler({
  router: appRouter,
  createContext: () => ({}),
});

Now, you finished to create router 🎉 Next, you will create a client with Next.js.

Create tRPC utility for frontend and interact with a backend router

Create a tRPC utility for Next.js using your API’s type signature. Let’s create a utils/trpc.ts file with the following contents:

utils/trpc.ts
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import type { AppRouter } from '../server/routers/_app'
 
export const trpc = createTRPCNext<AppRouter>({
  config({ ctx }) {
    return {
      links: [
        httpBatchLink({
          url: '/api/trpc/',
        }),
      ],
    };
  },
});

Configure _app.tsx

pages/_app.tsx
import "../styles/globals.css";
import { AppType } from "next/dist/shared/lib/utils";
import { trpc } from '../utils/trpc';
 
const MyApp: AppType = ({ Component, pageProps }) => {
  return <Component {...pageProps} />;
};
 
export default trpc.withTRPC(MyApp);

You’re ready! Let’s connect the server created now.

Use query

Open src/index.tsx then, add import trpc hook.

pages/index.tsx
import type { NextPage } from "next";
import { FormEvent, useState } from "react";
import styles from "../styles/Home.module.css";
import { trpc } from "../utils/trpc";

And, use tRPC query hook like this.

I strongly recommend typing it instead of copy and paste it.

pages/index.tsx
const [title, setTitle] = useState("");
const [filter, setFilter] = useState("");
const [error, setError] = useState("");
const query = trpc.posts.useQuery(); // 👈 Let's type it!

When you type const query = trpc., I think you see posts as a candidate, is it working?

useQuery

This is possible because tRPC use type information of router that you just created as the scheme for client. You don’t need to generate some file like GraphQL and OpenAPI!

Next, display query result like this.

pages/index.tsx
return (
  <section className={styles.container}>
    <main className={styles.main}>
      <header className={styles.title}>
        <h1>tRPC fast hands-on 🚀</h1>
      </header>
      <section className={styles.controller}>
       {/* form component */}
      </section>
      <section className={styles.grid}>
        {query.data?.map((data, i) => (
          <article key={`article-${i}`} className={styles.card}>
            <p>{data.title}</p>
          </article>
        ))}
      </section>
    </main>
  </section>
);

query.data is also type safe, so you will type data. inside callback function in map then, appears candidates which id and title.

type-safe-response

You see the following display on your local server, right?

Expected image of implemented the query

Use query variable

You can filter posts using filter parameter of posts query so let’s set input text to query variables.

Set filter state to useQuery args:

pages/index.tsx
  const [filter, setFilter] = useState("");
  const [error, setError] = useState("");
  const query = trpc.posts.useQuery({ filter })

note

If you type useQuery({ f, editor suggests filter field.

useQuery

This is another power of tRPC. You can use the input scheme declared on the server as the interface of the query variable on the client.

Input scheme is declared on following:

server/routes/_app.ts
export const appRouter = t.router({
posts: t.procedure
  .input(
    z
      .object({
        filter: z.string(),
      })
      .nullish()
  )
  .query(({ input }) => {
    if (input?.filter == null || input.filter == '') {
      return postList;
    }
    return postList.filter((post) => post.title.includes(input.filter));
  }),

Use mutation

In the last of this hands-on, let you create a mutation with tRPC.

Since the form UI is already implemented, you can use the title variable as an input variable in the postCreate mutation.

First, add mutation hook:

pages/index.tsx
  const query = trpc.posts.useQuery({ filter });
  const mutation = trpc.postCreate.useMutation();

Next, mutate on submitting the form.

pages/index.tsx
  async function submitNewPost(e: FormEvent) {
    e.preventDefault();
    mutation.mutate({ title })
  }

Then, you can create a post with tRPC!

useQuery

Conclusion

I don’t think tRPC will solve all problems like the Swiss Army Knife or Silver Bullet, but I do think it will significantly increase the speed of launching a product.

Especially I consider it has a high affinity for developer who want to build type-safe applications but feel GraphQL and OpenAPI are overreaching.

Some of you who have read this far may have the following questions:

  • “I know the basics, but can I implement a real world application or use case?”

  • “Can I implement authentication and error handling?”

  • “Can I implement localization?”

The answer is yes. I deploy application with tRPC to Vercel and get paid by my clients. This application uses firebase authentication to authenticate user and UI is Japanese.

Once again, I will explain how to authenticate and localize and more. If you are curios it, please follow my twitter account, I’ll let you know.