DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for a first look at create-t3-app
ajcwebdev
ajcwebdev

Posted on • Updated on

a first look at create-t3-app

create-t3-app is a fullstack React framework and CLI that has emerged as an evolution of the T3 stack recommended on Theo Browne's website init.tips. It's described by its creators as "kind of a template," which is meant to stress that it is "NOT a template".

Outline

All the code for this article can be found on my GitHub.

Introduction

ct3a's goal is to provide the quickest way to start a new fullstack, typesafe web application. To achieve this goal, the stack is architected around three foundational constituents:

01-end-to-end-type-safety

Source: Sabin Adams - End-To-End Type Safety

As someone who has resisted TypeScript until now, this is terrifying to me. But I'm going to make an exception and embrace TypeScript for the first time in my life if this stack can actually provide a smooth and streamlined TypeScript experience.

But for those already in love with TypeScript and fullstack React frameworks, you are probably feeling a strange sense of deja-vu right now. This is an almost identical stack to Blitz.js and shares many of the same architectural principles. The notable difference is that CTA includes tRPC (which itself has frequently been compared to Blitz.js).

History of the t3 Stack

The first iteration of the init.tips site suggested only one command was needed to initialize a mostly optimal boilerplate for the majority of web applications in 2021. This suggestion (in its infinite wisdom) was: Create a Next.js app, but with TypeScript.

02-first-version-of-init-tips

As people began to consider this advice, many developers inevitably asked:

"Mmmm, but what about all that other stuff not included in this stack that I need to make an even borderline functional application?"

This lead to other recommendations for add-ons to the stack. These add-ons targeted specific use cases such as:

  • Prisma for managing database migrations and SQL queries through an ORM
  • Next-auth for client side authentication
  • Tailwind for CSS and UI styling
  • tRPC for end-to-end typesafe APIs

If these were being frequently recommended, it stood to reason that it would make sense to create a new, more full featured command. This would generate not only a typed Next.js project, but one with an ORM, authentication, styling, and API protocol.

These would be automatically included while also giving you the ability to opt out if you still want the bare-bones version. I'm happy that this is taking off and that some consider it a novel idea.

I've spent the last two years relentlessly promoting frameworks assembling different versions of these kinds of stacks. RedwoodJS, Blitz.js, and Bison all have extremely similar but also slightly different stacks. To understand how these relate to each other, I would break it down like so:

03-framework-comparison-table

This is not meant to be an exhaustive list and I've purposefully left off things like tests, mocks, Storybook, deployment, and other non-architectural pieces.

As the project has evolved from init.tips to create-t3-app, it has taken on a life of its own. Theo has stated numerous times that he did not actually initiate the creation of create-t3-app, he simply talked about the idea numerous times in public.

Create Nex App

In fact, he never would have had the time to build or manage such a project. On top of full time content creation, he's the CEO of a startup building ping.gg, a collaborative streaming tool. His influence over the project primarily stemmed from his various public discussions of the stack.

These discussions inspired a group of people who were members of his recently formed Discord server. This online space was created to bring together fans of his Twitch and YouTube channels. A group independently began building out a full fledged project. This activity was centered around the work of Shoubhit Dash.

Known as nexxel or nexxeln online, Shoubhit took the initiative to formalize the stack by developing an interactive CLI tool that would be able to scaffold out a project using arbitrary combinations of the various technologies used in the stack. nexxel, a 17 year old self-taught developer, is the true rosetta stone to this project.

04-nexxel-first-discord-message-create-nex-app

Nexxel was blogging about tRPC in May right before launching the framework. Build end to end typesafe APIs with tRPC signaled the birth of the framework on May 21, 2022 along with an initial commit on May 20, 2022. Originally called Create Nex App, the README described the project like so:

Scaffold a starting project using the t3 stack using this interactive CLI.

The early prototypes of the project included Next.js, Tailwind, and TypeScript along with tRPC. Throughout June, the project began attracting around a dozen contributors. Julius Marminge (juliusmarminge) was one of the earliest contributors and remains active today.

Roughly a month later on June 26, 2022, nexxel published T3 stack and my most popular open source project ever. This blog post was published after working with the other contributors to fully integrate Prisma and Next Auth, marking the completion of the stack's initial integration phase.

Throughout the month of June, the GitHub repo gained nearly 2,000 GitHub stars. Despite having only been created at the end of May, the project had reached nearly unprecedented levels of momentum. On July 17, 2022, nexxel migrated his personal blog to create-t3-app and by the middle of August the project had over 5,000 stars.

05-create-t3-app-star-history-chart

Create t3 App

To get started with ct3a, you can run any of the following three commands and answer the command prompt questions:

