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.
User updates record in databaseNext.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:
- Job postings ask for Next.js experience
- Everyone on Twitter/X talks about it
- It’s the “default” React stack in 2026
- 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.
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 NOTThe 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.
// This is a Server Component (default)// It can fetch data but can't have interactivityexport 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.
"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.
// layout.tsx - Server Componentexport 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 directlyI spent more time organizing components than building features.
Cache Layers Upon Cache Layers
Next.js has four caching mechanisms:
- Full Route Cache - Caches rendered routes
- Data Cache - Caches fetch responses
- Router Cache - Caches route segments
- Request Memoization - Deduplicates requests
For a dashboard showing real-time data, I was constantly fighting these caches.
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.
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.
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:
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:
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.jsIs your app behind login? → Keep reading
Do you need SEO? → YES, consider Next.jsNo 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.jsIs this a greenfield project? → Vite SPA
Do you have a separate backend? → Vite SPADo you need Next.js API routes? → Next.js might help
Is your app mostly forms and tables? → Vite SPAIs your app content-heavy? → Next.jsThe Stack I Recommend
For internal dashboards and CRUD apps:
Build Tool: Vite (fast, simple)Routing: React Router or TanStack RouterData Fetching: TanStack QueryState: Zustand (simple) or React ContextForms: React Hook Form + ZodTables: TanStack TableUI: shadcn/ui, MUI, or Ant DesignSetup 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:
- 👨💻 Reddit discussion on Next.js vs SPA for dashboards
- 👨💻 Vite Official Documentation
- 👨💻 TanStack Query Documentation
- 👨💻 Next.js App Router Documentation
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments