Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, IT partner for business & fractional CTO. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. Real-Time Dashboard in Next.js with TanStack Query + Zustand

Iurii Builds: Real-Time Dashboard in Next.js with TanStack Query + Zustand

Server Components for the shell, TanStack Query polling for live metrics, Zustand for filter state, and Sentry error boundaries — the architecture that holds up in production.

April 16, 2026· 12 min read

Real-time Next.js admin dashboard: TanStack Query polling, Zustand for filter state, Sentry error boundaries per panel, and a zero-dependency bar chart.

Stack

Next.jsReactTypeScript

Libraries

TanStack QueryZustandDrizzle ORM

Services

SentryStripe

Topics

Admin DashboardSaaSData VisualizationArchitecture
Real-Time Dashboard in Next.js with TanStack Query + Zustand

On this page

  • The Architecture Question: Server Components vs Client
  • TanStack Query: Polling vs WebSockets
  • Zustand for Dashboard State
  • Filter Components Wired to Zustand
  • Activity Chart Without a Chart Library
  • Sentry for Error Monitoring
  • TanStack Query + Sentry Integration
  • Production Considerations

An admin dashboard sounds like a simple project. List some data, add some filters, show some charts. In practice, it's where all the product complexity concentrates: real-time data that needs to stay fresh, filter state that needs to survive navigation, error boundaries that need to tell you about production failures before users report them, and data visualization that shouldn't add 300 kB to your bundle for a single chart.

I've built admin dashboards for vatnode.dev and HTPBE?. The HTPBE? dashboard is server-rendered with no client-side data fetching — appropriate for a low-traffic internal tool where real-time isn't needed. The vatnode dashboard needed live API usage metrics, error rates, and user activity that update while you're looking at them.

This article covers the client-side architecture: TanStack Query for data fetching and polling, Zustand for dashboard filter state, and Sentry for error monitoring.

The Architecture Question: Server Components vs Client

Next.js App Router gives you a real choice here. React Server Components fetch data directly — no client-side JavaScript, no loading states, no stale data problem. I used this approach for the HTPBE? admin dashboard and it works very well for a static view of current state.

But for real-time metrics, Server Components aren't the right tool. You want data to update on the screen without a full page reload. That means client-side data fetching, which means TanStack Query. The broader SaaS production checklist covers where admin dashboards fit in the full architecture.

The architecture I use: Server Components for the initial page shell (authentication, static configuration, first data fetch), Client Components for the interactive dashboard panels that need to stay live. This keeps the Time to First Byte fast — the page renders with real data immediately — and adds live updates on top without requiring a WebSocket.

// app/(dashboard)/dashboard/page.tsx — Server Component
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { DashboardClient } from "./dashboard-client";
import { getInitialStats } from "@/lib/stats";
 
export default async function DashboardPage() {
  const session = await auth();
  if (!session) redirect("/login");
 
  // Initial data fetch on the server — no loading spinner on first load
  const initialStats = await getInitialStats(session.user.id);
 
  return <DashboardClient initialStats={initialStats} userId={session.user.id} />;
}

The initialStats becomes the initialData in TanStack Query, which means the dashboard renders immediately with real data. TanStack Query then takes over for polling, so the data stays fresh without another full page render.

TanStack Query: Polling vs WebSockets

Related service

MVP Development

Building a SaaS that needs a production-grade admin dashboard with live metrics, error monitoring, and filter state that survives navigation? I own the full stack.

More about this service →

The question that comes up immediately: why polling and not WebSockets? WebSockets give you true real-time push — the server notifies the client the instant data changes. Polling checks for changes on a schedule.

My answer: for an admin dashboard, polling is almost always sufficient and significantly simpler to operate. The questions an admin dashboard answers — "what are my API error rates?" "how many users are active today?" — don't require millisecond latency. A 30-second polling interval is fine. WebSockets require a persistent server connection, connection management, reconnection logic, and a different deployment model (can't use serverless for WebSocket handlers on Vercel without additional infrastructure).

Here's the TanStack Query setup for API usage metrics:

// app/(dashboard)/dashboard/dashboard-client.tsx
"use client";
 
import { useQuery, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useDashboardStore } from "@/store/dashboard";
 
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,    // Consider data stale after 30 seconds
      gcTime: 5 * 60_000,   // Keep unused data in cache for 5 minutes
      retry: 2,
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10_000),
    },
  },
});
 
