Safely catapult an idea into production using the T3 Stack

You are speaking with your neighbor ‘Dave The Poet’ who you tell that you are a programmer:

“Oh you are a programmer? I have a great idea for a website! Imagine a place where you can write haikus and share them with others! It’s called Haikubook!”

The feeling of dread this proposition induces in developers is comically universal. We see the tremendous amount of work needed to actualize ideas into working products: Developing frontend, developing backend, deploying frontend, deploying backend, setting up a database, setting up a CI/CD pipeline, setting up domains, maintenance, bug hunting, the list goes on.

Let’s put this neighbor’s most likely horrible idea aside and think to ourselves in general: Wouldn’t it be amazing if we could realize our software ideas faster… much faster… and for free?

In this article I want to share with you a tech stack that will launch your idea into production in no time. What we will need is:

  • The T3 stack, a typesafe full-stack app

  • Vercel (for automatic & free serverless deployments)

  • Supabase (because of their free Postgres Database)

What makes this stack special?

At the create-t3-app website, there is a great article about the technological choices made for this stack that I recommend checking out. But to cut it short, the developer experience in the T3 Stack is unparalleled:

  • Full stack typesafety. All the way from database to backend to frontend, everything is typed. And it just works. No codegen needed.

  • Authentication is simple. With NextAuth , session management and adding OAuth providers is straight forward.

  • Frontend styling is effortless with Tailwind CSS.

  • Deployment is just a few clicks away with Vercel, and it works. No need to hire that DevOps guy quite yet!

What’s the catch?

There are tradeoffs with this proposed stack, just like with any stack. Let’s look them over:

  • Scaling. Out of a pricing and performance perspective, this stack is not the best. Using for example Golang, Rust or .NET would probably be better choices in that sense. And deploying them directly on a PaaS such as Azure or AWS.

  • Flexibility. There are some architectural limitations due to the backend being confined to server-side NextJS and the limited functionalities in Prisma.

These problems can be solved by iteratively changing the stack one thing at a time. So we can always deal with this later on.

But with that said, these tradeoffs are not a problem for most early stage products. What we need is to get it done and see if it works or not!

Let’s try this stack in practice

Lets put this tech stack to the test and actually create Haikubook for Dave, after all… he is a very nice neighbor. :)

If you aren’t interested in the development of Dave’s Haikubook and just want to try it out, stop reading here and have look at Haikubook which I created with the proposed stack in less than a day!

The Haikubook source code is available publicly on GitHub here.

Creating the full-stack app

First of all we will create the T3 app, which will contain both the backend and frontend business logic for our Haikubook product. We do this by running the node command:

npm create t3-app@latest

We follow the instructions we are prompted with:

Adding Github as the login provider

Once we are done scaffolding our app we need to set up a way for users to login. create-t3-app adds Discord as the default login provider. Let’s replace it with Github and replace all instances of

DISCORD_CLIENT_ID , DISCORD_CLIENT_SECRET , DiscordProvider

with

GITHUB_CLIENT_ID , GITHUB_CLIENT_SECRET , GithubProvider

Now we can start the app with npm run dev , sign in, and see a secret message:

Lets review what we got so far:

  • Frontend

  • Backend

  • Authentication

  • Database

Let’s proceed to writing the actual business logic for the app so that Dave and his friends can share their Haikus!

Writing the business logic

We will create 4 functionalities for this MVP:

  • Fetch the latest 10 Haikus

  • Post a new Haiku

  • Like a Haiku

  • Unlike a Haiku

Let’s start by adding Haikus and ‘Haiku likes’ to the database schema in the schema.prisma file:

model Haiku {
    id         String      @id @default(cuid())
    title      String
    content    String
    createdAt  DateTime    @default(now())
    updatedAt  DateTime    @updatedAt
    authorId   String
    author     User        @relation(fields: [authorId], references: [id], onDelete: Cascade)
    haikuLikes HaikuLike[]
}

