Skip to main content

How to Structure a Full-Stack Next.js 15 Project in 2026: App Router, Server Actions & AI Patterns

Next.js 15 changed everything with App Router and Server Actions. Get the production folder structure Groovy Web uses across 200+ projects, including the new /agents directory for AI integration.

How to Structure a Full-Stack Next.js 15 Project in 2026: App Router, Server Actions & AI Patterns

Next.js 15 now powers over 45% of production React applications β€” and for good reason. The framework has evolved from a simple SSR wrapper into a full-stack platform capable of handling authentication, database mutations, streaming AI responses, and background agent workflows without leaving JavaScript β€” using TypeScript throughout. But here's what nobody tells you: most Next.js 15 projects fail not because the framework lacks capability, but because of folder structure decisions made in week one that can't be undone without a full rewrite.

This guide covers the exact production Next.js 15 folder structure that Groovy Web's engineering team uses across 200+ client projects. It reflects what actually works in 2026 β€” App Router, Server Components, Server Actions, and the new /agents directory pattern for integrating LLM workflows. If you are starting a new project or migrating from Pages Router, this is the guide that pre-2024 resources cannot give you.

45% of production React apps now run on Next.js
2024 Next.js 15 released with Turbopack stable and React 19 support
200+ Next.js projects shipped by Groovy Web's AI Agent Teams
Default App Router is now the primary routing model in all Next.js docs

What Changed in Next.js 15 That Makes Old Structure Guides Wrong

Every folder structure guide published before mid-2023 is wrong for production use in 2026. This is not hyperbole. The architectural model has fundamentally changed across four dimensions that affect how you organise code.

App Router Replaces Pages Router as the Default

The Pages Router (/pages directory) is now legacy. Next.js 15 ships with App Router as the default, documented first, and recommended for all new projects. The routing model is file-system-based but operates on a completely different mental model: every file in /app is a React Server Component by default unless you explicitly opt into client-side rendering with the "use client" directive. This inverts how you think about component placement. Instead of asking "should I SSR this?", you now ask "does this component need browser APIs or interactivity? If not, keep it on the server."

Old guides that place all components in /components and all pages in /pages break completely when you adopt App Router. Route groups, parallel routes, intercepted routes, and layouts require a different mental model of how files map to URLs.

Server Components Eliminate the Need for API Routes in Many Cases

In the Pages Router era, any server-side data fetch required either getServerSideProps, getStaticProps, or an API route. With React Server Components in App Router, you can fetch data directly in any component that runs on the server β€” which is every component by default. This eliminates an entire category of API routes that old architecture required. Your database queries, Prisma calls, and third-party API fetches now live inside Server Components, not in /pages/api handlers called from client components.

Server Actions Replace Form Handling and Many Mutation Patterns

Server Actions (stable in Next.js 14, production-proven in Next.js 15) allow you to define server-side functions that can be called directly from client components and HTML forms. This eliminates the request/response cycle for mutations. You no longer need a POST /api/users route to handle a form submission β€” the Server Action runs on the server, validated with Zod, interacts with your database via Prisma, and returns a typed response. This completely changes where you put business logic.

Turbopack Changes How You Think About Build Configuration

Turbopack is the new default bundler in Next.js 15 development mode, replacing Webpack for local development. This matters for folder structure because some older patterns β€” particularly around CSS Modules imports, dynamic alias resolution, and certain webpack-specific plugin configurations β€” need to be updated. The good news: Turbopack is dramatically faster (cold starts in under 1 second on large projects), but if your structure relied on webpack-specific hacks, those need to be cleaned up.

Migration Warning: If you are importing from /pages/api routes in your client components, you have coupling that will prevent a clean App Router migration. Structure your API layer as a separate /lib/api client module that can be swapped independently of the routing model.

The Groovy Web Production Next.js 15 Folder Structure

The following is the exact directory layout Groovy Web uses when starting a new Next.js 15 production project. Every directory has a purpose. Nothing is placed by habit.