npx create-t3-app@latest
Enter fullscreen mode Exit fullscreen mode
yarn create t3-app
Enter fullscreen mode Exit fullscreen mode
pnpm dlx create-t3-app@latest
Enter fullscreen mode Exit fullscreen mode

The following CLI options are currently available:

Option Description
--noGit Explicitly tell the CLI to not initialize a new git repo in the project
-y, --default Bypass CLI and use all default options to bootstrap new t3-app
[dir] Include a directory argument with a name for the project
--noInstall Generate project without installing dependencies

We'll give our project a name and select the default option which includes all four external dependencies.

pnpm dlx create-t3-app@latest ajcwebdev-t3 -y
Enter fullscreen mode Exit fullscreen mode

Leaving off the -y option will let you select a custom configuration with the specific packages you want included in your project. It will also ask whether you want to use JavaScript or TypeScript. If you try to select JavaScript though, you will discover that the option is but a mere illusion.

In fact, you must use TypeScript and also there is no God.

   ___ ___   _ _____ ___   _____ ____    _  ___  ___
  / __| _ \ __| /_\_   _| __| |_   _|__ /   /_\ | _ \ _ \
 | (__|   / _| / _ \| | | _|    | |  |_ \  / _ \|  _/  _/
  \___|_|_\___/_/ \_\_| |___|   |_| |___/ /_/ \_\_| |_|


Using: pnpm

βœ” ajcwebdev-t3 scaffolded successfully!

Installing packages...
βœ” Successfully installed nextAuth
βœ” Successfully installed prisma
βœ” Successfully installed tailwind
βœ” Successfully installed trpc

Initializing Git...
βœ” Successfully initialized git
Enter fullscreen mode Exit fullscreen mode

Enter your project directory and install the vercel CLI so we can deploy our project later on.

cd ajcwebdev-t3
pnpm add -D vercel
Enter fullscreen mode Exit fullscreen mode

Start the development server:

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Open localhost:3000 to see the generated project.

06-create-t3-app-localhost

Project Structure

If we ignore the configuration files in the root of our project then our folder and file structure includes the following:

.
β”œβ”€β”€ prisma
β”‚Β Β  └── schema.prisma
β”œβ”€β”€ public
β”‚Β Β  └── favicon.ico
└── src
 Β Β  β”œβ”€β”€ env
 Β Β  β”‚Β Β  β”œβ”€β”€ client.mjs
 Β Β  β”‚Β Β  β”œβ”€β”€ schema.mjs
 Β Β  β”‚Β Β  └── server.mjs
 Β Β  β”œβ”€β”€ pages
 Β Β  β”‚Β Β  β”œβ”€β”€ _app.tsx
 Β Β  β”‚Β Β  β”œβ”€β”€ api
 Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ auth
 Β Β  β”‚Β Β  β”‚Β Β  β”‚Β Β  └── [...nextauth].ts
 Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ examples.ts
 Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ restricted.ts
 Β Β  β”‚Β Β  β”‚Β Β  └── trpc
 Β Β  β”‚Β Β  β”‚Β Β      └── [trpc].ts
 Β Β  β”‚Β Β  └── index.tsx
 Β Β  β”œβ”€β”€ server
 Β Β  β”‚Β Β  β”œβ”€β”€ db
 Β Β  β”‚Β Β  β”‚Β Β  └── client.ts
 Β Β  β”‚Β Β  └── router
 Β Β  β”‚Β Β      β”œβ”€β”€ context.ts
 Β Β  β”‚Β Β      β”œβ”€β”€ example.ts
 Β Β  β”‚Β Β      β”œβ”€β”€ index.ts
 Β Β  β”‚Β Β      β”œβ”€β”€ protected-example-router.ts
 Β Β  β”‚Β Β      └── protected-router.ts
 Β Β  β”œβ”€β”€ styles
 Β Β  β”‚Β Β  └── globals.css
 Β Β  β”œβ”€β”€ types
 Β Β  β”‚Β Β  └── next-auth.d.ts
 Β Β  └── utils
 Β Β      └── trpc.ts
Enter fullscreen mode Exit fullscreen mode

Open src/pages/index.tsx and make some changes to customize the home page. Feel free to follow along or make your own alterations, there are many different ways this project could be organized. First, I will create a file called file called styles.tsx to hold all styling contained in the project.

echo > src/styles/styles.tsx
Enter fullscreen mode Exit fullscreen mode

I will create an object called styles and abstract out the Tailwind styles into variables that can be reused throughout the project.

// src/styles/styles.tsx

export const styles = {
  appContainer: "container mx-auto flex flex-col items-center justify-center min-h-screen p-4",
  title: "text-5xl md:text-[5rem] leading-normal font-extrabold text-gray-700",
  purple: "text-purple-300",
  body: "text-2xl text-gray-700",
  grid: "grid gap-3 pt-3 mt-3 text-center md:grid-cols-2 lg:w-2/3",
  queryResponse: "pt-6 text-2xl text-blue-500 flex justify-center items-center w-full",
  cardSection: "flex flex-col justify-center p-6 duration-500 border-2 border-gray-500 rounded shadow-xl motion-safe:hover:scale-105",
  cardTitle: "text-lg text-gray-700",
  cardDescription: "text-sm text-gray-600",
  link: "mt-3 text-sm underline text-violet-500 decoration-dotted underline-offset-2",
  blogContainer: "container mx-auto min-h-screen p-4",
  blogTitle: "text-5xl leading-normal font-extrabold text-gray-700",
  blogBody: "mb-2 text-lg text-gray-700",
  blogHeader: "text-5xl leading-normal font-extrabold text-gray-700"
}
Enter fullscreen mode Exit fullscreen mode

Add style variables to Home component.

  • appContainer styles the main content
  • title styles the page's h1 header
  • grid styles the div wrapping the TechnologyCard components
  • queryResponse styles the div wrapping the tRPC hello query
// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { styles } from "../styles/styles"

type TechnologyCardProps = {...}

const TechnologyCard = ({
  name, description, documentation
}: TechnologyCardProps) => {...}

export default function Home() {
  const hello = trpc.useQuery([
    "example.hello", { text: "from tRPC" }
  ])

  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta
          name="description"
          content="Example t3 project from A First Look at create-t3-app"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.appContainer}>
        <h1 className={styles.title}>
          Create <span className={styles.purple}>T3</span> App
        </h1>

        <p className={styles.body}>This stack uses:</p>

        <div className={styles.grid}>...</div>
        <div className={styles.queryResponse}>...</div>
      </main>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Add style variables to the TechnologyCard component:

  • cardSection styles the card's container on the section element
  • cardTitle styles the technology's title on each card
  • cardDescription styles the description of each technology
  • link styles the link on each card
// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { styles } from "../styles/styles"

type TechnologyCardProps = {...}

const TechnologyCard = ({
  name, description, documentation
}: TechnologyCardProps) => {
  return (
    <section className={styles.cardSection}>
      <h2 className={styles.cardTitle}>
        {name}
      </h2>

      <p className={styles.cardDescription}>
        {description}
      </p>

      <a
        className={styles.link}
        href={documentation}
        target="_blank"
        rel="noreferrer"
      >
        Documentation
      </a>
    </section>
  )
}

export default function Home() {...}
Enter fullscreen mode Exit fullscreen mode

Now I will modify the four cards to include links to my blog and social media profiles. With that change in mind, I will use url instead of documentation for a more appropriate prop name. I will also change the links to include the entire card within the anchor tags so clicking anywhere on the card will open the hyperlink.

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { styles } from "../styles/styles"

type TechnologyCardProps = {
  name: string
  url: string
}

const TechnologyCard = ({
  name, url
}: TechnologyCardProps) => {
  return (
    <a href={`https://${url}`} target="_blank" rel="noreferrer">
      <section className={styles.cardSection}>
        <h2 className={styles.cardTitle}>
          {name}
        </h2>

        <span className={styles.link}>
          {url}
        </span>
      </section>
    </a>
  )
}

export default function Home() {
  const hello = trpc.useQuery([
    "example.hello", { text: "from tRPC" }
  ])

  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta
          name="description"
          content="Example t3 project from A First Look at create-t3-app"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.appContainer}>
        <h1 className={styles.title}>
          Hello from <span className={styles.purple}>ajc</span>webdev
        </h1>

        <div className={styles.grid}>
          <TechnologyCard name="Blog" url="ajcwebdev.com/" />
          <TechnologyCard name="Twitter" url="twitter.com/ajcwebdev/" />
          <TechnologyCard name="GitHub" url="github.com/ajcwebdev/" />
          <TechnologyCard name="Polywork" url="poly.work/ajcwebdev/" />
        </div>

        <div className={styles.queryResponse}>
          {hello.data ? <p>{hello.data.greeting}</p> : <p>Loading..</p>}
        </div>
      </main>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Return to localhost:3000 to see the changes.

07-home-page-with-ajcwebdev-info

Lastly, I will abstract out the TechnologyCard component into its own file and rename it to Card.

echo > src/components/Card.tsx
Enter fullscreen mode Exit fullscreen mode

Rename TechnologyCardProps to CardProps and create a Card component.

// src/components/Card.tsx

import { styles } from "../styles/styles"

type CardProps = {
  name: string
  url: string
}

export default function Card({
  name, url
}: CardProps) {
  return (
    <a href={`https://${url}`} target="_blank" rel="noreferrer">
      <section className={styles.cardSection}>
        <h2 className={styles.cardTitle}>
          {name}
        </h2>

        <span className={styles.link}>
          {url}
        </span>
      </section>
    </a>
  )
}
Enter fullscreen mode Exit fullscreen mode

Import Card into src/pages/index.tsx and remove CardProps.

// src/pages/index.tsx

import Head from "next/head"
import Card from "../components/Card"
import { styles } from "../styles/styles"

export default function Home() {
  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta
          name="description"
          content="Example t3 project from A First Look at create-t3-app"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.appContainer}>
        <h1 className={styles.title}>
          Hello from <span className={styles.purple}>ajc</span>webdev
        </h1>

        <div className={styles.grid}>
          <Card name="Blog" url="ajcwebdev.com/" />
          <Card name="Twitter" url="twitter.com/ajcwebdev/" />
          <Card name="GitHub" url="github.com/ajcwebdev/" />
          <Card name="Polywork" url="poly.work/ajcwebdev/" />
        </div>
      </main>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Provision PostgreSQL Database

Since this a fullstack framework, it already includes a tool called Prisma for working with our database. Our models will be defined in the prisma/schema.prisma file along with our specific database provider.

Add Posts Model to Prisma Schema

The initial generated project has the database datasource set to SQLite. Since we want to use a real database, open schema.prisma and update the datasource to the PostgreSQL provider.

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
Enter fullscreen mode Exit fullscreen mode

In addition to the current models in the schema, add a Post model with an id, title, description, body, and createdAt timestamp.

// prisma/schema.prisma

model Post {
  id                String    @id
  title             String
  description       String
  body              String
  createdAt         DateTime  @default(now())
}
Enter fullscreen mode Exit fullscreen mode

Also, uncomment all appearances of @db.Text on the Account model.

// prisma/schema.prisma

model Account {
  id                String    @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?   @db.Text
  access_token      String?   @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?   @db.Text
  session_state     String?
  user              User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}
Enter fullscreen mode Exit fullscreen mode

Install CLI and Initialize Railway Project

We'll use Railway to provision a PostgreSQL database. First, you need to create a Railway account and install the Railway CLI. If you are unable to login through the browser, run railway login --browserless instead.

railway login
Enter fullscreen mode Exit fullscreen mode

Run the following command, select "Empty Project," and give your project a name.

railway init
Enter fullscreen mode Exit fullscreen mode

To provision the database, add a plugin to your Railway project and select PostgreSQL.

railway add
Enter fullscreen mode Exit fullscreen mode

Set the DATABASE_URL environment variable for your database and create a .env file to hold it.

echo DATABASE_URL=`railway variables get DATABASE_URL` > .env
Enter fullscreen mode Exit fullscreen mode

Run Database Migration

Run a migration with prisma migrate dev to generate the folders and files necessary to create a new migration. We'll name our migration init with the --name argument.

pnpm prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

After the migration is complete, generate the Prisma client with prisma generate.

pnpm prisma generate
Enter fullscreen mode Exit fullscreen mode

Create a Blog Post

Right now we'll avoid implementing an endpoint through our app with write, update, or delete functionality since we'll not be including authentication in this section. However, there are at least five different ways you can write data to your database. For Prisma, you can either:

The Railway dashboard provides the following methods for accessing your database:

  • Connect to database with the psql command under the Connect tab
  • Enter data with Railway's UI under the Data tab
  • Execute raw SQL queries under the Query tab

GUIs are more intuitive for developers without much SQL experience. But unfortunately, they can also be buggy or cumbersome. You especially don't want to be entering every row by hand when you need to input large amounts of data at once.

SQL commands provide a more consistent and scalable technique for seeding a database or entering ongoing new data. The last option on the list (Query tab on Railway dashboard) gives us the best of both worlds.

It does not require entering data into any GUI but it also does not require installing a Postgres client like psql to your local machine. We could create a blog post with the following command:

INSERT INTO "Post" (id, title, description, body) VALUES (
  '1',
  'A Blog Post Title',
  'This is the description of a blog post',
  'The body of the blog post is here. It is a very good blog post.'
);
Enter fullscreen mode Exit fullscreen mode

This SQL command can be entered directly into the text area under the Query tab.

08-create-post-with-raw-sql-in-query-tab-on-railway-dashboard

Click "Run query" and then add two more blog posts:

INSERT INTO "Post" (id, title, description, body) VALUES (
  '2',
  'Second Blog Post',
  'This is the description of ANOTHER blog post',
  'Even better than the last!'
);
INSERT INTO "Post" (id, title, description, body) VALUES (
  '3',
  'The Final Blog Post',
  'This is the description for my final blog post',
  'My blogging career is over. This is the end, thank you.'
);
Enter fullscreen mode Exit fullscreen mode

Query Posts with tRPC

tRPC is a library that is designed for writing typesafe APIs. Instead of importing server code, the client only imports a single TypeScript type. tRPC transforms this type into a fully typesafe client that can be called from the frontend.

Create Post Router

Create a file where we'll initialize a router instance called postRouter to query for all of our posts.

echo > src/server/router/post.ts
Enter fullscreen mode Exit fullscreen mode

Add a query endpoint to the router with the .query() method. It with accept two arguments: name for the name of the endpoint and params for query parameters.

  • Use params.resolve for the endpoint implementation (a function with a single req argument).
  • Use params.input for input validation (more on this later).
// src/server/router/post.ts

import { prisma } from "../db/client"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    },
  })
