Construye una App Fullstack
Construye una app fullstack usando Next.js como meta-framework y PostgreSQL como base de datos.
1. Introducci贸n
En este tutorial, desarrollaremos una app fullstack con el siguiente tech stack:
- Next.js como meta-framework
- TailwindCSS para estilos
- shadcn/ui para componentes UI
- Prisma como ORM
- PostgreSQL como base de datos
- Auth.js para autenticaci贸n
- Docker para crear una instancia de una base de datos PostgreSQL localmente
Aprenderemos algunos de los fundamentos de este tech stack, como usar server components en Next.js, o crear endpoints API usando el app router.
2. Configuraci贸n inicial
Comencemos creando un nuevo proyecto Next.js, en tu terminal ejecuta:
npx create-next-app@latest
Aseg煤rate de marcar Yes en las siguientes opciones:
- 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? @/*
Espera a que la instalaci贸n de las dependencias termine, luego accede a la carpeta del proyecto:
cd fullstack-app
Abre tu editor de c贸digo de preferencia.
2.1 Instalar shadcn/ui
Estos componentes nos ayudar谩n mucho a construir la UI junto con TailwindCSS.
Primero, inicializa shadcn/ui:
npx shadcn-ui@latest init
Aseg煤rate de configurar shadcn/ui de acuerdo a tu proyecto.
Puedes revisar la documentaci贸n de shadcn/ui para cada componente que puedas necesitar, cada componente se instala individualmente.
2.2 Crear una base de datos PostgreSQL usando Docker
Aseg煤rate de tener Docker instalado en tu m谩quina.
Primero, necesitas descargar una imagen de PostgreSQL de Docker Hub:
docker pull postgres
Luego, crea un contenedor con la imagen:
docker run --name my-postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres2.3 Instalar Prisma
Instala Prisma usando tu gestor de dependencias, en este caso npm:
npm install prisma -D
Ahora inicializa Prisma:
npx prisma init
Se crear谩 un directorio ./prisma en la ra铆z de tu proyecto, con un archivo schema.prisma.
Aqu铆 crear谩s tus schemas.
Agrega este modelo como ejemplo:
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
email String @unique
name String?
}
Actualiza tu archivo .env con la siguiente URL:
DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres?schema=public"
En esta URL est谩 tu nombre de usuario (por defecto es postgres), tu contrase帽a (en este caso password), el host (por defecto es localhost), el puerto (por defecto es 5432), el nombre de la base de datos (por defecto es postgres) y el schema (por defecto es public).
Crea tu primera migraci贸n para probar si Prisma puede conectarse a tu base de datos local:
npx prisma migrate dev --name init
Si todo est谩 bien, ver谩s un nuevo directorio /migrations con un nuevo archivo dentro.
Si tienes un error, aseg煤rate de poder conectarte a tu base de datos local. Elimina y crea el contenedor de nuevo si es necesario.
2.4 Configurar Auth.js
Agrega este modelo a tu 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])
}
Estos modelos son para Auth.js, ahora podemos instalarlo con el adaptador de Prisma:
npm install @prisma/client @auth/prisma-adapter
Instalaremos nodemailer tambi茅n, ya que usaremos magic links para la autenticaci贸n:
npm install nodemailer -D
Ahora crea src/utils/db.ts e inicializa 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;
Luego, crea src/libs/auth.ts para configurar 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;
Esta configuraci贸n es para usar un proveedor de email, para este proyecto usaremos Resend.
Crea una cuenta y luego obt茅n las siguientes credenciales en tu archivo .env:
- 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
Ahora, crea un archivo src/pages/api/auth/[...nextauth].ts:
import { authOptions } from "@/libs/auth";
import NextAuth from "next-auth/next";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
Este archivo es para manejar la autenticaci贸n en nuestra app.
Ahora puedes autenticar usuarios con un magic link enviado por email.
Crea un archivo 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>
);
}
Imp贸rtalo en src/app/auth/page.tsx:
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 />
</>
);
}
Como puedes ver, puedes redirigir a los usuarios si no est谩n autenticados obteniendo la sesi贸n con getServerSession.
3. Mejora tu UI
Creemos una app para posts cortos.
Primero, agrega algunos componentes de shadcn/ui y actualiza tus componentes, tambi茅n crearemos nuevos componentes:
npx shadcn-ui@latest add buttonnpx shadcn-ui@latest add dialognpx shadcn-ui@latest add inputnpx shadcn-ui@latest add textareanpx shadcn-ui@latest add formnpx shadcn-ui@latest add labelnpx shadcn-ui@latest add sonner
Agregaremos las URL de los endpoints para estos componentes, pero m谩s adelante.
src/app/auth/signin-form.tsx
Aqu铆 actualizaremos la UI y agregaremos validaci贸n de formulario.
"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
Actualiza los componentes de UI y agrega validaci贸n de formulario.
"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
Haz fetch de los dats con Prisma, como esta p谩gina es un server component, podemos hacer fetch directamente.
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
Crea un bot贸n para abrir un di谩logo que renderice los datos del post para editar, agrega validaci贸n y fetch al endpoint de la API.
"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
Crea un bot贸n para abrir un di谩logo para eliminar el post, agrega validaci贸n y fetch al endpoint de la API.
"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
Un bot贸n simple para cerrar sesi贸n.
"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
Renderiza el bot贸n de cerrar sesi贸n o iniciar sesi贸n dependiendo si el usuario est谩 autenticado o no.
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
Agrega tu Navbar y Toaster y algunos estilos.
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. A帽ade funcionalidad CRUD
Ahora podemos crear, leer, actualizar y eliminar posts.
prisma/schema.prisma
Actualiza tu schema de Prisma con el modelo Post:
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
}
Genera una nueva migraci贸n:
npx prisma migrate dev --name add-posts
Ahora, create un archivo src/app/api/posts/route.ts con las funciones as铆ncronas GET, PUT y DELETE:
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",
});
}
}
Intenta crear un post en el home.
La p谩gina se refrescar谩 y ver谩s el post que creaste, solo t煤 puedes editarlo o eliminarlo.
5. Conclusi贸n
Como puedes ver, crear una app fullstack con Next.js es muy f谩cil.
Por supuesto, se puede mejorar, agregando validaci贸n del lado del servidor para los inputs, agregando paginaci贸n para los posts en el home, etc.