export function DashboardClient({
  initialStats,
  userId,
}: {
  initialStats: UsageStats;
  userId: string;
}) {
  return (
    <QueryClientProvider client={queryClient}>
      <DashboardContent initialStats={initialStats} userId={userId} />
    </QueryClientProvider>
  );
}
 
function DashboardContent({
  initialStats,
  userId,
}: {
  initialStats: UsageStats;
  userId: string;
}) {
  // Read filter state from Zustand
  const { timeRange, environment } = useDashboardStore();
 
  const { data: stats, isLoading, error, dataUpdatedAt } = useQuery({
    queryKey: ["usage-stats", userId, timeRange, environment],
    queryFn: () => fetchUsageStats({ userId, timeRange, environment }),
    initialData: initialStats,
    refetchInterval: 30_000, // Poll every 30 seconds
    refetchIntervalInBackground: false, // Stop polling when tab is not focused
  });
 
  if (isLoading && !stats) return <DashboardSkeleton />;
  if (error) return <DashboardError error={error} />;
 
  return (
    <div>
      <StatsGrid stats={stats} />
      <ActivityChart userId={userId} timeRange={timeRange} />
      <LastUpdated timestamp={dataUpdatedAt} />
    </div>
  );
}

refetchIntervalInBackground: false is the flag I always set for admin dashboards. If you leave it true, every browser tab running the dashboard makes API calls every 30 seconds even when the tab is in the background. On a shared staging environment with 3 developers, that's 3× the load. Small detail, real impact.

The queryKey array includes the filter state — timeRange and environment. When a filter changes, TanStack Query fetches fresh data for the new combination and caches the old data separately. Switch back to the previous filter and it renders from cache immediately, re-validating in the background.

Zustand for Dashboard State

The dashboard has filter state that needs to:

  • Persist across panel switches (navigate to Users, come back to Overview — filters should be the same)
  • Be accessible from multiple components without prop drilling
  • Not cause unnecessary re-renders in components that don't use it

This is exactly what Zustand is for.

// store/dashboard.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
 
type TimeRange = "1h" | "24h" | "7d" | "30d";
type Environment = "live" | "test" | "all";
 
interface DashboardState {
  timeRange: TimeRange;
  environment: Environment;
  selectedUserId: string | null;
 
  setTimeRange: (range: TimeRange) => void;
  setEnvironment: (env: Environment) => void;
  setSelectedUser: (userId: string | null) => void;
  reset: () => void;
}
 
const initialState = {
  timeRange: "24h" as TimeRange,
  environment: "live" as Environment,
  selectedUserId: null,
};
 
export const useDashboardStore = create<DashboardState>()(
  persist(
    (set) => ({
      ...initialState,
      setTimeRange: (timeRange) => set({ timeRange }),
      setEnvironment: (environment) => set({ environment }),
      setSelectedUser: (selectedUserId) => set({ selectedUserId }),
      reset: () => set(initialState),
    }),
    {
      name: "dashboard-filters",
      partialize: (state) =>
        // Only persist the filter values, not the setter functions
        ({
          timeRange: state.timeRange,
          environment: state.environment,
          // Don't persist selectedUserId — clear on reload
        }),
    }
  )
);

The persist middleware with partialize is the important piece. Without partialize, Zustand would try to serialize the setter functions to localStorage — which fails silently. partialize lets you specify exactly which state slices to persist.

The time range and environment preferences persist across reloads (sensible UX — why should the user have to re-select "7d" every time they open the dashboard?). The selected user ID doesn't persist — on reload, you want to see the full list, not land on a specific user's detail view.

Filter Components Wired to Zustand

// components/dashboard/TimeRangeSelector.tsx
"use client";
 
import { useDashboardStore } from "@/store/dashboard";
 
const TIME_RANGES = [
  { value: "1h" as const, label: "1 hour" },
  { value: "24h" as const, label: "24 hours" },
  { value: "7d" as const, label: "7 days" },
  { value: "30d" as const, label: "30 days" },
];
 
export function TimeRangeSelector() {
  const { timeRange, setTimeRange } = useDashboardStore();
 
  return (
    <div className="flex gap-1 rounded-lg border p-1">
      {TIME_RANGES.map(({ value, label }) => (
        <button
          key={value}
          onClick={() => setTimeRange(value)}
          className={`rounded px-3 py-1 text-sm transition-colors ${
            timeRange === value
              ? "bg-primary text-primary-foreground"
              : "hover:bg-muted"
          }`}
        >
          {label}
        </button>
      ))}
    </div>
  );
}

