After watching students build applications with Next.js many times, I started to notice the same mistakes over and over again. These patterns often make applications slower, harder to maintain, and more difficult to understand.

Let us walk through a few of the most common ones.

Client Fetching

One of the most common mistakes is fetching data on the client side by default. This usually happens because many people come from plain React, where that pattern is very common. But Next.js offers better options, and we should take advantage of them.

Here is an example:

"use client"

import { useState } from "react"

export default function Page() {
	const [products, setProducts] = useState(null);

	async function getData(){
		const res = await fetch("/api/v1/products");
		const data = await res.json();
		setProducts(data);
	};

	useEffect(()=> {
		getData()
	},[]);

	return ...
};

This code fetches data through getData() inside useEffect, which means the fetching happens on the client. That approach has several drawbacks:

  • It is slower.
  • It is worse for SEO.
  • It does not take advantage of Next.js server features.

A better default is to fetch data on the server.

For example:

export default async function Page() {
	const res = await fetch(`${API_URL}/products`);
	const data = await res.json();

	// do something with data

	return ...
};

This version fetches data on the server, which gives us several benefits:

  • It is faster because the work happens on the server.
  • It is better for SEO because the client receives rendered HTML.
  • It makes proper use of Next.js server rendering features.

Fetching Too Much Data in a Single Page

Server-side fetching is great, but that does not mean everything should be fetched sequentially in one page component. If we do too much work there, the whole page can become slower to render.

Take this example:

import { getProducts, getUsers, getComments } from "@/libs"

export default async function Page(){
	const products = await getProducts();
	const users = await getUsers();
	const comments = await getComments();

	return ...
}

If this pattern is used too often, the page can become unnecessarily slow. A better approach is to lean on Next.js features such as streaming. We can do that by splitting the work into smaller components.

For example, we could create three components:

<ProductsComponent />
<UsersComponent />
<CommentsComponent />

Each component can fetch its own data, which makes rendering more flexible and allows Next.js to stream parts of the UI independently.

import { getProducts } from "@/libs"

export const ProductsComponent = async () => {
	const products = await getProducts();

	return ...
};
import { getUsers } from "@/libs"

export const UsersComponent = async () => {
	const users = await getProducts();

	return ...
} ;
import { getComments } from "@/libs"

export const CommentsComponent = async () => {
	const comments = await getProducts();

	return ...
};

Then we compose them into a single page:

import { ProductsComponent, UsersComponent, CommentsComponent } from "./";

export default async function Page() {
  return (
    <main>
      <ProductsComponent />
      <UsersComponent />
      <CommentsComponent />
    </main>
  );
}

We can also use React Suspense to improve the streaming experience:

import React from "react";
import { ProductsComponent, UsersComponent, CommentsComponent } from "./";

export default async function Page() {
  return (
    <main>
      <React.Suspense fallback={<p>Loading Products...</p>}>
        <ProductsComponent />
      </React.Suspense>
      <React.Suspense fallback={<p>Loading Users...</p>}>
        <UsersComponent />
      </React.Suspense>
      <React.Suspense fallback={<p>Loading Comments...</p>}>
        <CommentsComponent />
      </React.Suspense>
    </main>
  );
}

Not Using the Caching System

Caching is one of the easiest ways to improve performance. The basic idea is simple: instead of fetching the same data dynamically every time, we reuse previously fetched results when the data does not change often.

In practice, the flow looks like this:

  1. The app fetches data from the API.
  2. The result is cached.
  3. The next request checks the cache first.
  4. If the cached data is still valid, it is reused.
  5. If not, the app fetches fresh data and updates the cache.

How to Implement Caching

There are a few common ways to fetch data:

  • Using fetch()
  • Using third-party libraries

When we use fetch() in Next.js, caching is already part of the model.

export const revalidate = 60;

export default async function Page() {
  const res = await fetch(API_URL);
  const data = await res.json();
}

By adding revalidate, we tell Next.js how long cached data can stay fresh. The value is in seconds.

So with the example above, Next.js will revalidate the data at most every 60 seconds.

Fetching data with a third-party library

const getData = async () => {
  const data = await prisma.data.findMany();

  return data;
};

export default async function Page() {
  const data = await getData();
}
import { cache } from "react";

const getData = cache(async () => {
  const data = await prisma.data.findMany();

  return data;
});
import { getData } from "@/libs";

export const revalidate = 60;

export default async function Page() {
  const data = await getData();
}

Nice and simple.