Back to Tutorial Overview
Build a Full-Stack Application with Next.js 14
A comprehensive, step-by-step guide to building a production-ready full-stack app
Step 1: Project Setup
Initialize Your Next.js Project
First, let's create a new Next.js project with TypeScript and the App Router:
npx create-next-app@latest my-fullstack-app
cd my-fullstack-app
# When prompted, select:
# ✓ TypeScript: Yes
# ✓ ESLint: Yes
# ✓ Tailwind CSS: Yes
# ✓ src/ directory: Yes
# ✓ App Router: Yes
# ✓ Import alias: @/*Install Required Dependencies
Install the packages we'll need for our full-stack application:
# Database and ORM
npm install prisma @prisma/client
# Authentication
npm install next-auth@beta bcryptjs
npm install -D @types/bcryptjs
# Form validation
npm install zod react-hook-form @hookform/resolvers
# UI Components
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu
# Initialize Prisma
npx prisma initProject Structure
Your project structure should look like this:
my-fullstack-app/
├── prisma/
│ └── schema.prisma
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ └── auth/
│ │ ├── (auth)/
│ │ │ ├── login/
│ │ │ └── register/
│ │ ├── dashboard/
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── components/
│ ├── lib/
│ │ ├── prisma.ts
│ │ └── auth.ts
│ └── types/
├── .env
└── package.jsonStep 2: Database Configuration with Prisma
Configure Environment Variables
Create a .env file:
# PostgreSQL (recommended for production)
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
# Or SQLite for development
# DATABASE_URL="file:./dev.db"
# NextAuth Secret
NEXTAUTH_SECRET="your-secret-key-here"
NEXTAUTH_URL="http://localhost:3000"Define Your Schema
Update prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
password String
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
content String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Create Prisma Client
Create src/lib/prisma.ts:
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}Run Migrations
# Create and apply migration
npx prisma migrate dev --name init
# Generate Prisma Client
npx prisma generateStep 3: Implement Authentication
NextAuth Configuration
Create src/app/api/auth/[...nextauth]/route.ts:
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
const handler = NextAuth({
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email }
});
if (!user) {
return null;
}
const isValid = await bcrypt.compare(
credentials.password,
user.password
);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
};
}
})
],
session: {
strategy: 'jwt'
},
pages: {
signIn: '/login',
}
});
export { handler as GET, handler as POST };Registration API Route
Create src/app/api/register/route.ts:
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
name: z.string().min(2)
});
export async function POST(req: Request) {
try {
const body = await req.json();
const { email, password, name } = registerSchema.parse(body);
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { email }
});
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
name
}
});
return NextResponse.json(
{ message: 'User created successfully', userId: user.id },
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}Step 4: Create CRUD API Routes
Posts API - GET All Posts
Create src/app/api/posts/route.ts:
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const posts = await prisma.post.findMany({
where: { published: true },
include: {
author: {
select: {
name: true,
email: true
}
}
},
orderBy: {
createdAt: 'desc'
}
});
return NextResponse.json(posts);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch posts' },
{ status: 500 }
);
}
}
export async function POST(req: Request) {
try {
const session = await getServerSession();
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { title, content } = await req.json();
const post = await prisma.post.create({
data: {
title,
content,
authorId: session.user.id
}
});
return NextResponse.json(post, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create post' },
{ status: 500 }
);
}
}Individual Post Route
Create src/app/api/posts/[id]/route.ts:
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const post = await prisma.post.findUnique({
where: { id: params.id },
include: {
author: {
select: { name: true, email: true }
}
}
});
if (!post) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
);
}
return NextResponse.json(post);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch post' },
{ status: 500 }
);
}
}
export async function PATCH(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const { title, content, published } = await req.json();
const post = await prisma.post.update({
where: { id: params.id },
data: { title, content, published }
});
return NextResponse.json(post);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to update post' },
{ status: 500 }
);
}
}
export async function DELETE(
req: Request,
{ params }: { params: { id: string } }
) {
try {
await prisma.post.delete({
where: { id: params.id }
});
return NextResponse.json(
{ message: 'Post deleted successfully' }
);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to delete post' },
{ status: 500 }
);
}
}Want to Continue?
This tutorial continues with building the frontend, implementing forms, and deploying to production.
Back to Tutorial Overview