Enter fullscreen mode Exit fullscreen mode

In src/server/router/index.ts, there is a base appRouter for our server entry point. This can be gradually extended with more types and resolved into a single object. Import postRouter and use the .merge() method to combine the following into a single appRouter instance:

  • exampleRouter
  • postRouter
  • protectedExampleRouter
// src/server/router/index.ts

import superjson from "superjson"
import { createRouter } from "./context"
import { exampleRouter } from "./example"
import { postRouter } from "./post"
import { protectedExampleRouter } from "./protected-example-router"

export const appRouter = createRouter()
  .transformer(superjson)
  .merge("example.", exampleRouter)
  .merge("post.", postRouter)
  .merge("question.", protectedExampleRouter)

export type AppRouter = typeof appRouter
Enter fullscreen mode Exit fullscreen mode

Queries related to blog posts will be prefixed with post (post.all, post.byId). The hello query example will be prefixed with example as seen earlier with example.hello.

Query Posts with useQuery

Open src/pages/index.tsx to query all posts and display them on the home page. Create a Posts component and initialize a variable called postsQuery above the return statement. Set the postsQuery variable to the output of post.all with the useQuery() hook.

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { styles } from "../styles/styles"
import Card from "../components/Card"

const Posts = () => {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  return (...)
}

export default function Home() {...}
Enter fullscreen mode Exit fullscreen mode