my-app/
β”œβ”€β”€ app/                          ← App Router β€” ALL routes live here
β”‚   β”œβ”€β”€ (auth)/                   ← Route group: auth pages, no URL segment added
β”‚   β”‚   β”œβ”€β”€ login/
β”‚   β”‚   β”‚   └── page.tsx
β”‚   β”‚   β”œβ”€β”€ register/
β”‚   β”‚   β”‚   └── page.tsx
β”‚   β”‚   └── layout.tsx            ← Auth-specific layout (minimal, no nav)
β”‚   β”œβ”€β”€ (dashboard)/              ← Route group: authenticated app
β”‚   β”‚   β”œβ”€β”€ dashboard/
β”‚   β”‚   β”‚   └── page.tsx
β”‚   β”‚   β”œβ”€β”€ settings/
β”‚   β”‚   β”‚   └── page.tsx
β”‚   β”‚   └── layout.tsx            ← Dashboard layout (sidebar, nav)
β”‚   β”œβ”€β”€ api/                      ← API Route Handlers (use sparingly)
β”‚   β”‚   β”œβ”€β”€ webhooks/
β”‚   β”‚   β”‚   └── stripe/
β”‚   β”‚   β”‚       └── route.ts
β”‚   β”‚   └── ai/
β”‚   β”‚       └── stream/
β”‚   β”‚           └── route.ts      ← Streaming AI responses
β”‚   β”œβ”€β”€ globals.css
β”‚   β”œβ”€β”€ layout.tsx                ← Root layout (replaces _app.tsx)
β”‚   β”œβ”€β”€ page.tsx                  ← Homepage
β”‚   β”œβ”€β”€ loading.tsx               ← Global loading UI
β”‚   β”œβ”€β”€ error.tsx                 ← Global error boundary
β”‚   └── not-found.tsx             ← 404 page
β”‚
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ ui/                       ← Primitive/headless components
β”‚   β”‚   β”œβ”€β”€ Button.tsx
β”‚   β”‚   β”œβ”€β”€ Input.tsx
β”‚   β”‚   β”œβ”€β”€ Modal.tsx
β”‚   β”‚   └── index.ts              ← Barrel export
β”‚   β”œβ”€β”€ features/                 ← Feature-specific components
β”‚   β”‚   β”œβ”€β”€ auth/
β”‚   β”‚   β”‚   β”œβ”€β”€ LoginForm.tsx     ← "use client" β€” has state
β”‚   β”‚   β”‚   └── UserAvatar.tsx    ← Server Component β€” just renders data
β”‚   β”‚   └── billing/
β”‚   β”‚       β”œβ”€β”€ PlanCard.tsx
β”‚   β”‚       └── UsageChart.tsx    ← "use client" β€” needs Chart.js
β”‚   └── layouts/
β”‚       β”œβ”€β”€ DashboardLayout.tsx
β”‚       └── MarketingLayout.tsx
β”‚
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ actions/                  ← Server Actions (all "use server" files)
β”‚   β”‚   β”œβ”€β”€ auth.ts
β”‚   β”‚   β”œβ”€β”€ billing.ts
β”‚   β”‚   └── user.ts
β”‚   β”œβ”€β”€ api/                      ← API client functions (called from client components)
β”‚   β”‚   β”œβ”€β”€ client.ts             ← Axios/fetch wrapper
β”‚   β”‚   └── endpoints.ts
β”‚   β”œβ”€β”€ db/                       ← Database layer
β”‚   β”‚   β”œβ”€β”€ prisma.ts             ← Prisma client singleton
β”‚   β”‚   β”œβ”€β”€ queries/              ← Reusable query functions
β”‚   β”‚   β”‚   β”œβ”€β”€ users.ts
β”‚   β”‚   β”‚   └── billing.ts
β”‚   β”‚   └── schema/               ← Drizzle schema (if using Drizzle)
β”‚   β”œβ”€β”€ auth/                     ← Auth helpers (next-auth config)
β”‚   β”‚   └── options.ts
β”‚   β”œβ”€β”€ validations/              ← Zod schemas
β”‚   β”‚   β”œβ”€β”€ auth.ts
β”‚   β”‚   └── user.ts
β”‚   └── utils/                    ← Pure utility functions (no side effects)
β”‚       β”œβ”€β”€ cn.ts                 ← className merge (clsx + tailwind-merge)
β”‚       β”œβ”€β”€ format.ts
β”‚       └── date.ts
β”‚
β”œβ”€β”€ hooks/                        ← Client-side custom hooks ("use client" context)
β”‚   β”œβ”€β”€ useAuth.ts
β”‚   β”œβ”€β”€ useDebounce.ts
β”‚   └── useLocalStorage.ts
β”‚
β”œβ”€β”€ stores/                       ← Client state (Zustand or Jotai)
β”‚   β”œβ”€β”€ useAuthStore.ts
β”‚   └── useUIStore.ts
β”‚
β”œβ”€β”€ types/                        ← Global TypeScript type definitions
β”‚   β”œβ”€β”€ index.ts                  ← Re-exports all types
β”‚   β”œβ”€β”€ api.ts                    ← API response types
β”‚   β”œβ”€β”€ db.ts                     ← DB model types (if not using Prisma generated)
β”‚   └── next.d.ts                 ← Next.js augmentations
β”‚
β”œβ”€β”€ agents/                       ← AI agent integrations  ← NEW IN AI ERA
β”‚   β”œβ”€β”€ prompts/                  ← System prompts and prompt templates
β”‚   β”‚   β”œβ”€β”€ base.ts               ← Shared system prompt components
β”‚   β”‚   β”œβ”€β”€ summariser.ts
β”‚   β”‚   └── classifier.ts
β”‚   β”œβ”€β”€ tools/                    ← Agent tool definitions (function calling)
β”‚   β”‚   β”œβ”€β”€ search.ts
β”‚   β”‚   β”œβ”€β”€ database.ts
β”‚   β”‚   └── email.ts
β”‚   └── workflows/                ← Multi-step agent workflows
β”‚       β”œβ”€β”€ onboarding.ts         ← Multi-step user onboarding agent
β”‚       └── support.ts            ← Support ticket triage workflow
β”‚
β”œβ”€β”€ public/                       ← Static assets
β”‚   β”œβ”€β”€ images/
β”‚   └── fonts/
β”‚
β”œβ”€β”€ middleware.ts                 ← Edge middleware (auth, redirects, A/B)
β”œβ”€β”€ next.config.ts                ← Next.js config (TypeScript, not .js)
β”œβ”€β”€ tailwind.config.ts
β”œβ”€β”€ tsconfig.json
└── prisma/
    β”œβ”€β”€ schema.prisma
    └── migrations/