When setTimeRange is called, Zustand updates the store, which triggers a re-render only in components that subscribed to timeRange. The useQuery hook in DashboardContent has timeRange in its queryKey, so it automatically fires a new request.

Activity Chart Without a Chart Library

For the daily API call activity chart, I made the same decision as in the HTPBE? dashboard: no chart library. A simple bar chart does not justify adding Recharts (~400 kB), Chart.js (~300 kB), or even Nivo (~1 MB) to the bundle.

The technique: CSS proportional heights, pure JSX:

// components/dashboard/ActivityChart.tsx
"use client";
 
import { useQuery } from "@tanstack/react-query";
import { useDashboardStore } from "@/store/dashboard";
 
interface DayActivity {
  date: string;  // "2026-04-01"
  count: number;
}
 
export function ActivityChart({ userId }: { userId: string }) {
  const { timeRange, environment } = useDashboardStore();
 
  const { data: activityData } = useQuery<DayActivity[]>({
    queryKey: ["activity-chart", userId, timeRange, environment],
    queryFn: () => fetchActivityData({ userId, timeRange, environment }),
    refetchInterval: 60_000, // Chart updates less frequently than KPIs
  });
 
  if (!activityData || activityData.length === 0) {
    return <div className="h-32 animate-pulse rounded bg-muted" />;
  }
 
  const maxCount = Math.max(...activityData.map((d) => d.count), 1);
 
  return (
    <div>
      <h3 className="mb-3 text-sm font-medium text-muted-foreground">
        API Calls
      </h3>
      <div className="flex h-32 items-end gap-0.5">
        {activityData.map((day, i) => {
          const pct = (day.count / maxCount) * 100;
          const minHeight = day.count > 0 ? 3 : 0; // Ensure non-zero days are visible
 
          return (
            <div
              key={i}
              className="group relative flex flex-1 flex-col items-center justify-end"
            >
              <div
                className="w-full rounded-t bg-primary/60 transition-all group-hover:bg-primary"
                style={{ height: `${Math.max(pct, minHeight)}%` }}
              />
              {/* Tooltip on hover */}
              <div className="pointer-events-none absolute bottom-full mb-1 hidden whitespace-nowrap rounded bg-popover px-2 py-1 text-xs shadow group-hover:block">
                {formatDate(day.date)}: {day.count.toLocaleString()} calls
              </div>
            </div>
          );
        })}
      </div>
      {/* X-axis: first and last date labels */}
      <div className="mt-1 flex justify-between text-xs text-muted-foreground">
        <span>{formatDateShort(activityData[0]?.date)}</span>
        <span>{formatDateShort(activityData[activityData.length - 1]?.date)}</span>
      </div>
    </div>
  );
}
 
function formatDate(dateStr: string): string {
  return new Date(dateStr).toLocaleDateString("en-GB", {
    day: "2-digit",
    month: "short",
  });
}
 
function formatDateShort(dateStr: string | undefined): string {
  if (!dateStr) return "";
  return new Date(dateStr).toLocaleDateString("en-GB", {
    day: "2-digit",
    month: "short",
  });
}

40 lines, 0 dependencies, works correctly. The Math.max(pct, minHeight) ensures a day with one API call still shows a visible bar even when the month's maximum is 10,000 calls.

If you need interactivity beyond hover tooltips — zoom, brush selection, multiple data series with axes — then a chart library is justified. For a "how many API calls per day" bar chart, it isn't.

Sentry for Error Monitoring

Sentry is the error monitoring layer — it captures unhandled exceptions, records context (user ID, component tree, recent actions), and sends alerts.

// instrumentation.ts (Next.js instrumentation hook)
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const Sentry = await import("@sentry/nextjs");
    Sentry.init({
      dsn: process.env.SENTRY_DSN,
      environment: process.env.NODE_ENV,
      tracesSampleRate: 0.1, // Sample 10% of transactions for performance monitoring
      // Don't send errors from localhost
      enabled: process.env.NODE_ENV === "production",
    });
  }
}

For the dashboard specifically, I add Sentry error boundaries around the data panels so a failure in one panel doesn't crash the entire dashboard:

// components/dashboard/ErrorBoundary.tsx
"use client";
 
import * as Sentry from "@sentry/nextjs";
import { ErrorBoundary } from "@sentry/nextjs";
import type { ReactNode } from "react";
 
interface DashboardErrorBoundaryProps {
  panelName: string;
  children: ReactNode;
}
 
