Build a fullstack web app
Tech stack used in this tutorial
Content
1. Introduction
In this tutorial, we will develop a fullstack web app with the following tech stack:
- Next.js as meta-framework
- TailwindCSS for styling
- shadcn/ui for UI components
- Prisma as ORM
- PostgreSQL as database
- Auth.js for authentication
- Docker 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:
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)
> 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:
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:
npx shadcn-ui@latest init
Make sure to config shadcn/ui according your project configuration.
You can check the shadcn/ui 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 installed in your machine.
First you need to pull a PostgreSQL image from Docker Hub:
docker pull postgres
Then, create a container with the image:
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:
npm install prisma -D
Now initialize Prisma:
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:
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
email String @unique
name String?
}
Update your .env
file with the following:
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:
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:
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:
npm install @prisma/client @auth/prisma-adapter
Instal nodemailer too, as we’ll use magic links for authentication:
npm install nodemailer -D
Now create a src/utils/db.ts
and initialize prisma:
import { PrismaClient } from '@prisma/client';
const prismaClientSingleton = () => {
return new PrismaClient();
};
declare global {
var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
}
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:
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.
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:
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:
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
export default function SigninForm() {
const [email, setEmail] = useState<null | string>(null);
async function handleSubmit() {
await signIn('email', {
email,
callbackUrl: `${window.location.origin}`,
});
}
return (
<form className='mt-5 space-y-4' action={handleSubmit}>
<section className='flex flex-col gap-2'>
<label htmlFor='email'>Email</label>
<input
id='email'
type='email'
name='email'
onChange={(e) => setEmail(e.target.value)}
className='w-max p-1 border border-slate-400'
/>
</section>
<button type='submit'>Sign in</button>
</form>
);
}
Import it to your src/app/auth/page.tsx
file:
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 (
<>
<h1>Sign in</h1>
<SigninForm />
</>
);
}
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:
npx shadcn-ui@latest add button
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add input
npx shadcn-ui@latest add textarea
npx shadcn-ui@latest add form
npx shadcn-ui@latest add label
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.
'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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
},
});
async function onSubmit({ email }: z.infer<typeof formSchema>) {
await signIn('email', {
email,
callbackUrl: `${window.location.origin}`,
});
}
return (
<Form {...form}>
<form className='space-y-4' onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder='address@example.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className='w-full' type='submit'>
Send magic link
</Button>
</form>
</Form>
);
}
src/components/post/create.tsx
Update UI components and form validation.
'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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
content: '',
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Create post</Button>
</DialogTrigger>
<DialogContent className='max-w-[300px]'>
<DialogHeader className='text-left'>
<DialogTitle>Create post</DialogTitle>
<DialogDescription>
Please <strong>do not</strong> post <strong>NSFW</strong> content.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form className='space-y-4' onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name='title'
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder='Hi there!' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<Textarea
placeholder='Testing this great app!'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className='w-full' type='submit'>
Post
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
src/app/page.tsx
Fetch data from Prisma, as this page is a server component, we can fetch it directly.
import { authOptions } from '@/libs/auth';
import { getServerSession } from 'next-auth';
import prisma from '@/libs/db';
import CreatePost from '@/components/post/create';
import Post from '@/components/post';
export default async function Home() {
const session = await getServerSession(authOptions);
// You can fetch data to Prisma in server components
const posts = await prisma.post.findMany({
include: {
author: true,
},
});
return (
<>
<h1 className='mb-5 font-bold text-xl'>Home</h1>
{session ? (
<>
<CreatePost session={session} />
</>
) : (
<>
<p>You are not logged in</p>
</>
)}
<h3 className='text-lg font-semibold mt-10'>Posts</h3>
<ul className='mt-5 space-y-2.5'>
{posts.length > 0 ? (
posts.map((post) => (
<li key={post.id}>
<Post {...post} session={session} />
</li>
))
) : (
<p>No posts</p>
)}
</ul>
</>
);
}
src/components/post/item.tsx
'use client';
import DeletePost from './delete';
import EditPost from './edit';
import { TPostProps } from './types';
export default function PostItem(props: TPostProps) {
return (
<article className='w-max p-2 border border-slate-500 rounded-md'>
<header className='flex justify-between items-center'>
<h2 className='font-bold text-lg'>{props.title}</h2>
{props.session?.user?.id === props.authorId && (
<section className='space-x-2'>
<EditPost {...props} />
<DeletePost {...props} />
</section>
)}
</header>
<p>{props.content}</p>
<span className='text-sm'>
Posted by {props.author?.email || 'anon'} at{' '}
{new Date(props.createdAt).toLocaleString()}
</span>
</article>
);
}
src/components/post/edit.tsx
Create a button icon for opening a dialog rendering the post data for editing, add validation and fetch to the API endpoint.
'use client';
import { useState } from 'react';
import { Edit } from 'lucide-react';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
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 { Button } from '@/components/ui/button';
import { TPostProps } from './types';
const formSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(1),
});
export default function EditPost(props: TPostProps) {
const [open, setOpen] = useState(false);
const router = useRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: props.title,
content: props.content,
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const res = await fetch('/api/posts', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...values,
id: props.id,
}),
});
const json = await res.json();
if (!res.ok) {
toast(json.message);
return;
}
toast('Post edited!');
form.reset();
setOpen(false);
router.refresh();
} catch (error) {
console.error(error);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='secondary' size='icon'>
<Edit />
</Button>
</DialogTrigger>
<DialogContent className='max-w-[300px]'>
<DialogHeader className='text-left'>
<DialogTitle>Edit post</DialogTitle>
<DialogDescription>
Please <strong>do not</strong> post <strong>NSFW</strong> content.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form className='space-y-4' onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name='title'
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder='Hi there!' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<Textarea
placeholder='Testing this great app!'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogClose asChild>
<Button className='w-full' type='submit'>
Edit post
</Button>
</DialogClose>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
src/components/post/delete.tsx
Create a button icon for opening a dialog for deleting the post, add validation and fetch to the API endpoint.
'use client';
import { LucideTrash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { TPostProps } from './types';
export default function DeletePost(props: TPostProps) {
const router = useRouter();
async function handleDelete() {
try {
const res = await fetch('/api/posts', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: props.id,
}),
});
const json = await res.json();
if (!res.ok) {
toast(json.message);
return;
}
toast('Post deleted!');
router.refresh();
} catch (error) {
console.error(error);
}
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant='destructive' size='icon'>
<LucideTrash2 />
</Button>
</DialogTrigger>
<DialogContent className='max-w-[300px]'>
<DialogHeader className='text-left'>
<DialogTitle>Delete post</DialogTitle>
<DialogDescription>
Are you sure you want to <strong>delete</strong> this post? This
action cannot be undone.
</DialogDescription>
</DialogHeader>
<footer className='flex flex-col gap-2'>
<DialogClose asChild>
<Button variant='secondary' className='w-full'>
No, keep post
</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={handleDelete}
variant='destructive'
className='w-full'
>
Yes, delete post
</Button>
</DialogClose>
</footer>
</DialogContent>
</Dialog>
);
}
src/components/post/types.ts
type SessionProps = {
session: any;
};
type TPostProps = {
author: {
id: string;
name: string | null;
email: string | null;
emailVerified: Date | null;
image: string | null;
} | null;
id: string;
createdAt: Date;
updatedAt: Date;
title: string;
content: string;
authorId: string | null;
session: any;
};
export type { SessionProps, TPostProps };
src/components/sign-out.tsx
A simple sign out button.
'use client';
import { signOut } from 'next-auth/react';
import { Button } from './ui/button';
export default function SignOut() {
return <Button onClick={() => signOut()}>Sign out</Button>;
}
src/components/navbar.tsx
Render the sign out or sign in button depending if the user is logged in or not.
import Link from 'next/link';
import { Button } from './ui/button';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/libs/auth';
import SignOut from './sign-out';
export default async function Navbar() {
const session = await getServerSession(authOptions);
return (
<nav className='w-full p-4 border-b flex justify-between items-center'>
<section>
<Button variant='link' className='px-0 font-semibold text-lg'>
<Link href='/'>Fullstack app</Link>
</Button>
</section>
<section>
{session ? (
<SignOut />
) : (
<Button asChild>
<Link href='/auth'>Sign in</Link>
</Button>
)}
</section>
</nav>
);
}
src/app/layout.tsx
Add your Navbar and Toaster components and some styles.
import { Inter } from 'next/font/google';
import Navbar from '@/components/navbar';
import { Toaster } from '@/components/ui/sonner';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
interface Props extends React.PropsWithChildren {}
export default function RootLayout(props: Props) {
return (
<html lang='en'>
<body className={inter.className}>
<Navbar />
<main className='px-4 py-8'>{props.children}</main>
<Toaster />
</body>
</html>
);
}
4. Add CRUD functionality
Now we can add Create, Read, Update and Delete functionality to our app.
prisma/schema.prisma
Update your Prisma schema adding to User model a relationship with Post model:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
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[]
posts Post[]
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Post {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
content String
author User @relation(fields: [authorId], references: [id])
authorId String
}
Generate a new Prisma migration:
npx prisma migrate dev --name add-posts
Now, create a src/app/api/posts/route.ts
file with a POST, PUT and
DELETE async functions:
import prisma from '@/libs/db';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: Request) {
try {
if (!req.body) {
return NextResponse.json({
ok: false,
status: 400,
message: 'Data required',
});
}
const json = await req.json();
const res = await prisma.post.create({
data: json,
});
return NextResponse.json({
ok: true,
status: 201,
data: res,
});
} catch (error) {
if (error instanceof Error) {
return NextResponse.json({
ok: false,
status: 500,
message: error.message,
});
}
return NextResponse.json({
ok: false,
status: 500,
message: 'Internal server error',
});
}
}
export async function PUT(req: NextRequest) {
try {
const body = await req.json();
const res = await prisma.post.update({
where: { id: body.id },
data: {
title: body.title,
content: body.content,
},
});
return NextResponse.json({
ok: true,
status: 200,
data: res,
});
} catch (error) {
if (error instanceof Error) {
return NextResponse.json({
ok: false,
status: 500,
message: error.message,
});
}
return NextResponse.json({
ok: false,
status: 500,
message: 'Internal server error',
});
}
}
export async function DELETE(req: NextRequest) {
try {
const body = await req.json();
const res = await prisma.post.delete({
where: { id: body.id },
});
return NextResponse.json({
ok: true,
status: 200,
data: res,
});
} catch (error) {
if (error instanceof Error) {
return NextResponse.json({
ok: false,
status: 500,
message: error.message,
});
}
return NextResponse.json({
ok: false,
status: 500,
message: 'Internal server error',
});
}
}
Try creating a post in the home.
The page will refresh and you’ll see the post, as you created it, only you can edit or delete it.
5. Conclusion
As you can see, create a fullstack app with Next.js and Prisma is really easy.
Of course, it could be improved, adding server side validation for inputs, adding pagination for posts in the home, etc.
Posted: January 18, 2024