Why Each Top-Level Directory Exists

/app is sacred. Only routing files go here: page.tsx, layout.tsx, loading.tsx, error.tsx, route.ts, and not-found.tsx. Never put business logic or reusable components directly in route files. Treat route files as thin orchestration layers.

/components/ui holds your primitive components β€” the building blocks that have no knowledge of your domain. Button, Input, Badge, Modal. These are ideally headless or minimally styled. If you use shadcn/ui, this is where it installs.

/components/features is where domain-aware components live, organised by feature domain. A LoginForm knows about authentication. A PlanCard knows about billing. These components are allowed to import from /lib and /stores.

/lib is the application core. It contains Server Actions, database queries, validation schemas, auth configuration, and utilities. Nothing in /lib should import from /components or /app. The dependency graph flows one way: app β†’ components β†’ lib.

/hooks and /stores are explicitly client-side. Any file in these directories implicitly requires "use client" in whatever imports them. Keeping them separate from /lib makes the client/server boundary visible in the file system.

/agents is the new addition most teams in 2024-2025 are figuring out on the fly. This directory deserves its own section.

Server Components vs Client Components: The Decision Rule

The single most important architectural decision in Next.js 15 is where the server/client boundary falls in your component tree. Get this wrong and you will either ship too much JavaScript to the browser or create awkward prop-drilling to pass server data into client components.

The One-Line Rule

If a component uses any of the following, it must be a Client Component with "use client" at the top: browser APIs (window, document, localStorage), React hooks (useState, useEffect, useRef), event handlers (onClick, onChange), real-time subscriptions, or any third-party library that itself uses the above. Everything else should be a Server Component.

CharacteristicServer ComponentClient Component
Default in App RouterYesNo β€” requires "use client"
Can access database directlyYesNo
Can use useState / useEffectNoYes
Included in JavaScript bundleNoYes
Can be asyncYesNo (in React 19, limited support)
Can import Server ActionsYesYes (via props or import)
Re-renders on state changeNoYes
Ideal forData fetching, layout, static UIInteractivity, forms, animations

The Boundary Pattern: Push Client Components to the Leaves

The optimal strategy is to keep the top of your component tree on the server and push Client Components as far down the tree as possible β€” to the "leaves" where interactivity is actually needed. Consider a dashboard page: the page layout, the header, the data grid structure, and the static content are all Server Components. Only the interactive filter dropdown, the search input, and the chart tooltip become Client Components.

// app/(dashboard)/dashboard/page.tsx β€” Server Component (no directive needed)
import { DashboardHeader } from "@/components/features/dashboard/DashboardHeader";
import { MetricsGrid } from "@/components/features/dashboard/MetricsGrid";
import { RecentActivity } from "@/components/features/dashboard/RecentActivity";
import { getMetrics } from "@/lib/db/queries/metrics";
import { getCurrentUser } from "@/lib/auth/session";

export default async function DashboardPage() {
  // These DB calls run on the server β€” no useEffect, no loading spinner, no API route
  const [user, metrics] = await Promise.all([
    getCurrentUser(),
    getMetrics({ days: 30 }),
  ]);

  return (
    
); }

Common Mistakes That Break Performance

Mistake 1: Marking a component "use client" unnecessarily. Every Client Component and all its imports get included in the JavaScript bundle. A component that only renders static markup and accepts props from a parent should stay on the server.

Mistake 2: Importing a Client Component into a Server Component with server-only data. You can pass server data as props to Client Components, but you cannot pass non-serialisable values (class instances, functions other than Server Actions, Promises). Plan your data contracts accordingly.

Mistake 3: Putting all components in a single "use client" wrapper. Some teams put their entire component tree under a single Client Component "shell" to avoid thinking about the boundary. This effectively opts out of Server Components entirely and negates Next.js 15's biggest performance advantage.

Server Actions: The Game Changer for Full-Stack Next.js

Server Actions are the feature that makes Next.js 15 genuinely full-stack, not just a server-side rendering layer over a separate API. A Server Action is a function marked with "use server" that runs exclusively on the server but can be called from anywhere β€” a form's action prop, a button's onClick, or a server component directly.