As mentioned in the previous section, the appRouter object can be inferred on the client. Stringify the JSON output from postsQuery.data and display the data below the title of the page.

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { styles } from "../styles/styles"
import Card from "../components/Card"

const Posts = () => {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  const { data } = postsQuery

  return (
    <div className={styles.queryResponse}>
      {data
        ? <p>{JSON.stringify(data)}</p>
        : <p>Loading..</p>
      }
    </div>
  )
}

export default function Home() {...}
Enter fullscreen mode Exit fullscreen mode

Return Posts in the Home component.

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { styles } from "../styles/styles"
import Card from "../components/Card"

const Posts = () => {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  const { data } = postsQuery

  return (
    <div className={styles.queryResponse}>
      {data
        ? <p>{JSON.stringify(data)}</p>
        : <p>Loading..</p>
      }
    </div>
  )
}

export default function Home() {
  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta
          name="description"
          content="Example t3 project from A First Look at create-t3-app"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.appContainer}>
        <h1 className={styles.title}>
          Hello from <span className={styles.purple}>ajc</span>webdev
        </h1>

        <div className={styles.grid}>
          <Card name="Blog" url="ajcwebdev.com/" />
          <Card name="Twitter" url="twitter.com/ajcwebdev/" />
          <Card name="GitHub" url="github.com/ajcwebdev/" />
          <Card name="Polywork" url="poly.work/ajcwebdev/" />
        </div>

        <Posts />
      </main>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

