URL State, Server State and a BFF: A Vite Stack for Internal React Apps
Why an authenticated internal React platform can be better served by Vite, TanStack Router, TanStack Query and a separate BFF than by Next.js, and when that stops being true.
This article is published in English.
Sources: Next.js is excellent. That’s exactly why you **shouldn’t** use it everywhere.
When a new React project starts, "should we use Next.js?" is usually answered with yes before anyone has described the application. For public, content-heavy sites that reflex is often right. For an authenticated operations platform, where users spend all day filtering tables and editing records behind a login, many of the framework's strongest features solve problems the app does not have. This article walks through the architecture such a platform actually needs, shows how a composable stack of React, Vite, TanStack Router, TanStack Query and a backend-for-frontend (BFF) maps onto it, and ends with a checklist for deciding when Next.js is the better fit after all.
Nothing here argues that Next.js is weak. It is one of the best React frameworks available. The argument is against adopting it by default, without checking whether its capabilities match the requirements.
A concrete scenario
Consider a small team of five engineers building an internal platform with more than 100 routes, more than 300 API endpoints and roughly a million API calls per month. They could build it with Next.js. Instead they choose React with Vite, TanStack Router for routing, TanStack Query for data, and a separate BFF between the frontend and the backend services. Six months into the build, that choice holds up, not because Next.js would have failed, but because the platform barely touches the areas where Next.js excels.
For a broader comparison of the two approaches across project types, see Next.js or plain React: matching the framework to your project. The focus here is narrower: what an authenticated, data-heavy app needs from each layer.
Describe the application before picking the framework
Next.js has become the standard answer because it bundles so much: file-based routing and layouts, SSR and Server Components, a caching layer, middleware, backend code in the same project, optimised images and a big ecosystem. For plenty of products that bundle is ideal. The more useful first question, though, is what kind of application you are building. Two React applications can have almost nothing in common architecturally.
A public storefront
A public e-commerce site typically has:
- thousands of pages anyone can open
- traffic arriving from search engines
- real SEO requirements
- product pages whose content benefits from server rendering
- shareable public URLs and social previews
- a lot of static or slowly changing content
An internal operations platform
An internal tool typically has:
- nearly every screen behind authentication
- customer search, filters and paginated tables
- forms, dashboards and permission checks
- frequent mutations and near-real-time data
- a large amount of server state
- a highly interactive interface
- an API or BFF layer that already exists
Both are React apps, but they address different problems, and the architecture should reflect that.
Users operate the app rather than read it
On an internal platform, a user signs in and works inside the application for the whole day. They do not arrive from a search result, read an article or browse a product listing. They search, filter, sort, open dialogs, submit forms, change records, switch tabs, page through tables, refresh data and jump between related entities. They are running an application, not visiting a website, and that shift changes which framework features matter.
SEO stops being a requirement
Take a route like /customers/12345 that shows an authenticated customer record with sensitive data. You actively do not want a search engine to index it. There is no need for crawlers to discover it or for the first HTML response to be crawler-friendly. The page is valuable because an authorised user interacts with it, not because it can be found. One of the headline reasons to choose Next.js therefore contributes little. That is a mismatch of requirements, not a flaw in the framework.
Where Server Components fit, and where they do not
Server Components are a genuinely useful model, so the question deserves care. Picture a customer operations page with several sections:
Customer
├── Profile
├── Loans
├── Payments
├── Transactions
├── Documents
└── Activity
As soon as the page loads, the user starts changing it. They filter the payments list, for example:
Status: Failed
Type: ACH
Date: Last 30 days
Then they paginate, sort, search, open a transaction and edit something. The view is continuously reshaped by user input and fresh server data. The dominant flow is not this document-style cycle:
Request → Render document → Read document
It is much closer to an interaction loop:
User interaction
↓
Application state
↓
Server state
↓
API
↓
Updated UI
The more your screens look like the second diagram, the more your client-side architecture determines quality: how state is stored, how data is cached and how the UI reacts to change. Server Components can still render parts of such a page, but they do not remove the need for a strong client-side data layer.
Treating the URL as application state
Suppose an operations user is on the payments tab of a customer:
/customer/12345/payments
They apply a set of filters:
status=failed
type=ach
amountMin=500
page=2
If those filters are encoded in the address, the URL becomes:
/customer/12345/payments
?status=failed
&type=ach
&amountMin=500
&page=2
At that point the URL is not just navigation; it is part of the application state. One engineer can paste it to a colleague, who lands on the same customer, the same tab, the same filters and the same page of results. It also survives reloads and makes the back button behave as users expect. Getting this right across many screens is exactly where a type-safe router earns its place.
What TanStack Router contributes
TanStack Router treats routing as a first-class part of the design. Routes, path parameters and search parameters can be expressed in the type system, which matters once an app has more than a hundred routes. At that size, routing is no longer just "which component renders here"; it is architecture. For a route such as:
/customer/$customerId/payments
you want:
- clearly defined, typed path parameters
- search parameters that are validated and have predictable types
- navigation calls checked by the compiler, so a renamed parameter breaks the build rather than a link
- loading and error states declared per route
- authentication guards at the route level
- deep links that work every time
Crucially, none of this depends on whether a page is rendered on a server. For an internal app that runs entirely in the browser, that is a strong fit.
Server state is the real centre of gravity
Much of a large internal app is less about pages and more about remote data. The entities it constantly reads and writes look like this:
Customers
Loans
Payments
Transactions
Documents
Users
Accounts
Reports
For every one of them, the frontend has to answer the same questions:
- Is this data already in memory?
- Has it gone stale?
- Should it be refetched, and when?
- What does the UI show while loading?
- What happens when a request fails?
- Could a different component share that result?
- What changes after a mutation, and which queries must be invalidated?
- What should appear instantly when the user navigates back?
- Can the UI update optimistically?
That is server-state management, and it is precisely the problem TanStack Query is built for: a cache keyed by query, background refetching, deduplication of identical requests, mutation hooks and targeted invalidation. With the router owning URL state and Query owning server state, each layer has a clear responsibility, and component-local state shrinks to genuinely local UI concerns such as whether a dialog is open.
Keeping the BFF as the API boundary
In this scenario the frontend does not call dozens of microservices directly. It talks to a BFF, which in turn talks to the services. That separation keeps the frontend ignorant of backend implementation details, including whether a response is assembled from one service or five. The BFF owns the API contract.
It also produces a clean deployment split. The frontend is static assets served from a CDN or static host:
Frontend
↓
CDN / static hosting
while the BFF runs as its own service in front of the microservices:
BFF
↓
Microservices
Each side can be built and released independently. Next.js can absolutely be deployed alongside a BFF too. The real question is whether your frontend framework also needs to be your server boundary. When the organisation already has a backend layer that provides that boundary, adding a second one inside the frontend framework duplicates responsibility.
Authentication stays on the server side
The authentication flow follows the same separation. The user clicks sign in and is redirected to Okta. After a successful login, the callback lands on the backend, which keeps the access token server-side, with Redis handling session storage. The browser only holds a session identifier in a secure cookie. Conceptually:
Browser
│
│ Sign in
▼
Okta
│
│ Callback
▼
BFF / Backend
│
▼
Redis
The React app is not where the authentication architecture lives; its job is to operate the product, while the BFF keeps ownership of security and the API contract. A useful side effect is that access tokens never reach browser JavaScript, which reduces the impact of an XSS bug. Doing this inside Next.js is not inherently worse, but when this architecture already exists in the organisation, keeping it outside the frontend is simple and explicit.
Yes, Next.js could do all of this
It could. You could build the app with Next.js using client components, use TanStack Query inside it, wire up authentication, connect it to the BFF and deploy it however you like. Capability is not the issue. The better question is how much of the framework you would actually use. If most of the application ends up looking like this:
'use client'
↓
Client-side interactions
↓
API calls
↓
TanStack Query
then it is worth pausing to ask whether the server-side features justify making Next.js the boundary of your application, with its conventions, runtime and upgrade path.
Each piece of the composable stack has one job
For this category of application, a sensible default is React, Vite, TanStack Router and TanStack Query, plus the BFF. This is not about gluing libraries together until you have accidentally built your own framework. Every piece has a defined role:
- Vite provides the build tooling and development server.
- React renders the UI.
- TanStack Router owns routing and URL state.
- TanStack Query owns server state.
- The BFF is the API and security boundary.
Because the responsibilities are explicit, you can replace one part without rethinking the entire application.
The trade-offs of leaving Next.js out
The composable route has costs. You give up the batteries-included experience and take on decisions Next.js would make for you: project structure, code-splitting conventions, how the static host handles client-side routes, and keeping several libraries upgraded together. The requirements can also shift. If half the app later needs to become public and SEO suddenly matters, Next.js looks much more attractive. The same applies if you need substantial server rendering or static generation, if Server Components add real value to your design, or if you actually want frontend and server code deliberately coupled in one deployable. So the decision is not "Next.js or React". It is which architecture matches the application.
A checklist for choosing
Before committing, run through these questions:
- Does the app have a significant public surface? That is a strong point for Next.js.
- Does SEO matter? Next.js becomes more attractive.
- Do you need server rendering or static generation? Next.js wins that one too.
- Would Server Components or Server Actions materially improve the design? Then take Next.js seriously.
- Is the app mostly authenticated and highly interactive? Lean towards React with Vite.
- Does the organisation already run a BFF or API layer? A standalone client app deployed on its own is worth considering.
- Is server state a dominant concern? TanStack Query becomes very attractive.
- Are URL state and complex routing central? TanStack Router becomes very attractive.
A compact rule of thumb follows from these answers. When an application has a significant public-facing surface, give Next.js serious consideration. When it is predominantly authenticated, especially an internal or admin platform, start with React and Vite and add only the pieces you need. Neither stack is better in general; one is better suited to a given problem, a distinction that easily gets lost once a framework becomes the default answer.
Wrapping up
The best architecture is not the one with the longest feature list but the one whose features solve problems you actually have. For a login-gated operations tool backed by its own backend layer, heavy server state, dense interactions and very little public content, React with Vite, TanStack Router and TanStack Query is not a compromise; it is a deliberate choice.
- Classify the application first: public content site or authenticated tool.
- Treat the URL and server data as the two main state stores, and give each a dedicated tool.
- Keep the security and API boundary where your organisation already has one.
- Revisit the decision if public pages, SEO or server rendering become real requirements.