logo
Back to Blog
Next.js 15/16 Data Fetching: Mistakes & Security Issues
frontenddevelopmentnextjssecurity

Next.js 15/16 Data Fetching: Mistakes & Security Issues

Learn about common Next.js data fetching mistakes and security vulnerabilities with solutions for Next.js 15/16.

The Typical Vulnerability

Fetching data in Next.js 15/16 feels pretty straightforward at first. Just use async/await in a React Server Component and you're good to go, right?

Unfortunately, NO. Many developers unknowingly create security vulnerabilities and performance issues. Here's what you need to know.

The Problem

Using Server Actions for Data Fetching (Wrong!)

Let's start with a common but problematic pattern. Here's a simple todo app where the data is fetched incorrectly:

// page.tsx (Client Component)
'use client';

import { useEffect, useState } from 'react';
import { getTodos } from './actions';

export default function Home() {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    getTodos().then(setTodos);
  }, []);

  // Rest of component...
}
// actions.ts
'use server';

export async function getTodos() {
  const todos = await prisma.todo.findMany();
  return todos;
}

This creates several critical problems:

Problem 1: POST Requests for Data Fetching

When you use Server Actions to fetch data, Next.js creates POST requests instead of GET requests.

Open your browser DevTools Network tab and you'll see POST requests for data fetching. This is wrong because:

  • POST requests don't support HTTP caching
  • They can't be parallelized properly
  • They bypass authentication in unexpected ways

Problem 2: No Parallelism

POST requests run sequentially, not in parallel. If your data fetch takes 15 seconds and you try to create a new todo, the creation waits for the fetch to complete.

What happens:

  • Request 1 (fetch): 15 seconds
  • Request 2 (create): Waits for Request 1, then executes
  • Total time: 30+ seconds

What we want:

  • Request 1 (fetch): 15 seconds
  • Request 2 (create): Runs in parallel
  • Total time: ~15 seconds

Solution 1: Server Components with Suspense

The correct approach is using Server Components for data fetching. This is the Next.js 15/16 way:

// app/page.tsx (Server Component)
import { Suspense } from 'react';
import TodoForm from './components/todo-form';
import TodoList from './components/todo-list';

export default function Home() {
  return (
    <div>
      <TodoForm />
      <Suspense fallback={<div>Loading todos...</div>}>
        <TodoList />
      </Suspense>
    </div>
  );
}
// components/todo-form.tsx (Client Component)
'use client';

import { createTodo } from '@/app/actions';

export default function TodoForm() {
  async function handleSubmit(formData: FormData) {
    await createTodo(formData);
  }

  return <form action={handleSubmit}>{/* Form fields */}</form>;
}
// components/todo-list.tsx (Server Component)
import prisma from '@/lib/prisma';

async function getTodos() {
  return await prisma.todo.findMany();
}

export default async function TodoList() {
  const todos = await getTodos();

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <h3>{todo.title}</h3>
          <p>{todo.content}</p>
        </li>
      ))}
    </ul>
  );
}

Benefits:

  • Uses GET requests (implicit in Server Components)
  • Enables parallel request execution
  • Supports streaming with Suspense
  • Works with Next.js caching

The Security Problem: Missing Session Verification

Now let's add authentication. A common mistake is verifying sessions only at the page level:

// app/page.tsx
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';

export default async function Home() {
  const session = await auth();

  if (!session?.user) {
    redirect('/login');
  }

  return <div>{/* Component JSX */}</div>;
}

This seems secure, but it creates a critical vulnerability!

What happens when you extract the TodoList component for reuse? If another developer creates a new route and imports your component:

// app/test/page.tsx
import TodoList from '@/components/todo-list';

export default function TestPage() {
  return <TodoList />; // No session verification!
}

Visiting /test in an incognito browser will display todos without authentication. The session check only exists at the page level, not where data is fetched.

Solution 2: Data Access Layer (DAL)

The Next.js 15 documentation recommends creating a Data Access Layer to centralize data requests and authorization.

Here's the implementation:

// app/data/user/require-user.ts
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { cache } from 'react';
import 'server-only';

export const requireUser = cache(async () => {
  const session = await auth();

  if (!session?.user) {
    redirect('/login');
  }

  return session.user;
});
// app/data/todo/get-todos.ts
import prisma from '@/lib/prisma';
import 'server-only';
import { requireUser } from '../user/require-user';

export async function getTodos() {
  await requireUser(); // Verify session before fetching

  return await prisma.todo.findMany();
}
// components/todo-list.tsx
import { getTodos } from '@/app/data/todo/get-todos';

export default async function TodoList() {
  const todos = await getTodos();

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <h3>{todo.title}</h3>
        </li>
      ))}
    </ul>
  );
}

Now even if someone reuses TodoList without authentication, the data layer will redirect them to login.

Benefits of Data Access Layer

1. Single Source of Truth All data logic centralized in one place. Easy to update and debug.

2. Security by Default Session verification happens where data is fetched, preventing accidental data leaks.

3. Organized Structure Create folders for different data types:

app/data/
├── user/
   ├── require-user.ts
   ├── get-user.ts
   └── get-profile.ts
└── todo/
    ├── get-todos.ts
    ├── get-todo-by-id.ts
    └── create-todo.ts

Performance: React Cache

Notice the cache() function wrapping requireUser. This is crucial for performance in Next.js 15/16.

Imagine a dashboard with 10 data sources. Without caching, requireUser() would run 10 times. With cache(), it runs once per render.

import { cache } from 'react';

export const requireUser = cache(async () => {
  // Runs only once per server render
  const session = await auth();
  return session.user;
});

The cache is scoped to a single server render and doesn't persist between navigations - perfect for authentication checks!

Security: Server-Only Package

Add 'server-only' to prevent accidental client-side usage:

import 'server-only';

// This ensures the function ONLY runs on the server
// and throws a build-time error if imported in client components

Difference from 'use server':

  • 'use server' - Creates Server Actions callable from client
  • 'server-only' - Ensures code only runs on server, throws errors if used in client

Summary: Next.js 15/16 Best Practices

  1. Use Server Components for data fetching (not Server Actions)
  2. Implement Data Access Layer to centralize logic and security
  3. Verify Sessions at Data Layer not just page level
  4. Use React cache() to optimize authentication checks
  5. Add 'server-only' imports to prevent client-side usage
  6. Use Suspense Boundaries for streaming and better UX

Follow these patterns and you'll build secure, performant Next.js applications!

Follow me on X: @shishir_ahm3d

Related Posts

Next.js 15/16 Routing Guide 2026

Next.js 15/16 Routing Guide 2026

Learn modern routing in Next.js 15 and 16 with App Router, dynamic routes, and essential routing patterns for 2026.

frontenddevelopmentnextjs+3 more
Read More
TypeScript for Frontend Development: A Beginner's Guide

TypeScript for Frontend Development: A Beginner's Guide

A guide to TypeScript for frontend development for beginners part 1

frontenddevelopmenttypescript
Read More