Building a Full-Stack Next.js App
Next.js Development
Building a Full-Stack Next.js App
Building a full-stack application with Next.js brings together everything covered in this learning path: file-based routing, server and client components, data fetching strategies, API routes, database integration, authentication, middleware, and deployment. Next.js uniquely enables building the entire application — front end and back end — in a single codebase with a unified development experience. This final step walks through the architecture and key patterns of a production-grade application, demonstrating how all the pieces connect to create a cohesive, performant, and maintainable system.
Application Architecture
// Project structure for a full-stack Next.js application
src/
app/
(auth)/ # Auth route group (separate layout)
login/page.tsx
register/page.tsx
layout.tsx # Minimal layout for auth pages
(main)/ # Main app route group
layout.tsx # Layout with navbar, sidebar
page.tsx # Home / dashboard
projects/
page.tsx # Project list (SSR)
[id]/
page.tsx # Project detail (SSR)
edit/page.tsx # Edit form (Client Component)
settings/page.tsx
api/
auth/[...nextauth]/route.ts
projects/route.ts
projects/[id]/route.ts
upload/route.ts
components/
ui/ # Generic UI components
Button.tsx
Input.tsx
Modal.tsx
DataTable.tsx
features/ # Feature-specific components
ProjectCard.tsx
ProjectForm.tsx
TaskList.tsx
lib/
prisma.ts # Database client
auth.ts # Auth utilities
validations.ts # Zod schemas
middleware.ts # Auth + rate limiting
prisma/
schema.prisma
Server Actions for Mutations
Server Actions are functions that run on the server and can be called directly from Client Components. They replace the need for many API routes for form submissions and data mutations.
// app/actions/projects.ts
"use server";
import prisma from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
const ProjectSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
});
export async function createProject(formData: FormData) {
const session = await requireAuth();
const parsed = ProjectSchema.safeParse({
name: formData.get("name"),
description: formData.get("description"),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
const project = await prisma.project.create({
data: {
...parsed.data,
ownerId: session.user.id,
},
});
revalidatePath("/projects");
redirect(`/projects/${project.id}`);
}
export async function deleteProject(projectId: number) {
const session = await requireAuth();
await prisma.project.delete({
where: { id: projectId, ownerId: session.user.id },
});
revalidatePath("/projects");
}
Project List Page (Server Component)
// app/(main)/projects/page.tsx
import prisma from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import ProjectCard from "@/components/features/ProjectCard";
import CreateProjectButton from "@/components/features/CreateProjectButton";
export default async function ProjectsPage() {
const session = await requireAuth();
const projects = await prisma.project.findMany({
where: { ownerId: session.user.id },
include: {
_count: { select: { tasks: true } },
},
orderBy: { updatedAt: "desc" },
});
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Your Projects</h1>
<CreateProjectButton />
</div>
{projects.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500">No projects yet. Create your first one!</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
taskCount={project._count.tasks}
/>
))}
</div>
)}
</div>
);
}
Client Component with Server Action
// components/features/CreateProjectButton.tsx
"use client";
import { useState } from "react";
import { createProject } from "@/app/actions/projects";
export default function CreateProjectButton() {
const [isOpen, setIsOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(formData: FormData) {
setError(null);
const result = await createProject(formData);
if (result?.error) {
setError(Object.values(result.error).flat().join(", "));
}
// On success, createProject redirects automatically
}
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
New Project
</button>
);
}
return (
<form action={handleSubmit} className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Create Project</h2>
{error && <p className="text-red-500 mb-4">{error}</p>}
<input name="name" placeholder="Project name" required
className="w-full p-2 border rounded mb-4" />
<textarea name="description" placeholder="Description (optional)"
className="w-full p-2 border rounded mb-4" />
<div className="flex gap-2">
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded">
Create
</button>
<button type="button" onClick={() => setIsOpen(false)}
className="px-4 py-2 bg-gray-200 rounded">
Cancel
</button>
</div>
</form>
);
}
Full-Stack Architecture Summary
// Key patterns for production Next.js apps:
//
// 1. DATA FLOW
// Server Components → read from database/API directly
// Client Components → call Server Actions or API routes for mutations
// Server Actions → validate, mutate, revalidate cache
//
// 2. AUTHENTICATION
// middleware.ts → protect routes, redirect unauthenticated users
// lib/auth.ts → getSession(), requireAuth() helpers
// Cookies → HTTP-only session tokens
//
// 3. VALIDATION
// Zod schemas → validate on both client and server
// Server Actions → always validate inputs server-side
//
// 4. ERROR HANDLING
// error.tsx → catch rendering errors per route segment
// try/catch → handle database and API errors in Server Actions
// loading.tsx → show loading states during navigation
//
// 5. PERFORMANCE
// Server Components → minimize client JavaScript
// Streaming + Suspense → progressive loading
// Image optimization → next/image for all images
// ISR → revalidate cached pages on a schedule
Tip: Start with Server Components by default and only add"use client"when you need interactivity. Use Server Actions for form submissions instead of building API routes. Validate all inputs with Zod on the server. Cache aggressively with ISR and invalidate withrevalidatePathafter mutations.
Key Takeaways
- Next.js enables full-stack development in a single codebase with unified server and client code.
- Server Components fetch data directly; Client Components handle interactivity and call Server Actions.
- Server Actions replace many API routes for form submissions and data mutations.
- Organize code by feature domain: route groups, components, lib utilities, and database models.
- Apply security at every layer: middleware for route protection, server-side validation, HTTP-only cookies, and security headers.