Build a fullstack web app

Banner with the tech stack used in this tutorial, Next.js, TailwindCSS, shadcn/ui, Prisma, PostgreSQL and Auth.js Tech stack used in this tutorial

GitHub repo

Content

  1. Introduction
  2. Initial setup
    1. Install shadcn/ui
    2. Create a db using Docker
    3. Install Prisma
    4. Config Auth.js
  3. Improve your UI
  4. Add CRUD functionality
  5. Conclusion

1. Introduction

In this tutorial, we will develop a fullstack web app with the following tech stack:

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:

Terminal
npx create-next-app@latest

Make sure to mark Yes the following options:

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:

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:

Terminal
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:

Terminal
docker pull postgres

Then, create a container with the image:

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:

Terminal
npm install prisma -D

Now initialize Prisma:

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/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
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:

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/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:

Terminal
npm install @prisma/client @auth/prisma-adapter

Instal nodemailer too, as we'll use magic links for authentication:

Terminal
npm install nodemailer -D

Now create a src/utils/db.ts and initialize prisma:

src/utils/db.ts
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:

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.

Create an account and get the next credentials in your .env file:

Now, create a src/app/api/auth/[...nextauth]/route.ts file:

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:

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 | 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:

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 (
    <>
      <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:

Terminal
npx shadcn-ui@latest add button
Terminal
npx shadcn-ui@latest add dialog
Terminal
npx shadcn-ui@latest add input
Terminal
npx shadcn-ui@latest add textarea
Terminal
npx shadcn-ui@latest add form
Terminal
npx shadcn-ui@latest add label
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.

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<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.

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<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.

src/app/page.tsx
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

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.

src/components/post/edit.tsx
'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();
      console.log(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.

src/components/post/delete.tsx
'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

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.

src/components/sign-out.tsx
'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.

src/components/navbar.tsx
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.

src/app/layout.tsx
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:

prisma/schema.prisma
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:

Terminal
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:

src/app/api/posts/route.ts
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