09-display-json-object-with-blog-posts-on-home-page

We have some conditional logic to ensure that a loading message is displayed if the data has not yet returned from the server. But what if there are no blog posts in the database or the server returns an error? This is a case that would be perfectly suited for a Cell.

Add Cells for Declarative Data Fetching

One of my favorite patterns from Redwood that I have been hoping to see in other frameworks is the concept of a Cell. Cells provide a built-in convention for declarative data fetching that isn't exactly a state machine but shares common benefits and characteristics.

Unlike general purpose finite-state machines, Cells are specifically focused on common data fetching outcomes. They give developers the ability to avoid writing any conditional logic since a cell will manage what happens during the following four potential states of your data fetching:

  • Success - Display the response data
  • Failure - Handle the error message and provide instructions to the user
  • Empty - Show a message or graphic communicating an empty list
  • Loading - Show a message or graphic communicating the data is still loading

Thankfully, my hopes were fulfilled when lead tRPC maintainer, Alex Johansson opened a PR with a tRPC Cell example that he acknowledged was influenced by RedwoodJS.

Create Default Query Cell

createQueryCell is used to bootstrap DefaultQueryCell which can be used anywhere in your application.

echo > src/utils/DefaultQueryCell.tsx
Enter fullscreen mode Exit fullscreen mode

Ideally this will one day be internal to either tRPC or create-t3-app and you'll be able to just write cells without thinking about it. But for now, we need to create this ourselves.

