Back to blog

Full-Stack Development with Next.js 15 - The Complete Stack

8 min readBy Mustafa Akkaya
#Next.js#Full-Stack#Prisma#Authentication

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 (

id="title"

name="title"

type="text"

required

className="w-full rounded border px-3 py-2"

/>