Full-Stack Development with Next.js 15 - The Complete Stack
Next.js has evolved into a complete full-stack framework. With Server Actions, Server Components, and powerful integrations, you can build entire applications without leaving the Next.js ecosystem. Let's explore the modern full-stack development workflow.
Why Next.js for Full-Stack?
Next.js 15 offers everything you need:
- Server Components - Zero-bundle backend logic
- Server Actions - Type-safe server mutations
- App Router - File-based routing with layouts
- API Routes - RESTful endpoints when needed
- Authentication - Built-in session management
- Database Integration - Seamless ORM support
- Edge Runtime - Deploy globally with low latency
Project Setup
Start with a modern stack:
bash
# Create Next.js 15 project
npx create-next-app@latest my-app --typescript --tailwind --app
# Install dependencies
cd my-app
npm install prisma @prisma/client
npm install next-auth@beta
npm install zod react-hook-form
npm install @hookform/resolvers
# Development tools
npm install -D prisma
Database Layer with Prisma
Prisma provides type-safe database access.
Schema Definition
prisma
// 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
slug String @unique
content String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([slug])
}
Prisma Client Setup
typescript
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
Database Migrations
bash
# Create migration
npx prisma migrate dev --name init
# Generate Prisma Client
npx prisma generate
# Open Prisma Studio
npx prisma studio
Authentication with NextAuth.js v5
Implement secure authentication.
Auth Configuration
typescript
// auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
providers: [
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 as string },
});
if (!user) return null;
const isValid = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!isValid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
}
return session;
},
},
});
Route Handler
typescript
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
Protected Routes
typescript
// middleware.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith("/login");
const isProtectedPage = req.nextUrl.pathname.startsWith("/dashboard");
if (isProtectedPage && !isLoggedIn) {
return NextResponse.redirect(new URL("/login", req.url));
}
if (isAuthPage && isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Server Actions for Mutations
Type-safe server mutations without API routes.
Form Actions
typescript
// app/actions/posts.ts
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
const createPostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
published: z.boolean().default(false),
});
export async function createPost(formData: FormData) {
const session = await auth();
if (!session?.user) {
throw new Error("Unauthorized");
}
const validatedFields = createPostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
published: formData.get("published") === "on",
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: "Validation failed",
};
}
const { title, content, published } = validatedFields.data;
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
try {
await prisma.post.create({
data: {
title,
slug,
content,
published,
authorId: session.user.id,
},
});
} catch (error) {
return { message: "Database error: Failed to create post" };
}
revalidatePath("/dashboard/posts");
redirect("/dashboard/posts");
}
export async function deletePost(id: string) {
const session = await auth();
if (!session?.user) {
throw new Error("Unauthorized");
}
try {
await prisma.post.delete({
where: {
id,
authorId: session.user.id, // Security: only delete own posts
},
});
} catch (error) {
return { message: "Failed to delete post" };
}
revalidatePath("/dashboard/posts");
}
Using Server Actions in Forms
typescript
// app/dashboard/posts/new/page.tsx
import { createPost } from "@/app/actions/posts";
export default function NewPost() {
return (
);
}
Data Fetching Patterns
Efficient data loading strategies.
Parallel Data Fetching
typescript
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
async function getStats(userId: string) {
return await prisma.post.count({
where: { authorId: userId },
});
}
async function getRecentPosts(userId: string) {
return await prisma.post.findMany({
where: { authorId: userId },
orderBy: { createdAt: "desc" },
take: 5,
});
}
export default async function Dashboard() {
const session = await auth();
if (!session?.user) {
return null;
}
// Fetch in parallel
const [stats, recentPosts] = await Promise.all([
getStats(session.user.id),
getRecentPosts(session.user.id),
]);
return (
Dashboard
Total posts: {stats}
{recentPosts.map((post) => (
{post.title}
{post.content.substring(0, 100)}...
))}
);
}
Streaming with Suspense
typescript
// app/dashboard/posts/page.tsx
import { Suspense } from "react";
import { PostsList } from "./posts-list";
import { PostsSkeleton } from "./posts-skeleton";
export default function PostsPage() {
return (
My Posts
}>
);
}
// posts-list.tsx
async function PostsList() {
const posts = await prisma.post.findMany({
orderBy: { createdAt: "desc" },
});
return (
{posts.map((post) => (
))}
);
}
Form Validation with Zod
Type-safe validation on client and server.
Shared Schema
typescript
// lib/validations/post.ts
import { z } from "zod";
export const postSchema = z.object({
title: z
.string()
.min(3, "Title must be at least 3 characters")
.max(100, "Title must be less than 100 characters"),
content: z
.string()
.min(10, "Content must be at least 10 characters")
.max(5000, "Content must be less than 5000 characters"),
published: z.boolean().default(false),
});
export type PostFormData = z.infer;
Client-Side Form
typescript
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { postSchema, type PostFormData } from "@/lib/validations/post";
import { createPost } from "@/app/actions/posts";
export function PostForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(postSchema),
});
async function onSubmit(data: PostFormData) {
const formData = new FormData();
formData.append("title", data.title);
formData.append("content", data.content);
formData.append("published", String(data.published));
await createPost(formData);
}
return (
);
}
API Routes (When Needed)
For third-party integrations or webhooks.
typescript
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const post = await prisma.post.findUnique({
where: { id: params.id },
include: { author: { select: { name: true, email: true } } },
});
if (!post) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(post);
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const post = await prisma.post.update({
where: {
id: params.id,
authorId: session.user.id,
},
data: body,
});
return NextResponse.json(post);
}
Deployment
Deploy to Vercel for optimal performance.
bash
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
# Production deployment
vercel --prod
Environment Variables
env
# .env.local
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
NEXTAUTH_SECRET="your-secret-key"
NEXTAUTH_URL="http://localhost:3000"
Best Practices
- ✅ Use Server Components by default
- ✅ Server Actions for mutations
- ✅ Validate inputs with Zod
- ✅ Use Prisma for type-safe queries
- ✅ Implement proper authentication
- ✅ Cache strategically with revalidatePath
- ✅ Handle errors gracefully
- ✅ Use TypeScript strictly
- ✅ Optimize database queries
- ✅ Monitor performance
Conclusion
Next.js 15 provides a complete full-stack solution. With Server Components, Server Actions, and powerful integrations, you can build production-ready applications faster than ever.
The key is understanding when to use each feature: Server Components for data fetching, Server Actions for mutations, and API routes only when necessary.
Happy coding! 🚀