A Real Production Server Action: Form Submission with Optimistic Updates

// lib/actions/user.ts
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/lib/db/prisma";
import { getCurrentUser } from "@/lib/auth/session";

const UpdateProfileSchema = z.object({
  name: z.string().min(2).max(100),
  bio: z.string().max(500).optional(),
  website: z.string().url().optional().or(z.literal("")),
});

export type UpdateProfileState = {
  errors?: {
    name?: string[];
    bio?: string[];
    website?: string[];
  };
  message?: string;
  success?: boolean;
};

export async function updateProfile(
  prevState: UpdateProfileState,
  formData: FormData
): Promise {
  const user = await getCurrentUser();
  if (!user) redirect("/login");

  const validatedFields = UpdateProfileSchema.safeParse({
    name: formData.get("name"),
    bio: formData.get("bio"),
    website: formData.get("website"),
  });

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: "Validation failed. Please check the fields below.",
    };
  }

  try {
    await db.user.update({
      where: { id: user.id },
      data: validatedFields.data,
    });

    // Invalidate the cached profile page so the update is reflected immediately
    revalidatePath("/settings/profile");

    return { success: true, message: "Profile updated successfully." };
  } catch (error) {
    return {
      message: "Database error. Your profile could not be updated.",
    };
  }
}
// components/features/settings/ProfileForm.tsx
"use client";

import { useActionState } from "react";
import { useOptimistic } from "react";
import { updateProfile, type UpdateProfileState } from "@/lib/actions/user";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";

const initialState: UpdateProfileState = {};

interface ProfileFormProps {
  user: { id: string; name: string; bio?: string; website?: string };
}

export function ProfileForm({ user }: ProfileFormProps) {
  const [state, formAction, isPending] = useActionState(
    updateProfile,
    initialState
  );

  const [optimisticUser, setOptimisticUser] = useOptimistic(
    user,
    (currentUser, newName: string) => ({ ...currentUser, name: newName })
  );

  return (
    
setOptimisticUser(e.target.value)} /> {state.errors?.name && (

{state.errors.name[0]}

)} {state.message && (

{state.message}

)}
); }

Note several things about this pattern: validation runs on the server with Zod before any database call, the error state flows back to the form via useActionState (the React 19 replacement for useFormState), revalidatePath invalidates the Next.js cache so stale data is not served after mutation, and optimistic updates make the UI feel instant without any manual loading state management.

Where Server Actions Live in Your Structure

Keep all Server Action files in /lib/actions/ organised by domain. Name files after the resource they mutate: auth.ts, user.ts, billing.ts, posts.ts. Each file starts with "use server" at the top β€” this marks every exported function in the file as a Server Action automatically, so you do not need to add the directive to each function individually.

The /agents Directory: Structuring AI Integration in Next.js 15

This is the section that no other Next.js structure guide covers in 2026, yet it is the most important architectural decision for any application adding AI capabilities. At Groovy Web, roughly 70% of new client projects now require some form of LLM integration β€” whether that's a chat interface, a document processing pipeline, an AI-assisted form, or a full autonomous agent workflow.

Without a clear structure for AI code, teams end up scattering LLM API calls across route handlers, components, and utility files β€” a maintenance nightmare when prompt versions change, models get upgraded, or you need to add observability.

The /agents Directory Structure in Detail

agents/
β”œβ”€β”€ prompts/                      ← System prompts as typed TypeScript modules
β”‚   β”œβ”€β”€ base.ts                   ← Shared prompt components (company context, tone)
β”‚   β”œβ”€β”€ support.ts                ← Customer support agent system prompt
β”‚   β”œβ”€β”€ onboarding.ts             ← User onboarding assistant prompt
β”‚   └── classifier.ts             ← Document/ticket classification prompt
β”œβ”€β”€ tools/                        ← Agent tool definitions for function calling
β”‚   β”œβ”€β”€ index.ts                  ← Tool registry
β”‚   β”œβ”€β”€ search.ts                 ← Web/internal search tool
β”‚   β”œβ”€β”€ database.ts               ← Database query tool (read-only for agents)
β”‚   └── email.ts                  ← Email sending tool
└── workflows/                    ← Orchestrated multi-step agent workflows
    β”œβ”€β”€ support-triage.ts         ← Classify β†’ Route β†’ Draft response
    └── content-review.ts         ← Fetch β†’ Analyse β†’ Summarise β†’ Store

Prompt Management: Typed, Versioned, Testable

Never hardcode system prompts as string literals inside route handlers. Prompts are code β€” they need version control, testing, and the ability to be composed from shared fragments.

// agents/prompts/base.ts
export const COMPANY_CONTEXT = `
You are an AI assistant for Acme Corp, a B2B SaaS platform for inventory management.
Always be professional, concise, and solution-oriented.
If you are unsure, say so clearly and escalate to a human agent.
Never make up product features or pricing information.
`.trim();

