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:
# 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
class="keyword">class="comment">// prisma/schema.prisma
generator client {
provider = class="keyword">class="string">"prisma-client-js"
}
datasource db {
provider = class="keyword">class="string">"postgresql"
url = env(class="keyword">class="string">"DATABASE_URL")
}
model User {
id String @id @class="keyword">default(cuid())
email String @unique
name String?
password String
posts Post[]
createdAt DateTime @class="keyword">default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @class="keyword">default(cuid())
title String
slug String @unique
content String
published Boolean @class="keyword">default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @class="keyword">default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([slug])
}
Prisma Client Setup
class="keyword">class="comment">// lib/prisma.ts
class="keyword">import { PrismaClient } class="keyword">from class="keyword">class="string">"@prisma/client";
class="keyword">class="keyword">const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | class="keyword">undefined;
};
class="keyword">export class="keyword">class="keyword">const prisma = globalForPrisma.prisma ?? class="keyword">new PrismaClient();
class="keyword">class="keyword">if (process.env.NODE_ENV !== class="keyword">class="string">"production") {
globalForPrisma.prisma = prisma;
}
Database Migrations
# 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
class="keyword">class="comment">// auth.ts
class="keyword">import NextAuth class="keyword">from class="keyword">class="string">"next-auth";
class="keyword">import Credentials class="keyword">from class="keyword">class="string">"next-auth/providers/credentials";
class="keyword">import { PrismaAdapter } class="keyword">from class="keyword">class="string">"@auth/prisma-adapter";
class="keyword">import { prisma } class="keyword">from class="keyword">class="string">"@/lib/prisma";
class="keyword">import bcrypt class="keyword">from class="keyword">class="string">"bcryptjs";
class="keyword">export class="keyword">class="keyword">const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: class="keyword">class="string">"jwt" },
pages: {
signIn: class="keyword">class="string">"/login",
},
providers: [
Credentials({
credentials: {
email: { label: class="keyword">class="string">"Email", class="keyword">type: class="keyword">class="string">"email" },
password: { label: class="keyword">class="string">"Password", class="keyword">type: class="keyword">class="string">"password" },
},
class="keyword">class="keyword">async authorize(credentials) {
class="keyword">class="keyword">if (!credentials?.email || !credentials?.password) {
class="keyword">class="keyword">return class="keyword">null;
}
class="keyword">class="keyword">const user = class="keyword">class="keyword">await prisma.user.findUnique({
where: { email: credentials.email as string },
});
class="keyword">class="keyword">if (!user) class="keyword">class="keyword">return class="keyword">null;
class="keyword">class="keyword">const isValid = class="keyword">class="keyword">await bcrypt.compare(
credentials.password as string,
user.password
);
class="keyword">class="keyword">if (!isValid) class="keyword">class="keyword">return class="keyword">null;
class="keyword">class="keyword">return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
],
callbacks: {
class="keyword">class="keyword">async jwt({ token, user }) {
class="keyword">class="keyword">if (user) {
token.id = user.id;
}
class="keyword">class="keyword">return token;
},
class="keyword">class="keyword">async session({ session, token }) {
class="keyword">class="keyword">if (session.user) {
session.user.id = token.id as string;
}
class="keyword">class="keyword">return session;
},
},
});
Route Handler
class="keyword">class="comment">// app/api/auth/[...nextauth]/route.ts
class="keyword">import { handlers } class="keyword">from class="keyword">class="string">"@/auth";
class="keyword">export class="keyword">class="keyword">const { GET, POST } = handlers;
Protected Routes
class="keyword">class="comment">// middleware.ts
class="keyword">import { auth } class="keyword">from class="keyword">class="string">"@/auth";
class="keyword">import { NextResponse } class="keyword">from class="keyword">class="string">"next/server";
class="keyword">export class="keyword">default auth((req) => {
class="keyword">class="keyword">const isLoggedIn = !!req.auth;
class="keyword">class="keyword">const isAuthPage = req.nextUrl.pathname.startsWith(class="keyword">class="string">"/login");
class="keyword">class="keyword">const isProtectedPage = req.nextUrl.pathname.startsWith(class="keyword">class="string">"/dashboard");
class="keyword">class="keyword">if (isProtectedPage && !isLoggedIn) {
class="keyword">class="keyword">return NextResponse.redirect(class="keyword">new URL(class="keyword">class="string">"/login", req.url));
}
class="keyword">class="keyword">if (isAuthPage && isLoggedIn) {
class="keyword">class="keyword">return NextResponse.redirect(class="keyword">new URL(class="keyword">class="string">"/dashboard", req.url));
}
class="keyword">class="keyword">return NextResponse.next();
});
class="keyword">export class="keyword">class="keyword">const config = {
matcher: [class="keyword">class="string">"/((?!api|_next/class="keyword">static|_next/image|favicon.ico).*)"],
};
Server Actions for Mutations
Type-safe server mutations without API routes.
Form Actions
class="keyword">class="comment">// app/actions/posts.ts
class="keyword">class="string">"use server";
class="keyword">import { auth } class="keyword">from class="keyword">class="string">"@/auth";
class="keyword">import { prisma } class="keyword">from class="keyword">class="string">"@/lib/prisma";
class="keyword">import { revalidatePath } class="keyword">from class="keyword">class="string">"next/cache";
class="keyword">import { redirect } class="keyword">from class="keyword">class="string">"next/navigation";
class="keyword">import { z } class="keyword">from class="keyword">class="string">"zod";
class="keyword">class="keyword">const createPostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
published: z.boolean().class="keyword">default(false),
});
class="keyword">export class="keyword">class="keyword">async class="keyword">class="keyword">function createPost(formData: FormData) {
class="keyword">class="keyword">const session = class="keyword">class="keyword">await auth();
class="keyword">class="keyword">if (!session?.user) {
class="keyword">throw class="keyword">new Error(class="keyword">class="string">"Unauthorized");
}
class="keyword">class="keyword">const validatedFields = createPostSchema.safeParse({
title: formData.get(class="keyword">class="string">"title"),
content: formData.get(class="keyword">class="string">"content"),
published: formData.get(class="keyword">class="string">"published") === class="keyword">class="string">"on",
});
class="keyword">class="keyword">if (!validatedFields.success) {
class="keyword">class="keyword">return {
errors: validatedFields.error.flatten().fieldErrors,
message: class="keyword">class="string">"Validation failed",
};
}
class="keyword">class="keyword">const { title, content, published } = validatedFields.data;
class="keyword">class="keyword">const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, class="keyword">class="string">"-")
.replace(/(^-|-$)/g, class="keyword">class="string">"");
class="keyword">try {
class="keyword">class="keyword">await prisma.post.create({
data: {
title,
slug,
content,
published,
authorId: session.user.id,
},
});
} class="keyword">catch (error) {
class="keyword">class="keyword">return { message: class="keyword">class="string">"Database error: Failed to create post" };
}
revalidatePath(class="keyword">class="string">"/dashboard/posts");
redirect(class="keyword">class="string">"/dashboard/posts");
}
class="keyword">export class="keyword">class="keyword">async class="keyword">class="keyword">function deletePost(id: string) {
class="keyword">class="keyword">const session = class="keyword">class="keyword">await auth();
class="keyword">class="keyword">if (!session?.user) {
class="keyword">throw class="keyword">new Error(class="keyword">class="string">"Unauthorized");
}
class="keyword">try {
class="keyword">class="keyword">await prisma.post.delete({
where: {
id,
authorId: session.user.id, class="keyword">class="comment">// Security: only delete own posts
},
});
} class="keyword">catch (error) {
class="keyword">class="keyword">return { message: class="keyword">class="string">"Failed to delete post" };
}
revalidatePath(class="keyword">class="string">"/dashboard/posts");
}
Using Server Actions in Forms
class="keyword">class="comment">// app/dashboard/posts/class="keyword">new/page.tsx
class="keyword">import { createPost } class="keyword">from class="keyword">class="string">"@/app/actions/posts";
class="keyword">export class="keyword">default class="keyword">class="keyword">function NewPost() {
class="keyword">class="keyword">return (
);
}
Data Fetching Patterns
Efficient data loading strategies.
Parallel Data Fetching
class="keyword">class="comment">// app/dashboard/page.tsx
class="keyword">import { auth } class="keyword">from class="keyword">class="string">"@/auth";
class="keyword">import { prisma } class="keyword">from class="keyword">class="string">"@/lib/prisma";
class="keyword">class="keyword">async class="keyword">class="keyword">function getStats(userId: string) {
class="keyword">class="keyword">return class="keyword">class="keyword">await prisma.post.count({
where: { authorId: userId },
});
}
class="keyword">class="keyword">async class="keyword">class="keyword">function getRecentPosts(userId: string) {
class="keyword">class="keyword">return class="keyword">class="keyword">await prisma.post.findMany({
where: { authorId: userId },
orderBy: { createdAt: class="keyword">class="string">"desc" },
take: 5,
});
}
class="keyword">export class="keyword">default class="keyword">class="keyword">async class="keyword">class="keyword">function Dashboard() {
class="keyword">class="keyword">const session = class="keyword">class="keyword">await auth();
class="keyword">class="keyword">if (!session?.user) {
class="keyword">class="keyword">return class="keyword">null;
}
class="keyword">class="comment">// Fetch in parallel
class="keyword">class="keyword">const [stats, recentPosts] = class="keyword">class="keyword">await Promise.all([
getStats(session.user.id),
getRecentPosts(session.user.id),
]);
class="keyword">class="keyword">return (
Dashboard
Total posts: {stats}
class="keyword">class="string">"space-y-4">
{recentPosts.map((post) => (
{post.title}
{post.content.substring(0, 100)}...
))}
);
}
Streaming with Suspense
class="keyword">class="comment">// app/dashboard/posts/page.tsx
class="keyword">import { Suspense } class="keyword">from class="keyword">class="string">"react";
class="keyword">import { PostsList } class="keyword">from class="keyword">class="string">"./posts-list";
class="keyword">import { PostsSkeleton } class="keyword">from class="keyword">class="string">"./posts-skeleton";
class="keyword">export class="keyword">default class="keyword">class="keyword">function PostsPage() {
class="keyword">class="keyword">return (
My Posts
}>
);
}
class="keyword">class="comment">// posts-list.tsx
class="keyword">class="keyword">async class="keyword">class="keyword">function PostsList() {
class="keyword">class="keyword">const posts = class="keyword">class="keyword">await prisma.post.findMany({
orderBy: { createdAt: class="keyword">class="string">"desc" },
});
class="keyword">class="keyword">return (
class="keyword">class="string">"space-y-4">
{posts.map((post) => (
))}
);
}
Form Validation with Zod
Type-safe validation on client and server.
Shared Schema
class="keyword">class="comment">// lib/validations/post.ts
class="keyword">import { z } class="keyword">from class="keyword">class="string">"zod";
class="keyword">export class="keyword">class="keyword">const postSchema = z.object({
title: z
.string()
.min(3, class="keyword">class="string">"Title must be at least 3 characters")
.max(100, class="keyword">class="string">"Title must be less than 100 characters"),
content: z
.string()
.min(10, class="keyword">class="string">"Content must be at least 10 characters")
.max(5000, class="keyword">class="string">"Content must be less than 5000 characters"),
published: z.boolean().class="keyword">default(false),
});
class="keyword">export class="keyword">type PostFormData = z.infer<class="keyword">typeof postSchema>;
Client-Side Form
class="keyword">class="string">"use client";
class="keyword">import { useForm } class="keyword">from class="keyword">class="string">"react-hook-form";
class="keyword">import { zodResolver } class="keyword">from class="keyword">class="string">"@hookform/resolvers/zod";
class="keyword">import { postSchema, class="keyword">type PostFormData } class="keyword">from class="keyword">class="string">"@/lib/validations/post";
class="keyword">import { createPost } class="keyword">from class="keyword">class="string">"@/app/actions/posts";
class="keyword">export class="keyword">class="keyword">function PostForm() {
class="keyword">class="keyword">const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(postSchema),
});
class="keyword">class="keyword">async class="keyword">class="keyword">function onSubmit(data: PostFormData) {
class="keyword">class="keyword">const formData = class="keyword">new FormData();
formData.append(class="keyword">class="string">"title", data.title);
formData.append(class="keyword">class="string">"content", data.content);
formData.append(class="keyword">class="string">"published", String(data.published));
class="keyword">class="keyword">await createPost(formData);
}
class="keyword">class="keyword">return (
);
}
API Routes (When Needed)
For third-party integrations or webhooks.
class="keyword">class="comment">// app/api/posts/[id]/route.ts
class="keyword">import { NextRequest, NextResponse } class="keyword">from class="keyword">class="string">"next/server";
class="keyword">import { auth } class="keyword">from class="keyword">class="string">"@/auth";
class="keyword">import { prisma } class="keyword">from class="keyword">class="string">"@/lib/prisma";
class="keyword">export class="keyword">class="keyword">async class="keyword">class="keyword">function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
class="keyword">class="keyword">const post = class="keyword">class="keyword">await prisma.post.findUnique({
where: { id: params.id },
include: { author: { select: { name: true, email: true } } },
});
class="keyword">class="keyword">if (!post) {
class="keyword">class="keyword">return NextResponse.json({ error: class="keyword">class="string">"Not found" }, { status: 404 });
}
class="keyword">class="keyword">return NextResponse.json(post);
}
class="keyword">export class="keyword">class="keyword">async class="keyword">class="keyword">function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
class="keyword">class="keyword">const session = class="keyword">class="keyword">await auth();
class="keyword">class="keyword">if (!session?.user) {
class="keyword">class="keyword">return NextResponse.json({ error: class="keyword">class="string">"Unauthorized" }, { status: 401 });
}
class="keyword">class="keyword">const body = class="keyword">class="keyword">await request.json();
class="keyword">class="keyword">const post = class="keyword">class="keyword">await prisma.post.update({
where: {
id: params.id,
authorId: session.user.id,
},
data: body,
});
class="keyword">class="keyword">return NextResponse.json(post);
}
Deployment
Deploy to Vercel for optimal performance.
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
# Production deployment
vercel --prod
Environment Variables
# .env.local
DATABASE_URL=class="keyword">class="string">"postgresql:class="keyword">class="comment">//user:password@localhost:5432/mydb"
NEXTAUTH_SECRET=class="keyword">class="string">"your-secret-key"
NEXTAUTH_URL=class="keyword">class="string">"http:class="keyword">class="comment">//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! 🚀