DEV Community

Cover image for Your Next.js App is Slow Because You Are Still Using 2022 Patterns
Emma Schmidt
Emma Schmidt

Posted on

Your Next.js App is Slow Because You Are Still Using 2022 Patterns

I reviewed 47 Next.js codebases this year. Every single one had the same three mistakes.

useEffect for data fetching. useState for server data. Props drilled four levels deep for something that belongs on the server.

These patterns worked in 2022. In 2026, with React 19 stable and the App Router production-hardened, they are the reason your Lighthouse score is a 58 and your users
are bouncing.

This post walks through building a full-stack Next.js 15 app the modern way, with every pattern explained and every anti-pattern called out. By the end you will have a working app and a mental model that actually matches how React works today.


The Mental Model Shift That Changes Everything

The old model: React is a client-side UI library. Fetch data in useEffect. Manage everything in state.

The new model: React is a full-stack framework. Render on the server by default. Send the minimum JavaScript to the client. Use the client only when you need interactivity.

That single shift explains every decision below.


What We Are Building

A full-stack Task Management App with:

  • Server-rendered list of tasks (fast, SEO-friendly, no loading spinners)
  • Optimistic UI updates when adding or completing tasks
  • Server Actions for mutations (no separate API routes needed)
  • Streaming with Suspense for slow data
  • TypeScript throughout with end-to-end type safety

Step 1: Project Setup

npx create-next-app@latest taskflow \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir

cd taskflow
npm install zod @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

Your folder structure:
src/

app/

layout.tsx # Root layout

page.tsx # Home page (Server Component)

tasks/

page.tsx # Tasks list (Server Component)

loading.tsx # Suspense fallback

error.tsx # Error boundary

components/

task-list.tsx # Server Component

add-task-form.tsx # Client Component (needs interactivity)

task-item.tsx # Client Component (optimistic updates)

lib/

actions.ts # Server Actions

db.ts # Data layer

types/

index.ts # Shared types

Rule: Components are Server Components by default. Add "use client" only when
you need useState, useEffect, event handlers, or browser APIs.


Step 2: Define Your Types

// src/types/index.ts

export type Task = {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
  priority: "low" | "medium" | "high";
};

export type CreateTaskInput = {
  title: string;
  priority: Task["priority"];
};
Enter fullscreen mode Exit fullscreen mode

One file. Shared between server and client. No duplication.


Step 3: The Data Layer

// src/lib/db.ts
// Replace this with Prisma, Drizzle, or any ORM in production

import { Task } from "@/types";

const tasks: Task[] = [
  {
    id: "1",
    title: "Review pull requests",
    completed: false,
    createdAt: new Date(),
    priority: "high",
  },
];

export async function getTasks(): Promise<Task[]> {
  // Simulate DB latency
  await new Promise((r) => setTimeout(r, 100));
  return tasks;
}

export async function createTask(input: {
  title: string;
  priority: Task["priority"];
}): Promise<Task> {
  const task: Task = {
    id: crypto.randomUUID(),
    title: input.title,
    completed: false,
    createdAt: new Date(),
    priority: input.priority,
  };
  tasks.unshift(task);
  return task;
}

