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
We're going to utilize all parts of the stack.
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
.
Now provision Postgres.
It's as simple as that. Copy the connection string from the Connect
tab.
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/*
- Theprisma
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.
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.
Copy the client ID and secret and paste both of them into .env
.
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.
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
Now run the dev server.
npm run dev
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;
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;
}
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;
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;
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)
}
Let's push this modified schema to our Railway Postgres database.
npx prisma db push
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);
}
}),
});
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);
};
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,
});
};
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);
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);
}
}),
});
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);
}
}),
//...
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;
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;
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
It will automatically open on http://localhost:5555
. Go to the Guestbook
table and add a bunch of records like this.
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>
);
};
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>
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>
);
};
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>
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();
},
});
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.
Then click on New Project
and import your newly created 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 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
- Ayanava Karmakar for updating the blog using tRPC v10.
- Julius Marminge and Michael Lee for reviewing the updated blog.
- Lee Robinson for the idea of a guestbook.
- Anthony for giving constructive criticism.
- JAR and Krish for proof reading.
-
Hakan Güçlü for updating the project files in accordance with the latest
create-t3-app
template (7.3.0).
Top comments (20)
Thanks a lot for this tutorial! It's very helpful. One thing I would add that I got stuck on for a little bit is that you have to run
prisma generate
after creating your schema before you can access the prisma client.You're welcome!
prisma db push
automatically runsprisma generate
for you. Weird it didn't work for you.That is really strange! I had run
prisma db push
, I could see everything on railway no problem, but the client didn't work until I also ranprisma generate
, so yeah, no idea what that's aboutGlad you got it working!
I appreciate the work put in, but to anyone visiting this after March 2023 you will have a hard time following along with this as the folder structures have changed considerably in create-t3-app.
It would be really good if you could release an updated version, as this is the pretty much the first tutorial that pops up in google when you look for a (written) t3 tutorial.
The big issue is Typescript errors with the return types from your queries. If you follow along to the letter of this tutorial you will be hit by so many red lines!
Just leaving this here for future readers....
Hey! I know ct3a has changed a lot since this blog post, but with college applications and entrance exams right around the corner, I don't have a lot of time to keep updating this post and usually rely on the community to make a PR on my website's repo. From there I also update the content on dev.to. If you are free enough to update stuff that is broken, feel free to make a PR on my website's repo and I'll update the content here as well. The code is in this repository.
Sorry for not keeping this updated!
Also have you tried restarting the eslint and typescript servers? Many people seem to be facing that issue.
I don't have any knowledge on backend but don't we use something like node/express for backend? Which one does backend work?
Yeah express is a backend library. Next.js on the other hand is a full stack framework, it provides a backend framework as well as a frontend framework with routing. Note that Next.js's backend is based on Node except their edge API routes which have a subset of node features.
thank you
you're welcome
Just finished this tutorial, it was great and lead me on many rabbit holes to better understand the tech in this stack. Thanks for making it easy to follow!
You're welcome!
Awesome job @nexxeln 🚀
Thanks for doing this tutorial and for your work on CTA! I'm really interested in these technologies!! I know versions change often but it's rather frustrating that the code show in this article is different from the code linked in your github repo. It make's it difficult / impossible to follow along. Especially if I don't know what package versions you were using in this article write up.
I'd love to see a tutorial that shows how to use next-auth with credential sign up and login, in the T3 stack, using cookies and without using JWTTheo talks about it here but I'm having some trouble getting it all wired up: youtube.com/watch?v=h6wBYWWdyYQ
Thanks again!
This post will be updated soon to trpc v10. As of next-auth credential auth, I wouldn't recommend using it.
Docker build failing due to env validation.
SKIP_ENV_VALIDATION=1 doesn't work.
I am unable to login to your deployed version with Discord. And I am getting the same error in a personal project of mine. Do you know why that is happening?
Why am I getting type error on ctx.prisma.guestbook.create ?
Not sure but I have the exact same errors...