export function DashboardErrorBoundary({
  panelName,
  children,
}: DashboardErrorBoundaryProps) {
  return (
    <ErrorBoundary
      fallback={({ error, resetError }) => (
        <div className="rounded-lg border border-destructive/30 bg-destructive/10 p-4">
          <p className="text-sm text-destructive">
            Failed to load {panelName}.{" "}
            <button
              onClick={resetError}
              className="underline hover:no-underline"
            >
              Retry
            </button>
          </p>
          {process.env.NODE_ENV === "development" && (
            <pre className="mt-2 text-xs text-muted-foreground">
              {error instanceof Error ? error.message : String(error)}
            </pre>
          )}
        </div>
      )}
      onError={(error, componentStack) => {
        // Tag the error with the panel name for filtering in Sentry
        Sentry.withScope((scope) => {
          scope.setTag("dashboard_panel", panelName);
          scope.setExtra("componentStack", componentStack);
          Sentry.captureException(error);
        });
      }}
    >
      {children}
    </ErrorBoundary>
  );
}

Usage in the dashboard layout:

<DashboardErrorBoundary panelName="API Usage Stats">
  <UsageStatsPanel userId={userId} />
</DashboardErrorBoundary>
 
<DashboardErrorBoundary panelName="Activity Chart">
  <ActivityChart userId={userId} />
</DashboardErrorBoundary>
 
<DashboardErrorBoundary panelName="Recent Checks">
  <RecentChecksTable userId={userId} />
</DashboardErrorBoundary>

When one panel fails — say, the activity chart query throws a network error — the other panels continue working and the user sees a "Failed to load Activity Chart" message with a retry button. Sentry captures the error tagged with dashboard_panel: "Activity Chart" so I can filter for it specifically.

TanStack Query + Sentry Integration

One useful combination: capture query errors to Sentry automatically via a global error callback:

// lib/query-client.ts
import { QueryClient } from "@tanstack/react-query";
import * as Sentry from "@sentry/nextjs";
 
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      retry: 2,
    },
    mutations: {
      onError: (error) => {
        Sentry.captureException(error, {
          tags: { source: "tanstack-mutation" },
        });
      },
    },
  },
  // TanStack Query v5: global error handling
  mutationCache: undefined, // configured via QueryCache below
});
 
// Capture query errors globally
queryClient.getQueryCache().config = {
  onError: (error, query) => {
    // Only capture after all retries are exhausted
    if (query.state.fetchFailureCount === 2) {
      Sentry.captureException(error, {
        tags: {
          source: "tanstack-query",
          queryKey: JSON.stringify(query.queryKey),
        },
      });
    }
  },
};

This means I don't have to add .catch((err) => Sentry.captureException(err)) to every useQuery call — errors bubble up automatically.

Production Considerations

Don't poll when the tab is not focused. refetchIntervalInBackground: false is the flag. On a shared dashboard with multiple admins, every open tab polls independently. 5 admins × 30-second polling × 10 queries = 10 requests per second. Not catastrophic, but unnecessary.