// src/utils/DefaultQueryCell.tsx

import { TRPCClientErrorLike } from "@trpc/client"
import NextError from "next/error"
import type { AppRouter } from "../server/router/index"
import {
  QueryObserverIdleResult,
  QueryObserverLoadingErrorResult,
  QueryObserverLoadingResult,
  QueryObserverRefetchErrorResult,
  QueryObserverSuccessResult,
  UseQueryResult,
} from "react-query"

type JSXElementOrNull = JSX.Element | null

type ErrorResult<TData, TError> =
  | QueryObserverLoadingErrorResult<TData, TError>
  | QueryObserverRefetchErrorResult<TData, TError>

interface CreateQueryCellOptions<TError> {
  error: (query: ErrorResult<unknown, TError>) => JSXElementOrNull
  loading: (query: QueryObserverLoadingResult<unknown, TError>) => JSXElementOrNull
  idle: (query: QueryObserverIdleResult<unknown, TError>) => JSXElementOrNull
}

interface QueryCellOptions<TData, TError> {
  query: UseQueryResult<TData, TError>
  error?: (query: ErrorResult<TData, TError>) => JSXElementOrNull
  loading?: (query: QueryObserverLoadingResult<TData, TError>) => JSXElementOrNull
  idle?: (query: QueryObserverIdleResult<TData, TError>) => JSXElementOrNull
}

interface QueryCellOptionsWithEmpty<TData, TError>
  extends QueryCellOptions<TData, TError> {
  success: (query: QueryObserverSuccessResult<NonNullable<TData>, TError>) => JSXElementOrNull
  empty: (query: QueryObserverSuccessResult<TData, TError>) => JSXElementOrNull
}
interface QueryCellOptionsNoEmpty<TData, TError>
  extends QueryCellOptions<TData, TError> {
  success: (query: QueryObserverSuccessResult<TData, TError>) => JSXElementOrNull
}

function createQueryCell<TError>(
  queryCellOpts: CreateQueryCellOptions<TError>,
) {
  function QueryCell<TData>(opts: QueryCellOptionsWithEmpty<TData, TError>): JSXElementOrNull
  function QueryCell<TData>(opts: QueryCellOptionsNoEmpty<TData, TError>): JSXElementOrNull
  function QueryCell<TData>(opts:
    | QueryCellOptionsNoEmpty<TData, TError>
    | QueryCellOptionsWithEmpty<TData, TError>,
  ) {
    const { query } = opts

    if (query.status === 'success') {
      if ('empty' in opts &&
        (query.data == null ||
          (Array.isArray(query.data) && query.data.length === 0))
      ) {
        return opts.empty(query)
      }
      return opts.success(query as QueryObserverSuccessResult<NonNullable<TData>, TError>)
    }

    if (query.status === 'error') {
      return opts.error?.(query) ?? queryCellOpts.error(query)
    }
    if (query.status === 'loading') {
      return opts.loading?.(query) ?? queryCellOpts.loading(query)
    }
    if (query.status === 'idle') {
      return opts.idle?.(query) ?? queryCellOpts.idle(query)
    }
    return null
  }
  return QueryCell
}

type TError = TRPCClientErrorLike<AppRouter>

export const DefaultQueryCell = createQueryCell<TError>({
  error: (result) => (
    <NextError
      title={result.error.message}
      statusCode={result.error.data?.httpStatus ?? 500}
    />
  ),
  idle: () => <div>Loading...</div>,
  loading: () => <div>Loading...</div>,
})
Enter fullscreen mode Exit fullscreen mode

We want to be able to query an individual blog post based on its id. Create a post page with a dynamic route based on the id.

mkdir src/pages/post
echo > src/pages/post/\[id\].tsx
Enter fullscreen mode Exit fullscreen mode

Since we'll be sending data to the database, we need to validate the input. zod is a TypeScript schema validator with static type inference. We'll also import TRPCError for error handling.

// src/server/router/post.ts

import { prisma } from "../db/client"
import { TRPCError } from "@trpc/server"
import { z } from "zod"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    }
  })
Enter fullscreen mode Exit fullscreen mode

Add byId query to Post router in src/server/router/post.ts and destructure the id from the input.

// src/server/router/post.ts

import { prisma } from "../db/client"
import { TRPCError } from "@trpc/server"
import { z } from "zod"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    }
  })

  .query('byId', {
    input: z.object({ id: z.string() }),
    async resolve({ input }) {
      const { id } = input
    },
  })
Enter fullscreen mode Exit fullscreen mode

findUnique query lets you retrieve a single database record based on the id provided by passing it to Prisma's where option.

// src/server/router/post.ts

