DEV Community

Andrew Hu
Andrew Hu

Posted on

Full-stack, type-safe applications development with Next.js and tRPC

Table Of Contents

Introduction

When working with a modern UI library, we often need to fetch data from an external source, a REST or GraphQL API. Synchronizing the client with a server state comes with some challenges, including type definitions and safety. All the external data sources define schemas, but they are not immediately consumable from our UI without redefining them, leveraging types generations libraries, or importing monorepo packages. The lack of immediately available types and the introduction of more dedicated tools create friction and slow developers’ productivity and velocity. Suppose your organization is also split into a front-end and back-end team. The communication overhead adds a layer of potential conflicts because the two parts must agree on the schema before starting the development of features.

tRPC provides all the tools to create full-stack end-to-end type-safe applications. It is a library that combines the power of TypeScript and react-query to register all the server endpoints and make them instantly available with types to the client. It increases team productivity because backend endpoints are just functions, and the developers can effectively work on the entire stack. Any team can significantly benefit from this tool because code generation, types package, and communication overhead are phased out.

tRPC also has some downsides. A single developer maintains it. Files cannot be uploaded without any 3rd parties services like Amazon S3. WebSockets have limited support, and data is limited to JSON.

Pros

  • Full-stack end-to-end type-safe application development.
  • Better team productivity and velocity.
  • Minimal communication overhead.
  • If you trust the type system and there is no complex business logic, unit tests may not be required.

Cons

  • Solo maintainer.
  • TypeScript could slow down your IDE.
  • Data is limited to JSON.
  • Limited support for WebSocket.

tRPC Tutorial

tRPC Prerequisites

The tutorial assumes you are familiar with the following libraries:

  • React,
  • Next.js,
  • Prisma or other ORMs,
  • react-query.

tRPC project bootstrap

Let’s initialize a new tRPC project with the create-t3-app CLI:

npx create-t3-app@latest

You will be prompted to answer some questions:

  • Name your application.
  • Select TypeScript.
  • Select Prisma and tRPC.
  • Initialize a git repository.
  • Run npm install.

T3 questions

Once the project is bootstrapped, navigate to prisma/schema.prisma and update it with a new Blog model.

model Blog {
    id        String   @id @default(cuid())
    title     String
    content   String
    createdAt DateTime @default(now())
}
Enter fullscreen mode Exit fullscreen mode

From your terminal, run npx prisma db push to apply the model to a local SQLite database.

tRPC Backend development

We are ready to work on the backend of our application. Navigate to src/server/router and create a new file named blog.ts. Please copy the following code to create two endpoints. One handles the creation of a blog post, and the other lists the inserted articles.

import { createRouter } from "./context";
// `zod` is a schema declaration and validation library. We use it to define the shape of request arguments.
import { z } from "zod";

// `createRouter` exposes functions to create new API endpoints.
export const blogRouter = createRouter()
// A `mutation` defines an operation that modifies our data.
// It behaves like`POST`, `PUT`, `DELETE` operations.
// The input property accepts an optional `zod` schema to validate the request body.
  .mutation("create", {
    input: z.object({
      title: z.string(),
      content: z.string(),
    }),
// The API handler is a simple `resolve` function that contains all the business logic to handle the request.
    resolve({ input, ctx }) {
      return ctx.prisma.blog.create({
        data: {
          title: input.title,
          content: input.content,
        },
      });
    },
  })
// A `query` defines an operation that reads data from our backend.
// It behaves like a GET request.
  .query("all", {
    resolve({ ctx }) {
      return ctx.prisma.blog.findMany();
    },
  });
Enter fullscreen mode Exit fullscreen mode

Once we have defined the routes, we can register them in src/server/router/index.ts.

// src/server/router/index.ts
import { createRouter } from "./context";
import superjson from "superjson";

import { blogRouter } from "./blog";

export const appRouter = createRouter()
  .transformer(superjson)
// Registering the blogRouter
  .merge("blog.", blogRouter);

// export type definition of the API
export type AppRouter = typeof appRouter;
Enter fullscreen mode Exit fullscreen mode

UI development

Navigate to src/pages/index.tsx and paste the following code. The tRPC utilities that wrap react-query enable us to access the defined type-safe backend procedures.

import type { NextPage } from "next";
import { useState } from "react";
import BlogPost from "../components/BlogPost";
import Layout from "../components/Layout";

// import trpc utils that wrap react-query
import { trpc } from "../utils/trpc";

const Home: NextPage = () => {
// Hovering on data shows the types defined in our backend
  const { isLoading, data } = trpc.useQuery(["blog.all"]);

  return (
    <Layout>
      <h1>My personal blog</h1>
      {isLoading ? (
        <div>Loading posts</div>
      ) : (
        data?.map((blog) => <BlogPost blog={blog} key={blog.id} />)
      )}
      <CreatePostSection />
    </Layout>
  );
};

export default Home;

const CreatePostSection = () => {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const ctx = trpc.useContext();
  const createMutation = trpc.useMutation("blog.create");

  return (
    <div>
      {createMutation.isLoading}
      <h2>Create a new blog post</h2>
      <div>
        <input
          name="title"
          placeholder="Your title..."
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />
      </div>
      <div>
        <textarea
          rows={10}
          cols={50}
          value={content}
          placeholder="Start typing..."
          onChange={(e) => setContent(e.target.value)}
        />
      </div>
      <div>
        <button
          onClick={() =>
// mutate exposes the types of arguments as defined in our router
            createMutation.mutate(
              { title, content },
              {
                onSuccess() {
                  ctx.invalidateQueries("blog.all");
                  setContent("");
                  setTitle("");
                },
              }
            )
          }
          disabled={createMutation.isLoading}
        >
          Create new post
        </button>
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Run npm run dev to check the platform. You can further practice with tRPC by expanding the application with new features. Some examples are finding by id, deleting, or updating a single post. A complete solution can be found here https://github.com/andrew-hu368/t3-blog.

Top comments (0)