Skip to content

Is Next.js Overkill for Internal Dashboards?

My Dashboard Nightmare Started with Cache

I built an internal admin dashboard for our team. Nothing fancy - user management, some data tables, a few forms. I chose Next.js because “that’s what everyone uses.”

Big mistake.

After three days of fighting cache invalidation issues, I realized I was solving problems that shouldn’t exist. My dashboard didn’t need SEO. It didn’t need server-side rendering. It was behind authentication, used by five people internally.

Yet I spent hours debugging why my data table showed stale results after updates.

My debugging session
User updates record in database
Next.js cache: "I'll keep the old data"
Me: revalidatePath('/users')
Next.js: "Which cache? Full route? Data? Router?"
Me: "All of them?"
Next.js: "Still stale. Try revalidateTag."
Me: revalidateTag('users')
Next.js: "Maybe. Or maybe not. Who knows."

That’s when I realized: Next.js was the wrong tool for the job.

Why We Default to Next.js

Before I explain the alternative, let me admit why I chose Next.js in the first place:

  1. Job postings ask for Next.js experience
  2. Everyone on Twitter/X talks about it
  3. It’s the “default” React stack in 2026
  4. I didn’t want to miss out on “the right way”

But here’s the thing: Next.js solves problems my dashboard didn’t have.

Feature comparison
Next.js Features My Dashboard Needs
---------------- ------------------
Server-Side Rendering -> No (auth required, no SEO)
SEO Optimization -> No (internal tool)
Static Generation -> No (real-time data)
API Routes -> Maybe (but my backend exists)
Edge Functions -> No (overkill)
Multiple Cache Layers -> DEFINITELY NOT

The feature overlap was tiny. The complexity cost was huge.

The Client/Server Component Mess

The App Router in Next.js forces you to decide where every component runs. This sounds reasonable until you’re building a dashboard.

The mental overhead
// This is a Server Component (default)
// It can fetch data but can't have interactivity
export default async function UserList() {
const users = await db.users.findMany() // Server-side fetch
return <div>{users.map(u => <span>{u.name}</span>)}</div>
// Can't add onClick, useState, or any interactivity here
}

Want a clickable row? It becomes a Client Component.

Now you need Client Component
"use client" // This directive changes everything
export default function UserRow({ user }) {
const [selected, setSelected] = useState(false)
return (
<div onClick={() => setSelected(!selected)}>
{user.name}
</div>
)
}

But now you can’t fetch data in that component. The data must come from somewhere else.

The chain of complexity grows
// layout.tsx - Server Component
export default async function Layout({ children }) {
const user = await getCurrentUser() // Server-side
return (
<div>
<Sidebar user={user} /> {/* Props drilling starts here */}
{children}
</div>
)
}
// Now Sidebar must pass user down...
// And every interactive component needs "use client"
// And server data can't reach client components directly

I spent more time organizing components than building features.

Cache Layers Upon Cache Layers

Next.js has four caching mechanisms:

  1. Full Route Cache - Caches rendered routes
  2. Data Cache - Caches fetch responses
  3. Router Cache - Caches route segments
  4. Request Memoization - Deduplicates requests

For a dashboard showing real-time data, I was constantly fighting these caches.

The cache invalidation dance
import { revalidatePath, revalidateTag } from 'next/cache'
async function updateUser(formData: FormData) {
'use server'
await db.users.update({
where: { id: formData.get('id') },
data: { name: formData.get('name') }
})
// Which one do I need?
revalidatePath('/users') // This path?
revalidatePath('/users/[id]') // Dynamic paths too?
revalidateTag('users') // Tags I may have set?
revalidateTag('user-list') // More tags?
// Why is my table still showing old data?
}

Every mutation required research on which cache to invalidate. I wasn’t building features - I was fighting the framework.

The Simple Alternative: Vite SPA

After that painful experience, I rebuilt the dashboard with:

  • Vite - Build tool, fast dev server
  • React Router - Client-side routing
  • TanStack Query - Data fetching and caching (simple, controllable)
  • Zustand - State management
  • shadcn/ui - UI components

The code became dramatically simpler.

main.tsx - Clean and simple
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from './App'
import Users from './routes/Users'
import Settings from './routes/Settings'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute - I control this
refetchOnWindowFocus: true
}
}
})
const router = createBrowserRouter([
{ path: '/', element: <App /> },
{ path: '/users', element: <Users /> },
{ path: '/settings', element: <Settings /> }
])
ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
)

Every component runs in the browser. No client/server boundary decisions. One mental model.

Real-Time Data Made Easy

The biggest win: real-time data updates without cache headaches.

Users.tsx - Auto-refreshing data table
import { useQuery } from '@tanstack/react-query'
function Users() {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
refetchInterval: 5000 // Auto-refresh every 5 seconds
})
if (isLoading) return <TableSkeleton />
if (error) return <ErrorMessage error={error} />
return <DataTable data={users} />
}

That’s it. Five seconds of fresh data. No cache invalidation research. No revalidatePath mysteries.

Mutations are equally straightforward:

useUserMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (user: User) =>
fetch(`/api/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify(user)
}),
onSuccess: () => {
// Simple, predictable cache invalidation
queryClient.invalidateQueries({ queryKey: ['users'] })
}
})
}

One line to invalidate. Works every time. No framework-level cache layers to navigate.

What I Actually Gave Up

To be fair, I did give up some things by not using Next.js:

Trade-offs analysis
What I Lost Do I Miss It?
--------------- ------------
Server Components No (my dashboard is interactive)
SEO/SSR No (internal tool, no SEO needed)
API Routes No (separate backend exists)
Middleware No (auth handled by backend)
Edge Runtime No (overkill for my use case)

None of these mattered for an internal dashboard.

When Next.js IS Worth It

I’m not saying Next.js is bad. It’s excellent for:

  • Public-facing websites that need SEO
  • Marketing pages with static content
  • Blogs and documentation sites
  • Apps with heavy server-side processing
  • Projects leveraging API routes heavily

But internal dashboards, admin panels, CRUD apps? These don’t benefit from Next.js complexity.

Quick Decision Guide

Should I use Next.js for my dashboard?
Is your app public-facing? → YES, consider Next.js
Is your app behind login? → Keep reading
Do you need SEO? → YES, consider Next.js
No SEO needed? → Keep reading
Is real-time data important? → Vite SPA (avoid cache fights)
Is your team already using Next.js everywhere? → Maybe stick with Next.js
Is this a greenfield project? → Vite SPA
Do you have a separate backend? → Vite SPA
Do you need Next.js API routes? → Next.js might help
Is your app mostly forms and tables? → Vite SPA
Is your app content-heavy? → Next.js

The Stack I Recommend

For internal dashboards and CRUD apps:

Build Tool: Vite (fast, simple)
Routing: React Router or TanStack Router
Data Fetching: TanStack Query
State: Zustand (simple) or React Context
Forms: React Hook Form + Zod
Tables: TanStack Table
UI: shadcn/ui, MUI, or Ant Design

Setup takes 10 minutes. Development is faster. Debugging is simpler. Deployment is cheaper (static CDN or simple server).

Summary

I spent three days fighting Next.js for a dashboard that didn’t need any of its features. The client/server component boundaries, the multiple cache layers, the constant mental overhead - all unnecessary for an internal tool.

The Vite SPA stack gave me a single mental model: everything runs in the browser. Real-time data updates work without cache research. Component organization is straightforward. I focused on building features instead of understanding framework internals.

For your next internal dashboard, ask yourself: do you actually need server-side rendering? If not, skip Next.js. Use Vite. Your future self will thank you when you’re not debugging cache invalidation at 2 AM.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments