Build a full stack app with create-t3-app

nexxeln

Shoubhit Dash

Posted on August 12, 2022

Build a full stack app with create-t3-app

This blog post is outdated. I no longer have time to keep updating this post.

Hey there! Today we'll be building an application with the T3 stack. We're going to build a Guestbook inspired by Lee Robinson's Guestbook. Let's get right into it!

Getting Started

Let's set up a starter project with create-t3-app!



npm create t3-app@latest


Enter fullscreen mode Exit fullscreen mode

We're going to utilize all parts of the stack.

create-t3-app

Let's also set up a Postgres database on Railway. Railway makes it super simple to quickly set up a database.

Go to Railway and log in with GitHub if you haven't already. Now click on New Project.

New Project

Now provision Postgres.

Provision Postgres

It's as simple as that. Copy the connection string from the Connect tab.

Copy the connection string

Let's start coding! Open the project in your favourite code editor.

There are a lot of folders but don't be overwhelmed. Here's a basic overview.

  • prisma/* - The prisma schema.
  • public/* - Static assets including fonts and images.
  • src/env/* - Validation for environment variables.
  • src/pages/* - All the pages of the website.
  • src/server/* - The backend, which includes a tRPC server, Prisma client, and auth utility.
  • src/styles/* - Global CSS files, but we're going to be using Tailwind CSS for most of our styles.
  • src/types/* - Next Auth type declarations.
  • src/utils/* - Utility functions.

Duplicate the .env.example file and rename the new copy as .env. Open the .env file and paste the connection string in DATABASE_URL.

You'll notice we have Discord OAuth set up using next-auth, so we also need a DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET. Let's set that up.

Setting up authentication

Go to the Discord Developers Portal and create a new application.

New Application on Discord

Go to OAuth2/General and add all of the callback URLs to Redirects. For localhost the callback URL is http://localhost:3000/api/auth/callback/discord. I also added the production URL ahead of time.

Add redirects

Copy the client ID and secret and paste both of them into .env.

Copy client ID and secret

Uncomment NEXTAUTH_SECRET and set it as some random string too. Now we have all of our environment variables configured.

Let's also change the database to postgresql and uncomment the @db.Text annotations in the Account model in prisma/schema.prisma. All the models you see in the schema are necessary for Next Auth to work.

Changes in prisma/schema.prisma

Let's push this schema to our Railway Postgres database. This command will push our schema to Railway and generate type definitions for the Prisma client.



npx prisma db push


Enter fullscreen mode Exit fullscreen mode

Now run the dev server.



npm run dev


Enter fullscreen mode Exit fullscreen mode

Go to the src/pages/index.tsx file and delete all the code, let's just render a heading.



// src/pages/index.tsx

const Home = () => {
  return (
    <main>
      <h1>Guestbook</h1>
    </main>
  );
};

export default Home;


Enter fullscreen mode Exit fullscreen mode

Render a heading

I can't look at light themes, so lets apply some global styles in src/styles/globals.css to make this app dark theme.



/* src/styles/globals.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  @apply bg-neutral-900 text-neutral-100;
}


Enter fullscreen mode Exit fullscreen mode

Dark theme

Much better.

If you look at src/pages/api/auth/[...nextauth].ts, you can see we have Discord OAuth already set up using Next Auth. Here is where you can add more OAuth providers like Google, Twitter, etc.

Now let's create a button to let users login with Discord. We can use the signIn() function from Next Auth.



// src/pages/index.tsx

import { signIn } from "next-auth/react";

const Home = () => {
  return (
    <main>
      <h1>Guestbook</h1>
      <button
        onClick={() => {
          signIn("discord").catch(console.log);
        }}
      >
        Login with Discord
       </button>
    </main>
  );
};

export default Home;


Enter fullscreen mode Exit fullscreen mode

We can use the useSession() hook to get the session for the user. While we're at it, we can also use the signOut() function to implement log out functionality.



// src/pages/index.tsx

import { signIn, signOut, useSession } from "next-auth/react";

const Home = () => {
  const { data: session, status } = useSession();

  if (status === "loading") {
    return <main>Loading...</main>;
  }

  return (
    <main>
      <h1>Guestbook</h1>
      <div>
        {session ? (
          <>
            <p>hi {session.user?.name}</p>
            <button
              onClick={() => {
                signOut().catch(console.log);
              }}
            >
              Logout
            </button>
          </>
        ) : (
          <button
            onClick={() => {
              signIn("discord").catch(console.log);
            }}
          >
            Login with Discord
           </button>
        )}
      </div>
    </main>
  );
};

export default Home;


Enter fullscreen mode Exit fullscreen mode

Auth works

Great! We now have auth working. Next Auth really makes it stupidly simple.

Backend

Let's work on the backend now. We'll be using tRPC for our API layer and Prisma for connecting and querying our database.

We're going to have to modify our prisma schema and add a Guestbook model. Each message in the guestbook will have a name and a message. Here's how the model will look like.



model Guestbook {
    id        String   @id @default(cuid())
    createdAt DateTime @default(now())
    name      String
    message   String   @db.VarChar(100)
}


Enter fullscreen mode Exit fullscreen mode

Let's push this modified schema to our Railway Postgres database.



npx prisma db push


Enter fullscreen mode Exit fullscreen mode

Now let's get to the fun part - it's tRPC time. Go ahead and delete the example.ts file in src/server/api/routers folder. Then in the same folder, create a new file called guestbook.ts.

First, we're going to define a mutation to post messages to our database.



// src/server/api/routers/guestbook.ts

import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "../trpc";

export const guestbookRouter = createTRPCRouter({
  postMessage: publicProcedure
    .input(
      z.object({
        name: z.string(),
        message: z.string(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      try {
        await ctx.prisma.guestbook.create({
          data: {
            name: input.name,
            message: input.message,
          },
        });
      } catch (error) {
        console.log(error);
      }
    }),
});


Enter fullscreen mode Exit fullscreen mode

Here we have a tRPC mutation that uses zod to validate the input and has an async function that runs a single prisma query to create a new row in the Guestbook table.

Working with Prisma is an absolutely wonderful example. The autocomplete and typesafety is amazing.

We also want this mutation to be protected. Here we can use tRPC middlewares.

If you take a look at the src/server/auth.ts file, we're using unstable_getServerSession from Next Auth that gives us access to the session on the server.



// src/server/auth.ts

import { type GetServerSidePropsContext } from "next";
import { unstable_getServerSession } from "next-auth";
import { authOptions } from "../pages/api/auth/[...nextauth]";

export const getServerAuthSession = async (ctx: {
  req: GetServerSidePropsContext["req"];
  res: GetServerSidePropsContext["res"];
}) => {
  return await unstable_getServerSession(ctx.req, ctx.res, authOptions);
};


Enter fullscreen mode Exit fullscreen mode

We're passing that session into our tRPC context.



// src/server/api/trpc.ts

export const createTRPCContext = async (opts: CreateNextContextOptions) => {
  const { req, res } = opts;
  const session = await getServerAuthSession({ req, res });
  return createInnerTRPCContext({
    session,
  });
};


Enter fullscreen mode Exit fullscreen mode

Then, we can use this session to make our mutation protected using a protectedProcedure.



// src/server/api/trpc.ts

const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session || !ctx.session.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: {
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);


Enter fullscreen mode Exit fullscreen mode

Now, replace publicProcedure with protectedProcedure to make our mutation protected from unauthenticated users.



// src/server/api/routers/guestbook.ts

import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";

export const guestbookRouter = createTRPCRouter({
  postMessage: protectedProcedure
    .input(
      z.object({
        name: z.string(),
        message: z.string(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      try {
        await ctx.prisma.guestbook.create({
          data: {
            name: input.name,
            message: input.message,
          },
        });
      } catch (error) {
        console.log(error);
      }
    }),
});


Enter fullscreen mode Exit fullscreen mode

Next, let's write a query to get all messages in the guestbook. We want all guests to see the messages so we'll use the publicProcedure for this.



// src/server/api/routers/guestbook.ts

import { z } from "zod";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";

export const guestbookRouter = createTRPCRouter({
  getAll: publicProcedure.query(async ({ ctx }) => {
    try {
      return await ctx.prisma.guestbook.findMany({
        select: {
          name: true,
          message: true,
        },
        orderBy: {
          createdAt: "desc",
        },
      });
    } catch (error) {
      console.log("error", error);
    }
  }),
  //...


Enter fullscreen mode Exit fullscreen mode

Here we are getting just the name and message from all the rows from the Guestbook model. The rows are sorted in descending order by the createdAt field.



Now merge this router in the main appRouter.



// src/server/api/root.ts

import { createTRPCRouter } from "./trpc";
import { guestbookRouter } from "./routers/guestbook";

export const appRouter = createTRPCRouter({
  guestbook: guestbookRouter,
});

// export type definition of API
export type AppRouter = typeof appRouter;


Enter fullscreen mode Exit fullscreen mode

We're pretty much done on the backend part. Let's work on the UI next.

Frontend

Let's first center everything.



// src/pages/index.tsx

import { signIn, signOut, useSession } from "next-auth/react";

const Home = () => {
  const { data: session, status } = useSession();

  if (status === "loading") {
    return <main className="flex flex-col items-center pt-4">Loading...</main>;
  }

  return (
    <main className="flex flex-col items-center">
      <h1 className="text-3xl pt-4">Guestbook</h1>
      <p>
        Tutorial for <code>create-t3-app</code>
      </p>
      <div className="pt-10">
        <div>
          {session ? (
            <>
              <p className="mb-4 text-center">hi {session.user?.name}</p>
              <button
                type="button"
                className="mx-auto block rounded-md bg-neutral-800 py-3 px-6 text-center hover:bg-neutral-700"
                onClick={() => {
                  signOut().catch(console.log);
                }}
              >
                Logout
              </button>
            </>
          ) : (
            <button
              type="button"
              className="mx-auto block rounded-md bg-neutral-800 py-3 px-6 text-center hover:bg-neutral-700"
              onClick={() => {
                signIn("discord").catch(console.log);
              }}
            >
              Login with Discord
            </button>
          )}
        </div>
      </div>
    </main>
  );
};
export default Home;


Enter fullscreen mode Exit fullscreen mode

I also made the heading bigger and added some padding between the elements.

Let's use our tRPC query to get all the messages for the guestbook in the database. But we don't have any data right now. We can use Prisma Studio to add some data manually.



npx prisma studio


Enter fullscreen mode Exit fullscreen mode

It will automatically open on http://localhost:5555. Go to the Guestbook table and add a bunch of records like this.

Using Prisma Studio

Now that we have data, we can use the query and display the data. For this we can use the tRPC react-query wrapper. Let's create a component for this in src/pages/index.tsx.



// src/pages/index.tsx

import { api } from "../utils/api";

const GuestbookEntries = () => {
  const { data: guestbookEntries, isLoading } = api.guestbook.getAll.useQuery();

  if (isLoading) return <div>Fetching messages...</div>;

  return (
    <div className="flex flex-col gap-4">
      {guestbookEntries?.map((entry, index) => {
        return (
          <div key={index}>
            <p>{entry.message}</p>
            <span>- {entry.name}</span>
          </div>
        );
      })}
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

Here we're using useQuery() and mapping over the array it returns.

Of course here too we have wonderful typesafety and autocomplete.

Now render this component in the Home component.



// src/pages/index.tsx

<main className="flex flex-col items-center">
  <h1 className="text-3xl pt-4">Guestbook</h1>
  <p>
    Tutorial for <code>create-t3-app</code>
  </p>
  <div className="pt-10">
    <div>
      {session ? (
        <>
          <p className="mb-4 text-center">hi {session.user?.name}</p>
          <button
            type="button"
            className="mx-auto block rounded-md bg-neutral-800 py-3 px-6 text-center hover:bg-neutral-700"
            onClick={() => {
              signOut().catch(console.log);
            }}
          >
            Logout
          </button>
        </>
      ) : (
        <button
          type="button"
          className="mx-auto block rounded-md bg-neutral-800 py-3 px-6 text-center hover:bg-neutral-700"
          onClick={() => {
            signIn("discord").catch(console.log);
          }}
        >
          Login with Discord
        </button>
      )}
      <div className="pt-10">
        <GuestbookEntries />
      </div>
    </div>
  </div>
</main>


Enter fullscreen mode Exit fullscreen mode

Let's now create a form component in src/pages/index.tsx and use our tRPC mutation there.



// src/pages/index.tsx

const Form = () => {
  const [message, setMessage] = useState("");
  const { data: session, status } = useSession();

  const postMessage = api.guestbook.postMessage.useMutation();

  if (status !== "authenticated") return null;

  return (
    <form
      className="flex gap-2"
      onSubmit={(event) => {
        event.preventDefault();
        postMessage.mutate({
          name: session.user?.name as string,
          message,
        });
        setMessage("");
      }}
    >
      <input
        type="text"
        className="rounded-md border-2 border-zinc-800 bg-neutral-900 px-4 py-2 focus:outline-none"
        placeholder="Your message..."
        minLength={2}
        maxLength={100}
        value={message}
        onChange={(event) => setMessage(event.target.value)}
      />
      <button
        type="submit"
        className="rounded-md border-2 border-zinc-800 p-2 focus:outline-none"
      >
        Submit
      </button>
    </form>
  );
};


Enter fullscreen mode Exit fullscreen mode

We can now render the Form in the Home component and add some padding.



// src/pages/index.tsx

<div>
  {session ? (
    <>
      <p className="mb-4 text-center">hi {session.user?.name}</p>
      <button
        type="button"
        className="mx-auto block rounded-md bg-neutral-800 py-3 px-6 text-center hover:bg-neutral-700"
        onClick={() => {
          signOut().catch(console.log);
        }}
      >
        Logout
      </button>
      <div className="pt-6">
        <Form />
      </div>
    </>
  ) : (
    <button
      type="button"
      className="mx-auto block rounded-md bg-neutral-800 py-3 px-6 text-center hover:bg-neutral-700"
      onClick={() => {
        signIn("discord").catch(console.log);
      }}
    >
      Login with Discord
    </button>
  )}
  <div className="pt-10">
    <GuestbookEntries />
  </div>
</div>


Enter fullscreen mode Exit fullscreen mode

Here we have a form and we're using useMutation() to post the data to the database. But you'll notice one problem here. When we click on the submit button, it does post the message to the database, but the user doesn't get any immediate feedback. Only on refreshing the page, the user can see the new message.

For this we can use optimistic UI updates! react-query makes this trivial to do. We just need to add some stuff to our useMutation() hook inside the Form component.



// src/pages/index.tsx

const utils = api.useContext();
const postMessage = api.guestbook.postMessage.useMutation({
  onMutate: async (newEntry) => {
    await utils.guestbook.getAll.cancel();
    utils.guestbook.getAll.setData(undefined, (prevEntries) => {
      if (prevEntries) {
        return [newEntry, ...prevEntries];
      } else {
        return [newEntry];
      }
    });
  },
  onSettled: async () => {
    await utils.guestbook.getAll.invalidate();
  },
});


Enter fullscreen mode Exit fullscreen mode

If you want to learn more about this code example, you can read about optimistic updates with @tanstack/react-query here.

We're pretty much done with the coding part! That was pretty simple wasn't it. The T3 stack makes it super easy and quick to build full stack web apps. Let's now deploy our guestbook.

Deployment

We're going to use Vercel to deploy. Vercel makes it really easy to deploy NextJS apps, they are the people who made NextJS.

First, push your code to a GitHub repository. Now, go to Vercel and sign up with GitHub if you haven't already.

Vercel

Then click on New Project and import your newly created repository.

Import repository

Now we need to add environment variables, so copy paste all the environment variables to Vercel. After you've done that, click Deploy.

Add environment variables, then click Deploy

Add a custom domain if you have one and you're done! Congratulations!

All the code can be found here. You can visit the website at guestbook.nxl.sh.

Credits

💖 💪 🙅 🚩
nexxeln
Shoubhit Dash

Posted on August 12, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related