import { prisma } from "../db/client"
import { TRPCError } from "@trpc/server"
import { z } from "zod"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    }
  })

  .query('byId', {
    input: z.object({ id: z.string() }),
    async resolve({ input }) {
      const { id } = input
      const post = await prisma.post.findUnique({
        where: { id }
      })
    },
  })
Enter fullscreen mode Exit fullscreen mode

Last but not least, throw an error with TRPCError if a post is not returned.

// src/server/router/post.ts

import { prisma } from "../db/client"
import { TRPCError } from "@trpc/server"
import { z } from "zod"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    }
  })

  .query('byId', {
    input: z.object({ id: z.string() }),
    async resolve({ input }) {
      const { id } = input
      const post = await prisma.post.findUnique({
        where: { id }
      })
      if (!post) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: `No post with id '${id}'`
        })
      }
      return post
    }
  })
Enter fullscreen mode Exit fullscreen mode

Create Post Page

Import DefaultQueryCell in src/pages/post/[id].tsx and create a component called PostPage.

// src/pages/post/[id].tsx

import { useRouter } from "next/router"
import Head from "next/head"
import { DefaultQueryCell } from "../../utils/DefaultQueryCell"
import { trpc } from "../../utils/trpc"

export default function PostPage() {
  return (...)
}
Enter fullscreen mode Exit fullscreen mode

Return DefaultQueryCell and pass postQuery to query and data to success.

// src/pages/post/[id].tsx

import { useRouter } from "next/router"
import Head from "next/head"
import { DefaultQueryCell } from "../../utils/DefaultQueryCell"
import { trpc } from "../../utils/trpc"

