--- title: Build a fullstack web app description: Build a fullstack web app using Next.js as meta-framework and PostgreSQL as database. date: 2024-1-18 author: Juan Manzanero rss: true --- [GitHub repo](https://github.com/juanmanzanero-com/fullstack-app) ## Content 1. [Introduction](#1-introduction) 2. [Initial setup](#2-initial-setup) 1. [Install shadcn/ui](#21-install-shadcnui) 2. [Create a db using Docker](#22-create-a-db-using-docker) 3. [Install Prisma](#23-install-prisma) 4. [Config Auth.js](#24-config-authjs) 3. [Improve your UI](#3-improve-your-ui) 4. [Add CRUD functionality](#4-add-crud-functionality) 5. [Conclusion](#5-conclusion) ## 1. Introduction In this tutorial, we will develop a fullstack web app with the following tech stack: - [Next.js](https://nextjs.org/) as meta-framework - [TailwindCSS](https://tailwindcss.com/) for styling - [shadcn/ui](https://ui.shadcn.com/) for UI components - [Prisma](https://www.prisma.io/) as ORM - [PostgreSQL](https://www.postgresql.org/) as database - [Auth.js](https://authjs.dev/) for authentication - [Docker](https://www.docker.com/) for creating an intance of a PostgreSQL database locally We'll learn some of the fundamentals of this tech stack, like using **server components** in Next.js, or creating **API endpoints** using the **app router**. ## 2. Initial setup Let's start creating a new Next.js project, in your **terminal** run: ```bash title="Terminal" npx create-next-app@latest ``` Make sure to mark **Yes** the following options: - Would you like to use **TypeScript**? - Would you like to use **ESLint**? - Would you like to use **Tailwind CSS**? - Would you like to use **'src/' directory**? - Would you like to use **App Router**? (recommended) ```bash title="Terminal" > What is your project named? fullstack-app > Would you like to use TypeScript? No / Yes > Would you like to use ESLint? No / Yes > Would you like to use Tailwind CSS? No / Yes > Would you like to use `src/` directory? No / Yes > Would you like to use App Router? (recommended) No / Yes > Would you like to customize the default import alias (@/*)? No / Yes > What import alias would you like configured? @/* ``` Wait until the dependencies installation is completed, then access to the project directory: ```bash title="Terminal" cd fullstack-app ``` Open your code editor of your preference. ### 2.1 Install shadcn/ui This components will help us a lot building the **UI** along with TailwindCSS. First, initialize shadcn/ui: ```bash title="Terminal" npx shadcn-ui@latest init ``` Make sure to config shadcn/ui according your project configuration. You can check the [shadcn/ui docs](https://ui.shadcn.com/docs) for every component that you could need, each components is installed individually. ### 2.2 Create a db using Docker Make sure to have [Docker](https://www.docker.com/) installed in your machine. First you need to pull a PostgreSQL image from Docker Hub: ```bash title="Terminal" docker pull postgres ``` Then, create a container with the image: ```bash title="Terminal" docker run --name my-postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres ``` ### 2.3 Install Prisma Install Prisma using your dependency manager, in this case **npm**: ```bash title="Terminal" npm install prisma -D ``` Now initialize Prisma: ```bash title="Terminal" npx prisma init ``` A new `./prisma` direcotry will be created in the root of your project, with a schema.prisma file. You'll create your schemas in this file. Add this model as an example: ```prisma title="prisma/schema.prisma" model User { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) email String @unique name String? } ``` Update your `.env` file with the following: ```env title=".env" DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres?schema=public" ``` In the URL is your username (by default is postgres), your password (in this case password), the host (by default is localhost), the port (by default is 5432), the database name (by default is postgres) and the schema (by default is public). Create your first migration to test if Prisma can connect to your local database: ```bash title="Terminal" npx prisma migrate dev --name init ``` If everything is ok, you'll see a new /migrations directory with a new file inside. > If you have an error, make sure you can connect to your local DB. Delete, and > create the container again if necessary. ### 2.4 Config Auth.js Add this models to your **prisma schema**: ```prisma title="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]) } model Session { id String @id @default(cuid()) sessionToken String @unique userId String expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] } model VerificationToken { identifier String token String @unique expires DateTime @@unique([identifier, token]) } ``` These models are for **Auth.js**, now we can install it with the prisma adapter: ```bash title="Terminal" npm install @prisma/client @auth/prisma-adapter ``` Instal nodemailer too, as we'll use magic links for authentication: ```bash title="Terminal" npm install nodemailer -D ``` Now create a `src/utils/db.ts` and initialize **prisma**: ```ts title="src/utils/db.ts" import { PrismaClient } from '@prisma/client'; const prismaClientSingleton = () => { return new PrismaClient(); }; declare global { var prisma: undefined | ReturnType; } const prisma = globalThis.prisma ?? prismaClientSingleton(); export default prisma; if (process.env.NODE_ENV !== 'production') globalThis.prisma = prisma; ``` Then, create a `src/utils/auth.ts` file to config **Auth.js**: ```ts title="src/utils/auth.ts" import type { NextAuthOptions } from 'next-auth'; import { PrismaAdapter } from '@auth/prisma-adapter'; import EmailProvider from 'next-auth/providers/email'; import prisma from '@/libs/db'; import { Adapter } from 'next-auth/adapters'; export const authOptions = { adapter: PrismaAdapter(prisma) as Adapter, providers: [ EmailProvider({ server: { host: process.env.EMAIL_SERVER_HOST, port: process.env.EMAIL_SERVER_PORT, auth: { user: process.env.EMAIL_SERVER_USER, pass: process.env.EMAIL_SERVER_PASSWORD, }, }, from: process.env.EMAIL_FROM, }), ], callbacks: { session: async ({ session, user }) => { return { ...session, user: user, }; }, }, } satisfies NextAuthOptions; ``` This config is for using an email provider, for this project we'll use [Resend](https://resend.com/). Create an account and get the next credentials in your .env file: - EMAIL_SERVER_HOST: smtp.resend.com - EMAIL_SERVER_PORT: 465 - EMAIL_SERVER_USER: resend - EMAIL_FROM: onboarding@resend(dot)dev - EMAIL_SERVER_PASSWORD: yor api key Now, create a `src/app/api/auth/[...nextauth]/route.ts` file: ```ts title="src/app/api/auth/[...nextauth]/route.ts" import { authOptions } from '@/libs/auth'; import NextAuth from 'next-auth/next'; const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; ``` This file is for handling the authentication in our app. You can now authenticate users with a magic link sent by email. Create a `src/app/auth/signin-form.tsx` file: ```tsx title="src/app/auth/signin-form.tsx" 'use client'; import { useState } from 'react'; import { signIn } from 'next-auth/react'; export default function SigninForm() { const [email, setEmail] = useState(null); async function handleSubmit() { await signIn('email', { email, callbackUrl: `${window.location.origin}`, }); } return (
setEmail(e.target.value)} className='w-max p-1 border border-slate-400' />
); } ``` Import it to your `src/app/auth/page.tsx` file: ```tsx title="src/app/auth/page.tsx" import { authOptions } from '@/libs/auth'; import { getServerSession } from 'next-auth'; import { redirect } from 'next/navigation'; import SigninForm from './form'; export default async function Signin() { const session = await getServerSession(authOptions); if (session) { return redirect('/'); } return ( <>

Sign in

); } ``` As you can see, you can redirect users if they're not authenticated getting the session with **getServerSession**. ## 3. Improve your UI Let's create a short posts like app. First, add some shadcn/ui components and update your components, we'll create new components too: ```bash title="Terminal" npx shadcn-ui@latest add button ``` ```bash title="Terminal" npx shadcn-ui@latest add dialog ``` ```bash title="Terminal" npx shadcn-ui@latest add input ``` ```bash title="Terminal" npx shadcn-ui@latest add textarea ``` ```bash title="Terminal" npx shadcn-ui@latest add form ``` ```bash title="Terminal" npx shadcn-ui@latest add label ``` ```bash title="Terminal" npx shadcn-ui@latest add sonner ``` We'll add the endpoints URL for these components, but we'll create them later. `src/app/auth/signin-form.tsx` Here we'll update the UI and add form validation. ```tsx title="src/app/auth/signin-form.tsx" 'use client'; import { signIn } from 'next-auth/react'; import { Button } from '@/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; const formSchema = z.object({ email: z.string().email(), }); export default function SigninForm() { const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { email: '', }, }); async function onSubmit({ email }: z.infer) { await signIn('email', { email, callbackUrl: `${window.location.origin}`, }); } return (
( Email )} /> ); } ``` `src/components/post/create.tsx` Update UI components and form validation. ```tsx title="src/components/post/create.tsx" 'use client'; import { useState } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { SessionProps } from './types'; const formSchema = z.object({ title: z.string().min(1).max(100), content: z.string().min(1), }); export default function CreatePost(props: SessionProps) { const [open, setOpen] = useState(false); const router = useRouter(); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { title: '', content: '', }, }); async function onSubmit(values: z.infer) { try { const res = await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...values, authorId: props.session.user?.id, }), }); const json = await res.json(); if (!res.ok) { toast(json.message); return; } toast('Post created!'); form.reset(); setOpen(false); router.refresh(); } catch (error) { console.error(error); } } return ( Create post Please do not post NSFW content.
( Title )} /> ( Content