export const RESPONSE_FORMAT = `
Respond in plain text only. No markdown. No bullet points unless explicitly requested.
Keep responses under 150 words unless the question requires detailed explanation.
`.trim();

// agents/prompts/support.ts
import { COMPANY_CONTEXT, RESPONSE_FORMAT } from "./base";

export const SUPPORT_SYSTEM_PROMPT = `
${COMPANY_CONTEXT}

You are handling customer support inquiries. Your goals:
1. Understand the customer''s problem clearly
2. Check if it is a known issue with a documented solution
3. Provide step-by-step resolution when possible
4. Escalate to a human agent for billing issues, data loss, or enterprise accounts

${RESPONSE_FORMAT}
`.trim();

export type SupportPromptVersion = "v1.2";
export const CURRENT_VERSION: SupportPromptVersion = "v1.2";

Tool Definitions: Function Calling with Type Safety

// agents/tools/database.ts
import { tool } from "ai"; // Vercel AI SDK v4
import { z } from "zod";
import { db } from "@/lib/db/prisma";

export const getOrderStatusTool = tool({
  description:
    "Look up the current status of a customer order by order ID or email address.",
  parameters: z.object({
    orderId: z.string().optional().describe("The order ID to look up"),
    email: z
      .string()
      .email()
      .optional()
      .describe("Customer email to find recent orders"),
  }),
  execute: async ({ orderId, email }) => {
    if (!orderId && !email) {
      return { error: "Provide either an order ID or customer email" };
    }

    const order = await db.order.findFirst({
      where: orderId ? { id: orderId } : { customer: { email } },
      select: {
        id: true,
        status: true,
        createdAt: true,
        estimatedDelivery: true,
        items: { select: { name: true, quantity: true } },
      },
    });

    if (!order) return { error: "Order not found" };

    return {
      orderId: order.id,
      status: order.status,
      createdAt: order.createdAt.toISOString(),
      estimatedDelivery: order.estimatedDelivery?.toISOString(),
      items: order.items,
    };
  },
});

Streaming Responses: The Route Handler Pattern

// app/api/ai/stream/route.ts
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { SUPPORT_SYSTEM_PROMPT } from "@/agents/prompts/support";
import { getOrderStatusTool } from "@/agents/tools/database";
import { getCurrentUser } from "@/lib/auth/session";

export const runtime = "edge"; // Run on the Edge for lower latency

export async function POST(req: Request) {
  const user = await getCurrentUser();
  if (!user) return new Response("Unauthorized", { status: 401 });

  const { messages } = await req.json();

  const result = streamText({
    model: anthropic("claude-sonnet-4-6"),
    system: SUPPORT_SYSTEM_PROMPT,
    messages,
    tools: {
      getOrderStatus: getOrderStatusTool,
    },
    maxSteps: 5, // Allow multi-step tool use
    onFinish({ usage, finishReason }) {
      // Log token usage for cost tracking β€” use your preferred observability tool
      console.log("Tokens:", usage.totalTokens, "Reason:", finishReason);
    },
  });

  return result.toDataStreamResponse();
}

Multi-Step Workflows: Orchestration Without a Framework

Not every AI integration needs LangChain or LangGraph. For linear workflows with 3-7 steps, a plain TypeScript function with typed inputs/outputs is cleaner, easier to test, and faster to debug.

// agents/workflows/support-triage.ts
import { generateObject } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
import { db } from "@/lib/db/prisma";

const TriageResultSchema = z.object({
  category: z.enum(["billing", "technical", "general", "escalate"]),
  priority: z.enum(["low", "medium", "high", "urgent"]),
  suggestedResponse: z.string(),
  requiresHuman: z.boolean(),
  confidence: z.number().min(0).max(1),
});

export type TriageResult = z.infer;

export async function triageSupportTicket(ticketId: string): Promise {
  // Step 1: Fetch ticket from database
  const ticket = await db.supportTicket.findUniqueOrThrow({
    where: { id: ticketId },
    include: { customer: true, previousTickets: { take: 5 } },
  });

  // Step 2: Classify and generate initial response using structured output
  const { object: triage } = await generateObject({
    model: anthropic("claude-sonnet-4-6"),
    schema: TriageResultSchema,
    prompt: `
Analyse this customer support ticket and provide a structured triage response.

Customer: ${ticket.customer.name} (${ticket.customer.plan} plan)
Previous tickets: ${ticket.previousTickets.length}
Subject: ${ticket.subject}
Body: ${ticket.body}

Categorise the issue, assess priority, draft an initial response, and determine if human escalation is needed.
    `.trim(),
  });

  // Step 3: Store triage result and update ticket
  await db.supportTicket.update({
    where: { id: ticketId },
    data: {
      category: triage.category,
      priority: triage.priority,
      aiSuggestedResponse: triage.suggestedResponse,
      triageConfidence: triage.confidence,
      status: triage.requiresHuman ? "awaiting_human" : "ai_handled",
    },
  });

  return triage;
}

