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:

script.sh

# 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

code.prisma

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

app.ts

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

script.sh

# 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

app.ts

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

app.ts

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

app.ts

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

app.ts

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

app.ts

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 (

class="keyword">class="string">"space-y-4">
id=class="keyword">class="string">"title"

name=class="keyword">class="string">"title"

class="keyword">type=class="keyword">class="string">"text"

required

className=class="keyword">class="string">"w-full rounded border px-3 py-2"

/>