model HaikuLike {
    id        Int      @id @default(autoincrement())
    title     String
    content   String
    createdAt DateTime @default(now())
    haikuId   String
    haiku     Haiku    @relation(fields: [haikuId], references: [id], onDelete: Cascade)
    userId    String
    user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

We can run npx prisma migrate dev to apply the schema changes to our local database.

Now we can add a tRPC router for Haikus, this will work as our server API:

// src/server/api/routers/haiku.ts

import { TRPCError } from "@trpc/server";
import { z } from "zod";

import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";

export const haikuRouter = createTRPCRouter({
  getLatest: protectedProcedure
    .input(z.object({ limit: z.number().min(1).max(30) }))
    .query(({ ctx, input }) => {
      return ctx.db.haiku.findMany({
        orderBy: { createdAt: "desc" },
        take: input.limit,
        include: {
          haikuLikes: { select: { userId: true } },
          author: { select: { name: true } },
        },
      });
    }),

  create: protectedProcedure
    .input(z.object({ content: z.string().min(1).max(200) }))
    .mutation(async ({ ctx, input }) => {
      const haikuIsThreeLines = input.content.split("\n").length === 3;
      if (!haikuIsThreeLines) {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message: "Haiku must be three lines",
        });
      }
      return ctx.db.haiku.create({
        data: {
          content: input.content,
          author: { connect: { id: ctx.session.user.id } },
        },
      });
    }),

  like: protectedProcedure
    .input(z.object({ haikuId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      return ctx.db.haikuLike.upsert({
        where: {
          haikuId_userId: {
            haikuId: input.haikuId,
            userId: ctx.session.user.id,
          },
        },
        create: {
          haiku: { connect: { id: input.haikuId } },
          user: { connect: { id: ctx.session.user.id } },
        },
        update: {
          haiku: { connect: { id: input.haikuId } },
          user: { connect: { id: ctx.session.user.id } },
        },
      });
    }),

  unlike: protectedProcedure
    .input(z.object({ haikuId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      return ctx.db.haikuLike.deleteMany({
        where: {
          haikuId: input.haikuId,
          userId: ctx.session.user.id,
        },
      });
    }),
});

Now we can write the frontend code for Haikubook. We will need a form for creating Haikus, and a “Haiku feed” that displays the latest Haikus. Lets start with the card to display a Haiku:

// src/pages/components/HaikuCard.tsx
import { SVGProps } from "react";

const Heart = (props: SVGProps<SVGSVGElement>) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width="15"
    height="15"
    viewBox="0 0 24 24"
    fill={"none"}
    stroke={"currentColor"}
    stroke-width="2"
    stroke-linecap="round"
    stroke-linejoin="round"
    {...props}
  >
    <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
  </svg>
);
export const HaikuCard = ({
  content,
  author,
  likes,
  isLikedByUser,
  onLike,
  onUnlike,
}: {
  content: string;
  author: string;
  likes: number;
  isLikedByUser: boolean;
  onLike: () => void;
  onUnlike: () => void;
}) => {
  return (
    <div className="min-w-[200px] max-w-sm rounded-2xl border border-gray-400 bg-white/10 shadow-md backdrop-blur-sm dark:bg-gray-900/10">
      <div className="p-5">
        <p className="mb-3 whitespace-pre-line font-normal text-center italic text-gray-200">
          {content}
        </p>
        <div className="flex flex-row justify-between text-xs text-white/50">
          <span>{likes} likes</span>
          <span>- {author}</span>
          <Heart
            fill={isLikedByUser ? "#80e8ff" : "none"}
            onClick={isLikedByUser ? onUnlike : onLike}
          />
        </div>
      </div>
    </div>
  );
};

Now lets put it into a feed:

// src/pages/components/HaikuFeed.tsx
import { api } from "~/utils/api";
import { HaikuCard } from "./HaikuCard";
import { useSession } from "next-auth/react";