This workflow is a plain async function. You can unit test it with mocked DB calls, you can call it from a Server Action or a route handler, and you can add observability by wrapping the generateObject call with your preferred tracing library (Langfuse, Braintrust, or LangSmith all work here).

Package Versions (February 2026): Use ai@4.x (Vercel AI SDK), @ai-sdk/anthropic@1.x, next@15.x, @prisma/client@6.x. These are the versions Groovy Web's AI Agent Teams use across production projects.

Data Fetching Patterns for Production

Next.js 15 gives you four primary data fetching patterns. Understanding when to use each is the difference between a fast application and one that ships unnecessary JavaScript and over-fetches data.

Pattern 1: Server Component Async Fetch (Preferred for Static and Dynamic Data)

// app/(dashboard)/dashboard/page.tsx
import { db } from "@/lib/db/prisma";
import { getCurrentUser } from "@/lib/auth/session";
import { cache } from "react";

// React cache() deduplicates this call if multiple Server Components request it
const getUser = cache(async (id: string) => {
  return db.user.findUniqueOrThrow({ where: { id } });
});

export default async function DashboardPage() {
  const session = await getCurrentUser();
  const [user, recentOrders] = await Promise.all([
    getUser(session.id),
    db.order.findMany({
      where: { userId: session.id },
      orderBy: { createdAt: "desc" },
      take: 10,
    }),
  ]);

  return ;
}

// Force dynamic rendering for authenticated pages
export const dynamic = "force-dynamic";

Pattern 2: Prisma with Server Components β€” The Correct Singleton Pattern

// lib/db/prisma.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const db =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"],
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

This singleton pattern prevents Prisma from opening hundreds of database connections during hot module replacement in development β€” a common issue that causes "too many connections" errors and misleads developers into thinking they have a production database problem when it is actually a development configuration issue.

Pattern 3: React Query for Client-Side Data That Needs Real-Time Updates

// hooks/useOrderStatus.ts
"use client";
// Note: this file is implicitly client-only because it uses TanStack Query

import { useQuery } from "@tanstack/react-query";

export function useOrderStatus(orderId: string) {
  return useQuery({
    queryKey: ["order", orderId],
    queryFn: async () => {
      const res = await fetch(`/api/orders/${orderId}/status`);
      if (!res.ok) throw new Error("Failed to fetch order status");
      return res.json();
    },
    refetchInterval: 30_000, // Poll every 30 seconds for status updates
    staleTime: 10_000,
  });
}

Use React Query (@tanstack/react-query@5) only for data that genuinely needs client-side polling, real-time updates, or complex cache invalidation logic across multiple components. Do not use it as a replacement for Server Component fetching β€” that is the wrong mental model in App Router.

TypeScript Configuration for Production Next.js

