Skip to main content

Command Palette

Search for a command to run...

Frontend Architecture: React Query and Next.js Server Actions

Updated
5 min readView as Markdown
Frontend Architecture: React Query and Next.js Server Actions
P
Hi, I'm Paresh! I'm a full-stack developer based in Ahmedabad, India, working remotely to build scalable web and mobile applications. My core technical stack includes Laravel, Flutter, PHP, JavaScript, PostgreSQL, and MySQL. I'm passionate about the entire product lifecycle—from architecture and coding to SEO and digital marketing. Currently, I'm focused on growing smarttechdevs.in and developing impactful, real-world products

The Gap in the App Router Caching System

The Next.js App Router introduced a revolutionary, multi-tiered caching system. Between the Data Cache, the Full Route Cache, and the Client Router Cache, Next.js handles server-side rendering and static generation flawlessly. However, as enterprise applications scale into highly interactive, dashboard-heavy Single Page Applications (SPAs), developers quickly discover a glaring gap in the Next.js architecture: Client-Side State Synchronization.

Imagine a complex project management board. User A opens the board in Tab 1 and Tab 2. In Tab 1, they rename a task. Next.js Server Actions can mutate the database and call revalidatePath(), which updates Tab 1. But Tab 2 remains completely stale. Furthermore, if the user loses network connection and regains it, Next.js has no native mechanism to aggressively refetch the data in the background to ensure the UI is fresh. For complex polling, background refetching, and sophisticated optimistic updates across deeply nested component trees, the native Next.js cache is simply not enough.

At Smart Tech Devs, we bridge this gap by architecting a hybrid data-fetching layer. We combine the raw backend power of Next.js Server Actions with the industry-leading client-side state synchronization of TanStack React Query.

The Hydration Architecture

The core challenge of using React Query in a Server-Side Rendered (SSR) environment like Next.js is "Hydration." We want the server to fetch the data first (for SEO and instant visual load), but we need React Query on the client to "take over" that data once the JavaScript loads, managing its freshness thereafter.

We architect this using the Hydration Boundary pattern. The server fetches the data and dehydrates it into a payload. The client receives the HTML and hydrates the React Query cache instantly.

Phase 1: Setting up the Provider

Because the Next.js App Router relies heavily on Server Components, we cannot put our React Query QueryClientProvider in the root layout.tsx directly without turning the entire application into a Client Component. Instead, we create a dedicated Client Component wrapper.


// app/providers/QueryProvider.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

export default function QueryProvider({ children }: { children: React.ReactNode }) {
    // We useState to ensure the QueryClient is only instantiated ONCE per user session.
    // If we put this outside the component, it would share the cache across different users 
    // on the server, causing a massive data leak.
    const [queryClient] = useState(() => new QueryClient({
        defaultOptions: {
            queries: {
                staleTime: 60 * 1000, // Data is considered fresh for 1 minute
                refetchOnWindowFocus: true, // Auto-sync when the user changes tabs
            },
        },
    }));

    return (
        <QueryClientProvider client={queryClient}>
            {children}
        </QueryClientProvider>
    );
}

Phase 2: Server-Side Prefetching

Now, let's architect a specific route. We will use a Server Component to prefetch the data, ensuring zero layout shift and instant rendering. We use a Server Action as our "fetcher" function.


// app/dashboard/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { getEnterpriseMetrics } from '@/app/actions/metricsActions';
import DashboardClientUI from './DashboardClientUI';

export default async function DashboardPage() {
    const queryClient = new QueryClient();

    // 1. Prefetch the data on the Server
    // We pass our secure Next.js Server Action directly into the queryFn!
    await queryClient.prefetchQuery({
        queryKey: ['metrics', 'enterprise'],
        queryFn: async () => await getEnterpriseMetrics(),
    });

    return (
        // 2. Dehydrate the cache state and pass it to the client boundary
        <HydrationBoundary state={dehydrate(queryClient)}>
            <DashboardClientUI />
        </HydrationBoundary>
    );
}

Phase 3: Client-Side Consumption and Mutations

Inside our Client Component, we consume the data using useQuery. Because the data was passed down through the Hydration Boundary, the initial render uses the cached server data instantly. From that moment forward, React Query manages background updates.

When we need to mutate the data, we use useMutation, completely replacing traditional REST API calls with our secure Next.js Server Actions.


// app/dashboard/DashboardClientUI.tsx
'use client';

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getEnterpriseMetrics, updateMetricGoal } from '@/app/actions/metricsActions';

export default function DashboardClientUI() {
    const queryClient = useQueryClient();

    // 1. Fetch data: Instant initial load, backed by auto-refreshing client logic
    const { data: metrics, isLoading } = useQuery({
        queryKey: ['metrics', 'enterprise'],
        queryFn: async () => await getEnterpriseMetrics(),
    });

    // 2. Mutate data: Wrapping a Server Action in React Query for cache invalidation
    const mutation = useMutation({
        mutationFn: async (newGoal: number) => {
            return await updateMetricGoal(newGoal); // Next.js Server Action
        },
        onSuccess: () => {
            // 3. Automatically invalidate the cache, forcing React Query to fetch fresh data
            // across all components instantly.
            queryClient.invalidateQueries({ queryKey: ['metrics'] });
        },
    });

    if (isLoading) return <div>Loading...</div>; // Never hit due to SSR Hydration!

    return (
        <div className="p-8 max-w-4xl mx-auto">
            <h2 className="text-3xl font-bold">Live Enterprise Metrics</h2>
            <p className="text-xl mt-4">Current Goal: {metrics?.goal}</p>
            
            <button 
                onClick={() => mutation.mutate(50000)}
                disabled={mutation.isPending}
                className="mt-6 px-4 py-2 bg-blue-600 text-white rounded"
            >
                {mutation.isPending ? 'Updating Database...' : 'Increase Goal to 50k'}
            </button>
        </div>
    );
}

The Engineering ROI

By architecting a hybrid data layer that unites Next.js Server Actions with React Query, you solve the most complex state management challenges in modern enterprise software. You completely eliminate standard REST API boilerplate (no more fetch wrappers or CORS configurations). You achieve mathematically perfect SEO and First Contentful Paint (FCP) scores via Server-Side Hydration. Most importantly, you guarantee that your users are always looking at mathematically accurate, synchronized data across tabs and components, backed by intelligent background refetching and seamless mutation invalidation. It is the ultimate architecture for highly resilient, deeply interactive dashboards.

T

Solid pattern for the SSR hydration part. One thing on the Tab 1/Tab 2 example you open with though, does the invalidateQueries call in Tab 1's onSuccess actually reach Tab 2, or does Tab 2 only catch up once refetchOnWindowFocus fires because the user switches to it? If it's the latter, the multi-tab staleness problem isn't fully solved without something like a BroadcastChannel to trigger the invalidation cross-tab.