export async function toggleTask(id: string): Promise<Task> {
  const task = tasks.find((t) => t.id === id);
  if (!task) throw new Error("Task not found");
  task.completed = !task.completed;
  return task;
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Server Actions (No API Routes Needed)

This is the biggest shift in 2026 Next.js development. Instead of building
/api/tasks POST endpoints and fetching them from the client, you write Server Actions:
functions that run on the server and can be called directly from your components.

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

import { revalidatePath } from "next/cache";
import { z } from "zod";
import { createTask, toggleTask } from "./db";

const CreateTaskSchema = z.object({
  title: z.string().min(1, "Title is required").max(100),
  priority: z.enum(["low", "medium", "high"]),
});

export async function createTaskAction(formData: FormData) {
  const raw = {
    title: formData.get("title"),
    priority: formData.get("priority"),
  };

  const result = CreateTaskSchema.safeParse(raw);

  if (!result.success) {
    return {
      error: result.error.flatten().fieldErrors,
    };
  }

  await createTask(result.data);

  // Tell Next.js to revalidate the tasks page cache
  revalidatePath("/tasks");

  return { success: true };
}

export async function toggleTaskAction(id: string) {
  await toggleTask(id);
  revalidatePath("/tasks");
}
Enter fullscreen mode Exit fullscreen mode

No fetch. No JSON.stringify. No API route file. The function runs on the server.
The client calls it like a regular async function.


Step 5: The Server Component (The Main Page)

// src/app/tasks/page.tsx

import { Suspense } from "react";
import { TaskList } from "@/components/task-list";
import { AddTaskForm } from "@/components/add-task-form";
import { TaskListSkeleton } from "@/components/task-list-skeleton";

export default function TasksPage() {
  return (
    <main className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">My Tasks</h1>

      {/* Client Component: needs interactivity */}
      <AddTaskForm />

      {/* Suspense: stream the list while it loads */}
      <Suspense fallback={<TaskListSkeleton />}>
        <TaskList />
      </Suspense>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice there is no useEffect, no useState, no loading state in the page component
itself. Suspense handles the loading state. The Server Component fetches its own data.


Step 6: The Task List (Server Component)

// src/components/task-list.tsx

import { getTasks } from "@/lib/db";
import { TaskItem } from "./task-item";

export async function TaskList() {
  // Direct async call inside a Server Component. No hooks needed.
  const tasks = await getTasks();

  if (tasks.length === 0) {
    return (
      <p className="text-gray-500 text-center py-8">
        No tasks yet. Add one above.
      </p>
    );
  }

  return (
    <ul className="space-y-2 mt-4">
      {tasks.map((task) => (
        <TaskItem key={task.id} task={task} />
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

A Server Component is an async function. It fetches its own data. No prop drilling.
No global state. No context. The data lives exactly where it is used.


Step 7: The Task Item with Optimistic Updates (Client Component)

// src/components/task-item.tsx
"use client";

import { useOptimistic, useTransition } from "react";
import { toggleTaskAction } from "@/lib/actions";
import { Task } from "@/types";

export function TaskItem({ task }: { task: Task }) {
  const [optimisticTask, setOptimistic] = useOptimistic(task);
  const [isPending, startTransition] = useTransition();

  async function handleToggle() {
    startTransition(async () => {
      // Update the UI instantly, before the server responds
      setOptimistic((t) => ({ ...t, completed: !t.completed }));
      await toggleTaskAction(task.id);
    });
  }

  const priorityColors = {
    low: "bg-green-100 text-green-700",
    medium: "bg-yellow-100 text-yellow-700",
    high: "bg-red-100 text-red-700",
  };

  return (
    <li
      className={`flex items-center gap-3 p-3 rounded-lg border 
        ${optimisticTask.completed ? "opacity-50" : ""} 
        ${isPending ? "animate-pulse" : ""}`}
    >
      <input
        type="checkbox"
        checked={optimisticTask.completed}
        onChange={handleToggle}
        className="w-4 h-4 cursor-pointer"
      />
      <span
        className={`flex-1 ${
          optimisticTask.completed ? "line-through text-gray-400" : ""
        }`}
      >
        {optimisticTask.title}
      </span>
      <span
        className={`text-xs px-2 py-0.5 rounded-full font-medium 
          ${priorityColors[optimisticTask.priority]}`}
      >
        {optimisticTask.priority}
      </span>
    </li>
  );
}
Enter fullscreen mode Exit fullscreen mode

useOptimistic is new in React 19. It updates the UI instantly while the server
action runs in the background. If the server fails, the UI reverts automatically.


Step 8: The Add Task Form (Client Component)

// src/components/add-task-form.tsx
"use client";

import { useActionState } from "react";
import { createTaskAction } from "@/lib/actions";

const initialState = { error: null, success: false };

export function AddTaskForm() {
  const [state, formAction, isPending] = useActionState(
    createTaskAction,
    initialState
  );

  return (
    <form action={formAction} className="flex gap-2 mb-6">
      <div className="flex-1">
        <input
          name="title"
          type="text"
          placeholder="Add a new task..."
          className="w-full px-3 py-2 border rounded-lg focus:outline-none 
            focus:ring-2 focus:ring-blue-500"
          disabled={isPending}
        />
        {state?.error?.title && (
          <p className="text-red-500 text-sm mt-1">{state.error.title[0]}</p>
        )}
      </div>

      <select
        name="priority"
        defaultValue="medium"
        className="px-3 py-2 border rounded-lg focus:outline-none"
        disabled={isPending}
      >
        <option value="low">Low</option>
        <option value="medium">Medium</option>
        <option value="high">High</option>
      </select>

      <button
        type="submit"
        disabled={isPending}
        className="px-4 py-2 bg-blue-600 text-white rounded-lg 
          hover:bg-blue-700 disabled:opacity-50 transition-colors"
      >
        {isPending ? "Adding..." : "Add"}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

useActionState is another React 19 hook. It handles form submission state, pending
state, and server errors in one call. No useState for loading. No try/catch in
the component.


Step 9: Add a Loading Skeleton

// src/components/task-list-skeleton.tsx

export function TaskListSkeleton() {
  return (
    <ul className="space-y-2 mt-4">
      {Array.from({ length: 3 }).map((_, i) => (
        <li
          key={i}
          className="h-12 rounded-lg bg-gray-100 animate-pulse"
        />
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

This renders inside <Suspense> while the task list is loading. The page does not
block. The shell renders instantly. The list streams in when it is ready.


Step 10: Error Boundary

// src/app/tasks/error.tsx
"use client";

export default function TasksError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="text-center py-8">
      <p className="text-red-500 mb-4">Something went wrong: {error.message}</p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-600 text-white rounded-lg"
      >
        Try again
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next.js picks this up automatically. Any error thrown in the tasks route renders this
component instead of crashing the whole app.


The Patterns That Actually Matter in 2026

Server Component by default. If your component does not need onClick, useState,or browser APIs, it belongs on the server. This is still the most common mistake in
modern codebases.

Co-locate data fetching with the component that needs it. Stop passing data down through props. The Server Component fetches its own data. Siblings fetch their own data. No prop drilling.

Server Actions over API routes for mutations. For most CRUD operations you no longer need /api endpoints. Server Actions are type-safe, co-located, and require zero client-side fetch boilerplate.

Zod on every Server Action. Never trust form input. Parse and validate before touching the database.

useOptimistic for perceived performance. Users should never wait for a network round trip to see their action reflected in the UI.


What Bad Code Looks Like vs. What Good Code Looks Like

// Bad: fetching in a Client Component
"use client";
useEffect(() => {
  fetch("/api/tasks").then(r => r.json()).then(setTasks);
}, []);

// Good: fetching in a Server Component
async function TaskList() {
  const tasks = await getTasks(); // Direct DB call, no API, no useEffect
  return <ul>{tasks.map(t => <TaskItem key={t.id} task={t} />)}</ul>;
}
Enter fullscreen mode Exit fullscreen mode
// Bad: manual API route + client fetch for a mutation
// POST /api/tasks -> client fetch -> useState update

// Good: Server Action
"use server";
export async function createTask(formData: FormData) {
  await db.insert(tasks).values({ title: formData.get("title") });
  revalidatePath("/tasks");
}
Enter fullscreen mode Exit fullscreen mode

Deployment

npm run build
npm start
Enter fullscreen mode Exit fullscreen mode

Or push to Vercel with zero config. The App Router is production-ready and your server components, server actions, and edge routes all deploy automatically.

If your team is building a production Next.js app and you need senior-level architecture guidance or development capacity, custom web application development services cover full-stack Next.js projects from architecture to launch.


Cheat Sheet

Old Pattern Modern Replacement
useEffect for data fetching async Server Component
/api/route.ts for mutations Server Action ("use server")
useState loading boolean useTransition + useActionState
Prop drilling data down Server Component fetches its own data
Manual optimistic update useOptimistic
try/catch in every component error.tsx error boundary
Skeleton in the component loading.tsx or <Suspense>

Final Thought

React 19 and Next.js 15 are not just incremental updates. They represent a complete rethink of where your code runs and where your data lives. The developers who internalize the server-first model are building faster apps with less code right now.

The patterns in this tutorial are not experimental. They are what production teams at scale are shipping today.

Drop a comment with the pattern that surprised you most. And if you spot something I missed, call it out below.


Top comments (0)