TypeScript strict mode is not optional in production Next.js 15 projects at Groovy Web. Every project ships with the following configuration. Deviating from it leads to runtime errors that TypeScript would have caught at compile time.

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": false,
    "skipLibCheck": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "incremental": true,
    "jsx": "preserve",
    "plugins": [{ "name": "next" }],
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./src/components/*"],
      "@/lib/*": ["./src/lib/*"],
      "@/hooks/*": ["./src/hooks/*"],
      "@/stores/*": ["./src/stores/*"],
      "@/types/*": ["./src/types/*"],
      "@/agents/*": ["./src/agents/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

Key decisions in this config: moduleResolution: "bundler" is the correct setting for Next.js 15 with Turbopack β€” do not use node or node16. noUncheckedIndexedAccess: true forces you to handle the case where array/object access returns undefined, which catches a class of runtime errors that strict: true alone misses. The @/* path aliases map to your source directory and are referenced in next.config.ts via the built-in Next.js alias support.

Want This Structure Implemented in Your Project?

Groovy Web's AI Agent Teams set up production-ready Next.js 15 projects from scratch β€” correct folder structure, TypeScript configuration, Server Actions, Prisma integration, and AI agent scaffolding β€” in under a week. We've done it 200+ times.

Starting at $22/hr. Production-ready in weeks, not months.

Start Your Next.js 15 Project with Groovy Web

⬇

Free Next.js 15 Project Starter Template

The exact folder structure, tsconfig.json, next.config.ts, and Prisma setup that Groovy Web uses across 200+ production projects. Ready to clone and build on immediately.

No spam. Unsubscribe any time. Used by 1,200+ developers.

Common Folder Structure Mistakes (and How to Fix Them)

Mistake 1: Keeping the /pages Directory Alongside /app

Next.js supports both routers simultaneously for incremental migration, but teams often leave old /pages routes in place indefinitely. This creates a split-brain codebase where half your routes use one data fetching model and half use another. Fix: migrate all routes to App Router and delete /pages entirely once complete. If you need the Pages Router for a specific package that does not support App Router yet, isolate it to a single route file and document it explicitly.

# BEFORE β€” mixed routing (antipattern)
pages/
  index.tsx          ← Still using getServerSideProps
  about.tsx
app/
  dashboard/
    page.tsx         ← App Router

# AFTER β€” clean App Router only
app/
  page.tsx           ← Migrated
  about/
    page.tsx         ← Migrated
  dashboard/
    page.tsx

Mistake 2: Dumping Everything in /components

A flat /components directory with 80 files is unnavigable. When a component is named UserCard.tsx it is impossible to know from the name whether it is a primitive UI element, a feature component, a layout wrapper, or something domain-specific. The solution is the three-tier split: /components/ui, /components/features, /components/layouts.

# BEFORE β€” flat chaos
components/
  Button.tsx
  UserCard.tsx
  DashboardLayout.tsx
  LoginForm.tsx
  PricingTable.tsx
  Modal.tsx
  Header.tsx

# AFTER β€” organised by purpose
components/
  ui/
    Button.tsx
    Modal.tsx
  features/
    auth/
      LoginForm.tsx
    billing/
      PricingTable.tsx
    users/
      UserCard.tsx
  layouts/
    DashboardLayout.tsx
    Header.tsx

Mistake 3: Putting Database Calls Directly in Page Files

Even though Server Components allow database calls anywhere, embedding Prisma queries directly in page.tsx files creates untestable, non-reusable code. Extract all database interactions to /lib/db/queries/.

// BEFORE β€” untestable, coupled, not reusable
// app/users/page.tsx
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient(); // New client on every request!
export default async function UsersPage() {
  const users = await prisma.user.findMany();
  return ;
}

// AFTER β€” uses singleton, testable, reusable
// lib/db/queries/users.ts
import { db } from "@/lib/db/prisma";
export async function getUsers() {
  return db.user.findMany({ orderBy: { createdAt: "desc" } });
}

// app/users/page.tsx
import { getUsers } from "@/lib/db/queries/users";
export default async function UsersPage() {
  const users = await getUsers();
  return ;
}

Mistake 4: Scattering AI/LLM Calls Across Route Handlers

Teams without an /agents directory end up with Anthropic API calls in five different route handlers, each with slightly different system prompts, none of them shared, all of them untested. When a prompt needs updating or a model needs to be swapped, the change has to happen in five places. The fix: centralise all LLM interactions in /agents/ and call into that layer from route handlers and Server Actions.

Mistake 5: Using /stores (Zustand/Jotai) for Server State

Zustand and Jotai are excellent for UI state β€” modal open/closed, selected tab, user preferences. They are the wrong tool for server state β€” data that lives in a database. Mixing the two creates stale data bugs where the Zustand store holds an outdated version of data that was updated via a Server Action. Use React Query or SWR for server state. Use Zustand only for truly client-side UI state.

Frequently Asked Questions

Should I use App Router or Pages Router for a new project in 2026?

App Router, without exception. The Next.js team has stated clearly that Pages Router is in maintenance mode only β€” no new features will be added. App Router is where React Server Components, Server Actions, streaming, and Suspense-based loading states live. Starting a new project on Pages Router in 2026 means starting with legacy architecture. The only valid reason to use Pages Router is if you are maintaining an existing codebase that cannot yet afford the migration investment.

How do I handle authentication in Next.js 15?

The current production recommendation is next-auth@5 (Auth.js), which has been rebuilt for App Router with native support for Server Components and Route Handlers. Configure it in /lib/auth/options.ts, wrap your authenticated routes with a middleware check in middleware.ts, and use the auth() helper in Server Components to get the current session without a useEffect or API call. For enterprise projects, Clerk is an alternative that offloads the entire auth surface β€” its Next.js SDK integrates cleanly with App Router.

Where do I put environment variables?

Environment variables in Next.js 15 follow a strict exposure model: variables prefixed with NEXT_PUBLIC_ are bundled into client-side JavaScript and visible in the browser. All other variables are server-only and never sent to the client. Store database URLs, API keys, and secrets in .env.local (gitignored), .env.production (gitignored, deployed via CI secrets), and document what is needed in .env.example (committed). Never prefix secrets with NEXT_PUBLIC_. Validate your environment variables at startup using a library like @t3-oss/env-nextjs, which uses Zod to validate all env vars before the app starts.

How do I structure a monorepo with Next.js?

The standard approach in 2026 is Turborepo with the following package structure: apps/web (your Next.js app), apps/api (separate API service if needed), packages/ui (shared component library), packages/db (shared Prisma schema and client), packages/types (shared TypeScript types), packages/config (shared tsconfig, ESLint, Tailwind config). The packages/db approach is particularly powerful β€” it lets you share your Prisma client and query functions between your Next.js app and any background workers or API services without duplicating the schema.

What ORM works best with Next.js 15?

Prisma remains the most popular choice with excellent TypeScript integration, a clean API, and good Next.js documentation. Use @prisma/client@6.x. Drizzle ORM is the rising alternative β€” it is faster, has a smaller bundle size, and its schema-as-code approach (TypeScript rather than Prisma's DSL) appeals to developers who want full TypeScript end-to-end. Groovy Web uses Prisma for projects that prioritise developer velocity and Drizzle for projects with strict performance budgets or edge deployments where Prisma's binary size is a constraint.

How do I add AI features to an existing Next.js app?

Start by adding the /agents directory to your existing structure without touching any existing code. Install ai@4 and your chosen model SDK (@ai-sdk/anthropic or @ai-sdk/openai). Create your first prompt in /agents/prompts/, add a streaming route handler at /app/api/ai/stream/route.ts, and test the integration in isolation. Only after the /agents layer is solid should you wire it into your existing Server Actions or Client Components. This incremental approach avoids the "AI rewrite" trap where adding AI features requires restructuring half the codebase.

Frequently Asked Questions

What is the recommended folder structure for a Next.js 15 App Router project?

The recommended structure places all routing inside the app/ directory, with separate folders for components, lib (utilities and data access), hooks, types, and public assets. Server components live directly in the app/ directory, while reusable UI components go in components/. Data fetching logic belongs in lib/data or lib/actions, and API route handlers in app/api/. This separation makes server and client boundaries explicit and keeps the codebase navigable as it grows.

What is the difference between Server Components and Client Components in Next.js 15?

Server Components render on the server and can directly access databases, file systems, and server-only secrets without sending any code to the client. Client Components are marked with the 'use client' directive and run in the browser, enabling interactivity, useState, and browser APIs. The default in the App Router is Server Components β€” you opt into the client only when needed for interactivity. This model reduces the JavaScript sent to the browser and improves performance.

When should I use Server Actions in Next.js?

Server Actions are the recommended pattern for form submissions, data mutations, and any operation that needs to run securely on the server without a separate API route. They colocate the mutation logic with the component that triggers it, eliminating the need to define and fetch a dedicated API endpoint for simple data changes. Use Server Actions for create, update, and delete operations β€” and API routes for public-facing endpoints that need to be consumed by external clients.

How do I manage environment variables securely in Next.js?

Variables prefixed with NEXT_PUBLIC_ are embedded in the client bundle and visible in the browser β€” use these only for non-sensitive configuration like analytics IDs or public API URLs. All other variables (API keys, database credentials, secret tokens) should be stored without the NEXT_PUBLIC_ prefix and accessed only in Server Components, Server Actions, or API routes. Never import server-side environment variables in client components.

What TypeScript patterns work best in a large Next.js codebase?

Define shared types in a central types/ directory and import them across the codebase rather than duplicating type definitions. Use Zod for runtime validation of form inputs and API responses, and colocate the Zod schema with the type definition it validates. Enable strict mode in tsconfig.json from day one β€” retrofitting strict TypeScript into a large codebase is significantly more costly than building with it from the start.

How does Next.js 15 handle caching differently from previous versions?

Next.js 15 changed the default caching behaviour significantly: fetch requests and route segments are no longer cached by default, shifting toward opt-in caching instead of opt-out. Developers now explicitly set cache options using the next.revalidate option or the unstable_cache utility for data that can be stale. This change makes caching behaviour more predictable and eliminates a common source of production bugs caused by unintentional data staleness.


Need a Next.js 15 Expert Team?

Groovy Web's engineers have shipped 200+ production Next.js applications. Starting at $22/hr, our AI Agent Teams deliver full-stack Next.js projects 10-20X faster than traditional agencies.

Start Your Next.js Project β†’


Related Services


Published: February 2026 | Author: Groovy Web Team | Category: Web App Development

Ship 10-20X Faster with AI Agent Teams

Our AI-First engineering approach delivers production-ready applications in weeks, not months. Starting at $22/hr.

Get Free Consultation

Was this article helpful?

Groovy Web Team

Written by Groovy Web Team

Groovy Web is an AI-First development agency specializing in building production-grade AI applications, multi-agent systems, and enterprise solutions. We've helped 200+ clients achieve 10-20X development velocity using AI Agent Teams.

Ready to Build Your App?

Get a free consultation and see how AI-First development can accelerate your project.

1-week free trial No long-term contract Start in 1-2 weeks
Get Free Consultation
Start a Project

Got an Idea?
Let's Build It Together

Tell us about your project and we'll get back to you within 24 hours with a game plan.

Response Time

Within 24 hours

247+ Projects Delivered
10+ Years Experience
3 Global Offices

Follow Us

Only 3 slots available this month

Hire AI-First Engineers
10-20Γ— Faster Development

For startups & product teams

One engineer replaces an entire team. Full-stack development, AI orchestration, and production-grade delivery β€” starting at just $22/hour.

Helped 8+ startups save $200K+ in 60 days

10-20Γ— faster delivery
Save 70-90% on costs
Start in 1-2 weeks

No long-term commitment Β· Flexible pricing Β· Cancel anytime