export default function PostPage() {
  const id = useRouter().query.id as string
  const postQuery = trpc.useQuery([
    'post.byId', { id }
  ])

  return (
    <DefaultQueryCell
      query={postQuery}
      success={({ data }) => (
        <>
          <Head>
            <title>{data.title}</title>
            <meta
              name="description"
              content={data.description}
            />
          </Head>

          <main>
            <h1>{data.title}</h1>
            <p>{data.body}</p>
            <em>
              Created {data.createdAt.toLocaleDateString()}
            </em>
          </main>
        </>
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Lastly, add styles.

// src/pages/post/[id].tsx

import { useRouter } from "next/router"
import Head from "next/head"
import { DefaultQueryCell } from "../../utils/DefaultQueryCell"
import { trpc } from "../../utils/trpc"
import { styles } from "../../styles/styles"

export default function PostPage() {
  const id = useRouter().query.id as string
  const postQuery = trpc.useQuery([
    'post.byId', { id }
  ])

  return (
    <DefaultQueryCell
      query={postQuery}
      success={({ data }) => (
        <>
          <Head>
            <title>{data.title}</title>
            <meta
              name="description"
              content={data.description}
            />
          </Head>

          <main className={styles.blogContainer}>
            <h1 className={styles.blogTitle}>
              {data.title}
            </h1>
            <p className={styles.blogBody}>
              {data.body}
            </p>
            <em>
              Created {data.createdAt.toLocaleDateString()}
            </em>
          </main>
        </>
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Open localhost:3000/post/1 to see your first blog post.

10-first-blog-post-page

Create Posts Cell

echo > src/components/PostsCell.tsx
Enter fullscreen mode Exit fullscreen mode

Create a PostsCell function and import the following above it:

  • Link for linking to each blog post's page
  • styles for styling the posts
  • DefaultQueryCell for creating the cell
  • trpc for executing the query
// src/components/PostsCell.tsx

import Link from "next/link"
import { styles } from "../styles/styles"
import { DefaultQueryCell } from "../utils/DefaultQueryCell"
import { trpc } from "../utils/trpc"

export default function PostsCell() {
  return (...)
}
Enter fullscreen mode Exit fullscreen mode

Create a type called BlogPostProps with an id and title of type string. Delete the Posts component in src/pages/index.tsx and move the useQuery hook into the PostsCell component.

// src/components/PostsCell.tsx

import Link from "next/link"
import { styles } from "../styles/styles"
import { DefaultQueryCell } from "../utils/DefaultQueryCell"
import { trpc } from "../utils/trpc"

type BlogPostProps = {
  id: string
  title: string
}

export default function PostsCell() {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  return (...)
}
Enter fullscreen mode Exit fullscreen mode

Return DefaultQueryCell with the query set to postsQuery. success will map over the data object and display a link for each blog post.

// src/components/PostsCell.tsx

import Link from "next/link"
import { styles } from "../styles/styles"
import { DefaultQueryCell } from "../utils/DefaultQueryCell"
import { trpc } from "../utils/trpc"

type BlogPostProps = {
  id: string
  title: string
}

export default function PostsCell() {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  return (
    <>
      <h2 className={styles.blogHeader}>
        Posts
      </h2>

      {postsQuery.status === 'loading'}

      <DefaultQueryCell
        query={postsQuery}
        success={({ data }: any) => (
          data.map(({id, title}: BlogPostProps) => (
            <Link key={id} href={`/post/${id}`}>
              <p className={styles.link}>
                {title}
              </p>
            </Link>
          ))
        )}
        empty={() => <p>WE NEED POSTS!!!</p>}
      />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Import PostsCell in src/pages/index.tsx and return the component in the Home function.

// src/pages/index.tsx

import Head from "next/head"
import { styles } from "../styles/styles"
import Card from "../components/Card"
import PostsCell from "../components/PostsCell"

export default function Home() {
  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta name="description" content="Example t3 project from A First Look at create-t3-app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.appContainer}>
        <h1 className={styles.title}>
          Hello from <span className={styles.purple}>ajc</span>webdev
        </h1>

        <div className={styles.grid}>
          <Card name="Blog" url="ajcwebdev.com/" />
          <Card name="Twitter" url="twitter.com/ajcwebdev/" />
          <Card name="GitHub" url="github.com/ajcwebdev/" />
          <Card name="Polywork" url="poly.work/ajcwebdev/" />
        </div>

        <PostsCell />
      </main>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

11-home-page-with-blog-post-titles

Deploy to Vercel

Commit your current changes and create a new repository on GitHub with the GitHub CLI.

git add .
git commit -m "ct3a"
gh repo create ajcwebdev-t3 --public --push \
  --source=. \
  --description="An example T3 application with Next.js, Prisma, tRPC, and Tailwind deployed on Vercel." \
  --remote=upstream
Enter fullscreen mode Exit fullscreen mode

Since create-t3-app is mostly Next.js and Prisma at the end of the day, it can be deployed very easily on platforms like Vercel. But, in return for that ease of use, you will be taking a performance hit whenever your database is queried.

When Prisma is running in a Lambda function it has a noticeable cold start. Future guides in the ct3a documentation will demonstrate how to use platforms like Fly, Railway, and Render to deploy your project to a long running server.

Use the following command to pass your database environment variable and deploy to Vercel. Use --confirm to give the default answer for each question.

pnpm vercel --env DATABASE_URL=YOUR_DATABASE_URL_HERE
Enter fullscreen mode Exit fullscreen mode

After the first deployment this command will deploy to a preview branch. You will need to include --prod to push changes directly to the live site for future deployments.

Open ajcwebdev-t3.vercel.app to see your blog.

12-home-page-deployed-on-vercel

API endpoints are exposed on api/trpc/, so ajcwebdev-t3.vercel.app/api/trpc/post.all will display all blog posts.

13-all-posts-trpc-endpoint-on-vercel-api-route

Or you can hit the endpoint with curl:

curl "https://ajcwebdev-t3.vercel.app/api/trpc/post.all" | npx json
Enter fullscreen mode Exit fullscreen mode
{
  "id": null,
  "result": {
    "type": "data",
    "data": {
      "json": [
        {
          "id": "1",
          "title": "A Blog Post Title",
          "description": "This is the description of a blog post",
          "body": "The body of the blog post is here. It is a very good blog post.",
          "createdAt": "2022-08-13T08:30:59.344Z"
        },
        {
          "id": "2",
          "title": "Second Blog Post",
          "description": "This is the description of ANOTHER blog post",
          "body": "Even better than the last!",
          "createdAt": "2022-08-13T08:36:59.790Z"
        },
        {
          "id": "3",
          "title": "The Final Blog Post",
          "description": "This is the description for my final blog post",
          "body": "My blogging career is over. This is the end, thank you.",
          "createdAt": "2022-08-13T08:40:32.133Z"
        }
      ],
      "meta": {
        "values": {...}
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

For single blog posts try any of the following:

And copy them to the end of:

https://ajcwebdev-t3.vercel.app/api/trpc/post.byId?batch=1&input=

Check PageSpeed Insights for Desktop here.

14-pagespeed-insight-desktop

If what I know about these metrics is correct, then I believe 100 is considered a preferable score when compared to other scores which are not 100.

Check PageSpeed Insights for Mobile here.

15-pagespeed-insight-mobile

100 again! Equally as preferable!!

Resources

Articles

Videos

Top comments (5)

Collapse
nexxeln profile image
Shoubhit Dash

Thanks for featuring create-t3-app!

Collapse
ajcwebdev profile image
ajcwebdev Author

Thank you for all the work you've done building and shepherding the project into existence!

Collapse
Sloan, the sloth mascot
Comment deleted
Collapse
Sloan, the sloth mascot
Comment deleted
Collapse
mateuszlatka9 profile image
Mateusz Łątka

Tested

πŸ€” Did you know?

Β 
πŸ“š You can adjust your experience level in Settings to see more relevant content.