advanced Step 15 of 15

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 with revalidatePath after 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.
arrow_back Deployment check_circle Lap Complete!