export const HaikuFeed = () => {
  const { data: sessionData } = useSession();
  const utils = api.useUtils();
  const haikus = api.haiku.getLatest.useQuery({ limit: 10 });
  const likeHaiku = api.haiku.like.useMutation().mutateAsync;
  const unlikeHaiku = api.haiku.unlike.useMutation().mutateAsync;
  return (
    <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
      {haikus.data?.map((haiku) => (
        <HaikuCard
          author={haiku.author.name ?? "Unknown"}
          key={haiku.id}
          content={haiku.content}
          likes={haiku.haikuLikes.length}
          isLikedByUser={haiku.haikuLikes.some(
            (like) => like.userId === sessionData?.user.id,
          )}
          onLike={() =>
            likeHaiku(
              { haikuId: haiku.id },
              { onSuccess: () => void utils.haiku.getLatest.invalidate() },
            )
          }
          onUnlike={() =>
            unlikeHaiku(
              { haikuId: haiku.id },
              { onSuccess: () => void utils.haiku.getLatest.invalidate() },
            )
          }
        />
      ))}
    </div>
  );
};

Here we utilize the TRPC API that we have created. Note that it is completely typesafe which makes for a very smooth developer experience.

Finally our form for writing our beautiful Haikus:

// src/pages/components/HaikuCreateForm.tsx

import { useForm } from "react-hook-form";
import { api } from "~/utils/api";

export default function HaikuCreateForm() {
  const { register, handleSubmit, reset } = useForm<{ content: string }>();
  const utils = api.useUtils();
  const haikuMutation = api.haiku.create.useMutation();

  const onSubmit = async (data: { content: string }) => {
    try {
      await haikuMutation.mutateAsync(
        { content: data.content },
        {
          onSuccess: () => {
            void utils.haiku.getLatest.invalidate();
          },
        },
      );
      reset();
    } catch (error) {
      console.log("An error occurred.")
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
      <div>
        <label
          htmlFor="content"
          className="block text-sm font-medium text-white"
        >
          Write your Haiku...
        </label>
        <div className="mt-1">
          <textarea
            id="content"
            autoComplete="content"
            required
            {...register("content", { required: true })}
            className="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
          />
        </div>
      </div>

      <div className="flex justify-center">
        <button
          type="submit"
          className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20"
        >
          {"Post Haiku"}
        </button>
      </div>
    </form>
  );
}

Putting it altogether and we now have this:

Amazing! We can do exactly what we set out to do:

  • Fetch the latest 10 Haikus

  • Post a new Haiku

  • Like a Haiku

  • Unlike a Haiku

Dave will be so happy. Let’s take this app to production, it’s time to go live!

Going to production

To go live we will need to

  • Connect the project to Vercel

  • Set up a Postgres database in Supabase

  • Generate a JWT secret for our authentication

  • Create a new GitHub OAuth App that can be used for production

  • Add the environment variables to Vercel

Connect the project to Vercel

Lets connect our GitHub project to Vercel so that it can continuously deploy our app. We do this by logging in to our GitHub account on Vercel and importing our project:

Also override the build command so that it applies our database migrations:

Done! The app can’t deploy yet because its missing some environment variables. We will take care of that in the following steps.

Setting up a database for production

Let’s create a free project on Supabase which gives us a Postgres database. Take note of the database connection string:

In Vercel, add this as an environment variable called DATABASE_URL:

Generate a JWT secret for our app

We need a secret key in order to encrypt our JWT tokens used for authentication. Let’s generate it with openssl :

openssl rand -base64 32

In Vercel, add this as an environment variable called NEXTAUTH_SECRET:

Create a new Github OAuth app for production

We will need a new Github OAuth app that is used in production. Once again, follow the instructions on NextAuth’s website . Add the OAuth variables to Vercel:

And finally, add the NEXTAUTH_URL:

Now we are live!

We proudly show Dave HaikuBook and his reaction is quite underwhelming. To him it never seemed like an impressive feat to realize this idea, and in a way he is right… It has in fact never been easier than this!

Concluding remarks

We have now seen how easy it is to get a idea into production with the T3 Stack. Getting a minimum viable product into production with the T3 stack is great, but the tech stack shines the most in the next stage: feature development. Developing new features in T3 is faster than any other stack thanks to its unparalleled developer experience.

Personally, I would only choose another stack if I knew that the stack’s architectural limitations were an immediate blocker, or that extreme performance was mandatory. But don’t take my word for it, check it out for yourself and give it a spin!

My name is Oliver Johns and I’m a full stack engineer working as an IT consultant at UNQ Consulting. If you want to connect with me on LinkedIn to discuss tech or opportunities, feel free to reach out!