DEV Community

Cover image for Improving Data Fetching in Next.js: Lessons from Moving Beyond useEffect
Chao Fang for Subito

Posted on with Alessandro Grosselle

Improving Data Fetching in Next.js: Lessons from Moving Beyond useEffect

At Subito (Italy's leading classifieds platform), we never paid much attention to how we made API calls in our web frontend microsites, all built on Next.js. We had a simple library based on Axios that handled everything: making requests, modeling data, and managing errors.

On the server side, it was a simple call; on the client side, we used useEffect with state management for data and errors. It worked, until we started asking: is this still the right approach?

The Wake-Up Call: The Cloudflare Incident

The turning point was reading about the Cloudflare outage caused by excessive API calls triggered by useEffect.

It made us reflect on our own stack:

  • Are we over-fetching?
  • Are we accidentally triggering duplicate requests?
  • Could we overload our backend without realizing it?

The Mission

Our primary goal was simple: embrace the standard! We wanted to align perfectly with the official recommendations from React and Next.js.

Since our architecture relies on the Next.js App Router to consume REST APIs, we needed a clean, well-defined data-fetching strategy that clearly distinguishes between React Server Components and Client Components.

The Server Side: embracing the standard

For Server Components, the decision was surprisingly easy: Next.js extends the native fetch API, adding powerful caching and revalidation features out of the box.

What we did: we replaced all Axios calls in our Server Components with the native fetch API extended by Next.js.

This "simple" switch allowed us to remove Axios entirely from our code, a library that, while historic, adds ~13kb to the bundle and has had multiple security advisories over the years (Axios served us well for years and played a huge role in making HTTP requests easier across the JavaScript ecosystem; but it was time to move on).

The Client Side: the great debate (React Query vs. SWR)

While the server side was easy, the client side required a comparison between two libraries: TanStack Query (React Query) and SWR.

The React Query Experiment

We started with React Query: it has gained massive popularity in the React ecosystem, and for good reason!
It's incredibly powerful, a true "server state" manager; we had to try it!

What we found: for our use case it felt like overkill: it requires a QueryClient, a Provider, and careful queryKey management.
The learning curve was steeper than expected, and integrating it into our existing microsites wasn't trivial. The mental shift and boilerplate needed slowed down adoption.
On top of that, we don't really need mutations or cache invalidation on the client since our caching is handled server-side by our backend REST APIs.

The SWR "Toy" that Could

Then we tried SWR (Stale-While-Revalidate) by Vercel.
One of our colleagues jokingly called it a "giocattolino" ("a little toy" in italian) because of how simple it is. But for our use case, it was perfect!

  • Zero Boilerplate: No mandatory providers. Just a hook: useSWR(key, fetcher)
  • Low Learning Curve: It felt like a natural extension of the platform
  • Client-only by design: SWR is focused solely on Client Components, exactly what we needed, since we already solved server-side fetching with native fetch

The Migration

The move from useEffect to SWR felt like cleaning a cluttered room. Here's a real example from our codebase:

Before (useEffect):

const [items, setItems] = useState<Array<AdItem>>([]);
const [isLoading, setIsLoading] = useState(true);

// load the Ads
useEffect(() => {
  getRecommendedItems(vertical)
    .then(setItems)
    .finally(() => setIsLoading(false));
}, [vertical]);
Enter fullscreen mode Exit fullscreen mode

After (SWR):

const { data: items = [], isLoading } = useSWR(
  SWR_KEYS.recommender(vertical),
  fetchRecommendedItems
);
Enter fullscreen mode Exit fullscreen mode

We deleted two useState declarations and replaced a bulky useEffect block with a single, declarative hook.

Centralized Key Management

To avoid "magic strings" and ensure consistent caching, we centralized our SWR keys:

export const SWR_KEYS = {
  recommender: (vertical: string) => ['recommender', 'items', vertical] as const,
  user: (userId: string) => ['user', userId] as const,
};
Enter fullscreen mode Exit fullscreen mode

Simple, type-safe, and predictable.

Testing Without the Headache

Since SWR caches globally, we developed a utility to ensure our unit tests stay isolated:

import { render } from '@testing-library/react';
import { SWRConfig } from 'swr';

export const renderWithSWR = (ui: React.ReactElement) => {
  return render(
    <SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
      {ui}
    </SWRConfig>
  );
};

// Usage: renderWithSWR(<RecommenderWidget />);
Enter fullscreen mode Exit fullscreen mode

Conclusion

By moving away from manual useEffect fetching and adopting the SWR + Native Fetch combo, we've achieved cleaner code and a much more React-standard way to handle data fetching.

Sometimes the simplest tool is the most effective one. Our use case was clear: few mutations on the client side, no shared cache needed between server and client, and no client-side caching required since we're a pure frontend team calling REST services that handle their own caching.

For this scenario, SWR turned out to be the most immediate, simple, and fitting solution. No over-engineering, just the right tool for the job.

The big takeaway: don't just follow the hype. React Query is an amazing library, if you need complex cache invalidation, optimistic updates, or tight server-client state synchronization, it's probably the right choice. For us, it wasn't. Understand your actual requirements first, then pick the tool that fits, not the other way around.

Top comments (0)