Invalidate cache on user actions. If an admin triggers an action (revoke an API key, update a user's plan), invalidate the relevant query so the dashboard reflects the change immediately:

// After revoking an API key
await revokeApiKey(keyId);
queryClient.invalidateQueries({ queryKey: ["user-api-keys", userId] });
queryClient.invalidateQueries({ queryKey: ["usage-stats", userId] });

Use select to transform data in the query. If your API returns raw database rows and you need a derived format for the chart, transform in the select option — it runs on cached data without triggering a refetch:

useQuery({
  queryKey: ["activity", userId],
  queryFn: fetchActivity,
  select: (data) => ({
    chartData: data.map((d) => ({ date: d.day, count: d.total })),
    totalCalls: data.reduce((sum, d) => sum + d.total, 0),
  }),
});

If you're building an admin dashboard or internal tool that needs real-time metrics and needs to be solid when things go wrong — get in touch. For SaaS MVP development where the admin dashboard is one piece of a larger product, I own the full stack from schema to deployment. I'm available for freelance projects and long-term engagements.


Related projects: HTPBE? Admin Dashboard — the server-rendered version of this problem. vatnode.dev — the SaaS that needed the client-side polling version.

Further reading:

  • TanStack Query documentation
  • Zustand documentation
  • Sentry Next.js documentation
Iurii Rogulia

Working on something like this?

MVP Development

Building a SaaS that needs a production-grade admin dashboard — live metrics, error monitoring, filter state that survives navigation? I ship the full stack.

More about this service

Relevant client work

View all projects
HTPBE? — Internal Admin Dashboard
HTPBE? — Internal Admin Dashboard
March 15, 2026
HTPBE? — Internal Admin Dashboard

Role-gated admin dashboard for the HTPBE? SaaS platform — real-time KPIs, per-user quota tracking, and a zero-dependency bar chart, all server-rendered via

vatnode — EU VAT Validation API
vatnode — EU VAT Validation API
January 19, 2026
vatnode — EU VAT Validation API

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

pi-pi.ee — B2B E-commerce for Waterless Urinal Systems
pi-pi.ee — B2B E-commerce for Waterless Urinal Systems
January 17, 2026
pi-pi.ee — B2B E-commerce for Waterless Urinal Systems

i18n B2B e-commerce platform for waterless urinal products across 32 European countries with automated VAT handling, PDF invoicing, and CRM integration.

What clients say

“

I'd built most of our MVP with Cursor and it looked finished — it compiled, the tests were green, the demo worked. It just wouldn't survive real users. Iurii took it over and found what I couldn't see: authentication handled four different ways, tests that only asserted what the code already did, and a dependency list half of which was unused. He didn't rewrite it from scratch — he told me honestly what was salvageable, ripped out the dead code, and got it to something a real team could build on. Two weeks and it went from 'looks done' to actually shippable.

Sebastian Falk 🇸🇪

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
“

I had a validated idea and a deadline tied to an accelerator demo day, but no product. Iurii helped me cut the scope down to what actually needed to exist for launch and pushed back hard on the features I thought I needed but didn't. We shipped a working MVP in six weeks with real users on it by demo day. The honest scoping at the start is what saved the timeline — it would have been easy to let me build twice as much and miss the date.

Aino Virtanen 🇫🇮

Founder

Stack

Next.jsTypeScript

Databases

PostgreSQL

Topics

MVPProductScopeSaaS
“

We wanted to add an AI feature that turns messy user notes into structured records, but our first attempt returned unpredictable JSON that broke the app half the time. Iurii rebuilt it using structured outputs against the OpenAI API with proper validation, so the data is always shaped the way our database expects. He also added a fallback path for when the model is unsure instead of letting it guess. It's been running in production for a month with no manual cleanup.

Bram de Vries 🇳🇱

Product Lead

Stack

Next.jsTypeScript

Services

OpenAI

Topics

AILLMStructured Outputs

Related articles

Next.js SaaS Checklist: Launch Production-Ready in 8 Weeks
January 19, 2026· 17 min
Next.js SaaS Checklist: Launch Production-Ready in 8 Weeks

40+ point production SaaS checklist: auth, Stripe billing, PostgreSQL, rate limiting, email, monitoring, and security — with honest 8-week build estimates.

Stack

Next.jsTypeScript

Libraries

Drizzle ORMBetter AuthBullMQZodReact Email

Databases

PostgreSQLRedis

Services

StripeVercelResendSentry

Topics

SaaSArchitectureAuth
Stripe Webhooks: Idempotency, Retries, and Queue Setup
January 2, 2026· 11 min
Stripe Webhooks: Idempotency, Retries, and Queue Setup

Stripe webhook production architecture: idempotency keys in PostgreSQL and Redis, BullMQ queue, signature verification in Next.js App Router — with full

Stack

Next.jsTypeScriptNode.js

Libraries

BullMQDrizzle ORMioredis

Databases

PostgreSQLRedis

Services

Stripe

Topics

SaaSWebhooksArchitecturePayments
Build an Internal CRM on Supabase: A Weekend-Scale Guide
June 24, 2026· 21 min
Build an Internal CRM on Supabase: A Weekend-Scale Guide

Build an internal CRM on Supabase: schema design, RLS policies, Next.js App Router frontend, realtime subscriptions, and an honest look at where the weekend

Stack

Next.jsTypeScript

Libraries

Drizzle ORM

Databases

PostgreSQL

Services

Supabase

Topics

CRMArchitectureSaaSInternal Tools
Turborepo Monorepo: Next.js and Hono in One Repo With Shared Types
August 4, 2025· 13 min
Turborepo Monorepo: Next.js and Hono in One Repo With Shared Types

Turborepo monorepo with Next.js and Hono, sharing one set of TypeScript types across frontend and backend.

Stack

Next.jsTypeScriptHonoTurborepoNode.jsDocker

Libraries

Drizzle ORMZod

Databases

PostgreSQLRedis

Services

Vercel

Topics

ArchitectureMonorepoSaaS
Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026· 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis

Topics

E-commercePaymentsArchitectureSaaS