1---
2layout: "#src/blog/tags/blog-layout.marko"
3meta:
4 title: One Year with Next.js App Router — Why We're Moving On
5 description: A critique of React Server Components and Next.js 15.
6 keywords: ["webdev", "technical analysis", "opinion"]
7 authors: ["clover caruso"]
8 embed:
9 thumbnail: /open-graph/next-js.png
10 canonical: /blog/webdev/one-year-next-app-router
11date: "Oct 21st, 2025"
12---
13
14As I've been using [Next.js] professionally on my employer's web app, I find the
15core design of their App Router and [React Server Components] (RSC) to be
16extremely frustrating. And it's not small bugs or that the API is confusing,
17but large disagreements about the fundamental design decisions that Vercel and
18the React team made when building it.
19
20The more webdev events I go to, the more I see people who dislike Next.js, but
21still get stuck using it. By the end of this article, I will share how me and
22my colleagues escaped this hell, seamlessly migrating our entire frontend to
23[TanStack Start].
24
25[Next.js]: https://nextjs.org
26[React Server Components]: https://react.dev/reference/rsc/server-components
27
28<table-of-contents />
29
30<h2#technical-review>A Technical Review: What are Server Components?</>
31
32The pitch of RSC is that components are put into two categories,
33<b.server>"server"</b> components and <b.client>"client"</b>
34components. Server components don't have `useState`, `useEffect`, but can be
35`async function`s and refer to backend tools like directly calling into a
36database. Client components are the existing
37model, where there is code on the backend to generate HTML text and frontend
38code to manage the DOM using `window.document.*`.
39
40> The first disaster: naming!! React is now using the wors
41> <b.server>"server"</b> and <b.client>"client"</b> to refer to
42> a very specific things, ignoring their existing definitions. This would be
43> fine, except <b.client>Client</b> components can run on the backend
44> too! In this article, I'll be using the terms <b>"backend"</b> and
45> <b>"frontend"</b> to describe the two execution environments that web apps
46> exist in: a Node.js process and a Web browser, respectively.
47
48This <b.server>Server</b>/<b.client>Client</b> component model
49is interesting. Since built-ins like `<Suspense />` get serialized across the
50network, data fetching can be very trivially modeled with async
51<b.server>server components</b>, and the fallback UI works as if it were
52client-side.
53
54```tsx filename="src/app/[username]/page.tsx" tint="server"
55// For this article, server components will be highlighted in red
56export default async function Page({ params }) {
57 // Page params are given as a resolved promise
58 const { username } = await params;
59
60 // The components `UserInfo` and `UserPostList` will be run at the same
61 // time. Once `UserInfo` is ready, the visitor will see the page with a
62 // `PostListSkeleton` if the post list is not yet ready.
63 return <main>
64 <UserInfo username={username} />
65
66 <Suspense fallback={<PostListSkeleton />}>
67 <UserPostList username={username} />
68 </Suspense>
69 </main>
70}
71
72// Waterfalls are avoided by having multiple components, which
73// are all evaluated at the same time.
74
75async function UserInfo({ username }) {
76 const user = await fetchUserInfo(username);
77 return <>
78 <h1>{user.displayName}</h1>
79 {user.bio ? <Markdown content={user.bio} /> : ""}
80 </>
81}
82
83async function UserPostList({ username }) {
84 const posts = await fetchUserPostList(username);
85 return /* post list ui omitted for brevity */;
86}
87```
88
89If we ignore the 40kB gzipped bundle size of React itself, the above example
90has zero JavaScript for the UI and data fetching &mdash; it just streams the
91markup! For example, the imagined markdown parser within the `<Markdown />`
92component stays on the backend. When an interactive frontend is needed, <b.client>Client
93components</b> can be created by putting them in a file starting with `"use
94client"`.
95
96```tsx filename="src/components/CopyButton.tsx" tint="client"
97"use client"; // This comment marks the file for client-side bundling.
98
99export function CopyButton({ url }) {
100 return <>
101 <span>{url}</span>
102 <button onClick={() => {
103 const full = new URL(url, location.href);
104 navigator.clipboard.writeText(full.href);
105 // omitting error handling, success ui, styles
106 }}>copy</button>
107 </>
108}
109```
110```tsx filename="src/app/q+a/Card.tsx" tint="server"
111export function Card() {
112 return <article>
113 <header>
114 {/* Make the browser import the copy button */}
115 <CopyButton url="/q+a/2506010139" />
116 </header>
117 <p>
118 {/* Process markdown on the backend */}
119 <Markdown content=".........." />
120 </p>
121 </article>
122}
123```
124
125<h2 id="real-world-pitfalls">Real-world Pitfalls of the App Router</>
126
127After quitting [Bun] as a runtime engineer (I implemented [Server Components
128bundling] and [a RSC template][bun-rsc] there), I joined a small company working on the
129front lines: a Next.js app with a Hono backend. The following notes are
130simplifications from the real world problems I've encountered when trying to
131maintain and develop new features. As a result of all of these, everyone's time
132is wasted either working around design flaws, or explaining to each other why
133what should be a non-issue is an immovable object.
134
135[Bun]: https://bun.com
136[Server Components bundling]: https://github.com/oven-sh/bun/blob/67f0c3e016aa479738469adac2b79a1862b88122/src/bake/bake.d.ts
137[bun-rsc]: https://github.com/oven-sh/bun/tree/e7790894d92b730758ecadf971cb935063508dfb/src/bake/bun-framework-react
138
139<h3 id='optimistic-updates'>Optimistic Updates are Impossible</>
140
141The Next.js documentation for performing mutations [does not mention optimistic
142updates][nextjs-updating-data]; it appears this case was not thought about.
143Components rendered by the <b.server>React Server</b>, by design, can
144not be modified after mounting. Elements that could change need to be inside a
145client component, but data fetching cannot happen on the client components,
146even during SSR on the backend. This results in awkwardly small server
147components that only do data fetching and then have a client component that
148contains a mostly-static version of the page.
149
150```tsx filename="src/app/user/[username]/page.tsx" tint="server"
151
152export default async function Page() {
153 const user = await fetchUserInfo(username);
154 return <ProfileLayout>
155 <UserProfile user={user} />
156 </ProfileLayout>;
157}
158```
159```tsx filename="src/app/user/[username]/UserProfile.tsx" tint="client"
160
161"use client"; // Must separate the client code into a second file!
162
163export function UserProfile({ user: initialUser }) {
164 // There are many great state management libraries out there;
165 // for simplicity, this example will use one state cell.
166 const [user, optimisticUpdateUser] = useState(initialUser);
167
168 async function onEdit(newUser) {
169 optimisticUpdateUser(newUser);
170 const resp = await fetch("...", {
171 method: 'POST',
172 body: JSON.stringify(newUser),
173 ... // (headers, credentials, tracing, and more)
174 })
175 if (!resp.ok) /* always remember to test for errors! */
176 }
177
178 return <main>{/* user interface with editable fields... */}</main>:
179}
180```
181
182As more of the page needs interactivity, it gets messier trying to keep the
183static parts truly server-side. On the work app, nearly every piece of UI
184displays some dynamic data. A [`WebSocket`][ws] synchronizes data live as it
185updates (for example, a user card's online state along with their basic
186profile). Since these component setups are harder to understand and maintain
187for engineers, almost all of our pages are entirely `"use client"` with a
188`page.tsx` that defines the data fetching.
189
190A more concrete example of what this looks like in practice with the
191data-fetching library we use at work, [TanStack Query].
192
193[TanStack Query]: https://github.com/tanstack/query#readme
194
195```ts filename="src/queries/users.ts"
196// At work, there is a helper function `defineQuery` for type safety.
197// Fetchers are trivial and can run on the backend or the frontend.
198export const queryUserInfo = (username) => ({
199 queryKey: ['user', username],
200 queryFn: async ({ ... }) => /* fetch data */
201});
202```
203```tsx filename="src/app/user/[username]/page.tsx" tint="server"
204export default async function Page({ params }) {
205 const { username } = await params;
206
207 // There's no global state in the React Server. Since layouts
208 // are executed in parallel, the TanStack `QueryClient` has to
209 // be reconstructed multiple times per route.
210 const queryClient = new QueryClient();
211 await queryClient.ensureQueryData(queryUserInfo(username));
212
213 // HydrationBoundary is a client component that passes JSON
214 // data from the React server to the client component.
215 return <HydrationBoundary state={dehydrate(queryClient)}>
216 <ClientPage />
217 </HydrationBoundary>;
218}
219```
220```tsx filename="src/app/user/[username]/ClientPage.tsx" tint="client"
221"use client";
222export function ClientPage() {
223 const { username } = useParams();
224 const { data: user } = useSuspenseQuery(queryUserInfo(username));
225
226 // ... some hooks
227
228 return <main>
229 {/* ... an interactive web page */}
230 </main>;
231}
232```
233
234This example has to be three separate files because of the rules of server
235component bundling. (The client component needs `"use client"`, and server
236component files often can't be imported on the client due to server-only
237imports.). In the Pages router, this could've been a single file because of the
238tree-shaking that `getStaticProps` and `getServerSideProps` has.
239
240[ws]: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
241[nextjs-updating-data]: https://nextjs.org/docs/app/getting-started/updating-data
242
243<h3 id='redundant-fetches'>Every Navigation is Another Fetch</>
244
245Since the App Router starts every page as a server component, with (ideally)
246small areas of interactivity, a navigation to a new page *has* to fetch the
247Next.js server, regardless of what data the client already has available! Even
248with a a `loading.tsx` file, opening `/`, navigating to `/other`, and then
249going back to `/` will show the loading state while it re-fetches the homepage.
250
251The only case this works is for **perfectly static content**, where instant
252navigations and prefetching work great. But **web apps are not static**, they
253have lots of dynamic content. Being logged in affects the homepage, which is
254infuriating because the client literally has everything needed to display the
255page instantly. It's not like the cookies changed.
256
257> **aside**: In further testing on a blank project, I observe cases where the
258> Next frontend code would pre-fetch routes, but **without any real contents**.
259> On the hello world example, this was a 1.8kB RSC payload that pointed to 2
260> different JS chunks 4 separate times. This is just pure waste of our
261> bandwidth and egress, especially considering all of this information is
262> re-fetched when I actually click the link.
263>
264> ```json whitespace="pre-wrap"
265> 1:"$Sreact.fragment"
266> 2:I[39756,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
267> 3:I[37457,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
268> 4:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"ViewportBoundary"]
269> 6:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"MetadataBoundary"]
270> 7:"$Sreact.suspense"
271> 0:{"b":"TdwnOXsfOJapNex_HjHGt","f":[["children","other",["other",{"children":["__PAGE__",{}]}],["other",["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":null},[["$","div","l",{"children":"loading..."}],[],[]],false],["$","$1","h",{"children":[null,["$","$1","KCFxAJdIDH3BlYXAHsbcVv",{"children":[["$","$L4",null,{"children":"$L5"}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],["$","$L6","KCFxAJdIDH3BlYXAHsbcVm",{"children":["$","div",null,{"hidden":true,"children":["$","$7",null,{"fallback":null,"children":"$L8"}]}]}]]}],false]],"S":false}
272> 5:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
273> 9:I[27201,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"IconMark"]
274> 8:[["$","title","0",{"children":"Create Next App"}],["$","meta","1",{"name":"description","content":"Generated by create next app"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0b3bf435.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L9","3",{}]]
275> ```
276>
277> In review, I found there is actually some content in here: the loading state.
278> Do you see it?
279>
280> ```json
281> ["$","div","l",{"children":"loading..."}]
282> ```
283>
284> It's still a lot of waste, since all of this data gets re-emitted in the
285> actual page RSC.
286
287The solution to this appears to be [`staleTime`][nextjs-stale], but it's marked
288experimental and "not recommended for production". The fact this is a
289non-default afterthought configuration option is embarrassing. Even if we used
290it, you cannot make multiple pages that refer to the same underlying data share
291any of it.
292
293One form of loading state that cannot be represented with the App Router is
294having a page such as a page like a git project's issue page, and clicking on a
295user name to navigate to their profile page. With `loading.tsx`, the entire
296page is a skeleton, but when modeling these queries with TanStack Query it is
297possible to show the username and avatar instantly while the user's bio and
298repositories are fetched in. Server components don't support this form of
299navigation because the data is only available in rendered components, so it
300must be re-fetched.
301
302[nextjs-stale]: https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes
303[nextjs-stale-bug]: https://github.com/vercel/next.js/issues/70661
304
305In our Next.js site, we have this line of code on our server component data
306fetchers to make soft navigations faster by skipping the data fetch phase all
307together.
308
309```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server"
310export function serverSidePrefetchQueries(queries) {
311 if ((await headers()).get("next-url")) {
312 // This is a soft-navigation. SKIP the prefetching to make it faster.
313 // The client might already have this data, and if not, they have the
314 // loading state. Ideally, this server request wouldn't exist -- The
315 // client side has nearly ALL the code since the app is written mostly
316 // as client components. Kind of a design flaw of the App router TBH.
317 return;
318 }
319 // ... data prefetching-logic ...
320}
321```
322
323In addition to this, `loading.tsx` should contain the `useQuery` calls so that
324while the network request for the empty RSC happens, the data is being fetched
325if it actually is needed. In fact, the `loading.tsx` state can just be the
326actual client component, and you'll see the client page.
327
328```tsx filename="src/app/user/[username]/loading.tsx" tint="client"
329"use client";
330export default function PageLoadingSkeleton() {
331 return <ClientPage />;
332}
333```
334
335> At work, we just make our `loading.tsx` files contain the `useQuery`
336> calls and show a skeleton. This is because when Next.js loads the actual Server
337> Component, no matter what, the entire page re-mounts. No VDOM diffing here,
338> meaning all hooks (`useState`) will reset slightly after the request
339> completes. I tried to reproduce a simple case where I was *begging* Next.js to
340> just *update the existing DOM* and preserve state, but it just doesn't.
341> Thankfully, the time the blank RSC call takes is short enough.
342
343<h3 id='layout-restrictions'>Layouts are Artificially Restricted</>
344
345Layouts can perform data fetching, but they can't observe or alter the request
346in any way. This is done so that Next.js can fetch and cache layouts whenever they
347want. In every other framework, layouts are just regular components that have
348no feature difference compared to page components.
349
350Fetching layouts in isolation is a cute idea, but it ends up being silly
351because it also means that any data fetching has to be re-done per layout. You
352can't share a `QueryClient`; instead, you must rely on their [monkey-patched
353`fetch`][nextjs-fetch] to cache the same `GET` request like they promise.
354
355When a coworker asks me about why Next.js rejects some code, I've given up on
356explaining the technical intricacies and just say *"It's a Next.js Skill Issue,
357I'm going to blow it up soon don't worry."* These rules are too hard for normal
358developers to understand.
359
360[nextjs-fetch]: https://nextjs.org/docs/app/api-reference/functions/fetch
361
362<h3 id='rsc-payload'>You Still Download All the Content Twice</>
363
364Unlike the ["Islands Architecture"][islands], Server Components still have to
365be hydrated on the frontend to support `Suspense` and preserving client
366component state. When doing soft navigations, the "RSC Payload" (which is not
367HTML at all) is retrieved by `fetch`. On a fresh reload, HTML is needed for the
368[first paint], but the information about Client components and `Suspense` is
369not contained within that HTML. React's solution is to **send a second copy of
370the entire page's markup**. An example of what a Next.js production server
371would send in a dynamic page render would be something like this:
372
373[first paint]: https://web.dev/articles/fcp
374
375```html filename="GET /user/clover"
376<!DOCTYPE html>
377<html>
378<head>
379 {link and meta tags}
380</head>
381<body>
382 {server side render}
383 <script>
384 // a bootstrap script that sets up global `__next_f` as
385 // an array. once React loads, this `.push` function
386 // gets overwritten to write new chunks directly to the
387 // RSC decoder. this script has some dom helpers too
388 (self.__next_f=self.__next_f||[]).push([0])
389 </script>
390 <script>
391 // the RSC payload for the application shell.
392 self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[658993,[\"/_next/st{...}"])
393 </script>
394
395 <!--
396 the closing </body> is NOT written yet, since there is a
397 suspense boundary not resolved. time passes, and only
398 then is more data is written
399 -->
400 <div class="user-post-list">
401 {server side render of a Suspense boundary}
402 </div>
403 <script>
404 // the RSC payload for the suspense boundary
405 self.__next_f.push([2,"14:[\"$\",\"div\",null,{\"children\":[[\"$\",\"h4\"{...}"])
406 </script>
407
408 <!-- HTML and script tags repeat until the entire page is done -->
409</body>
410</html>
411```
412
413This solution **doubles the size of the initial HTML payload**. Except it's
414worse, because the RSC payload includes JSON quoted in JS string literals,
415which is a is much less efficient format than HTML. While it seems to compress
416fine with brotli and render fast in the browser, this is wasteful. With the
417hydration pattern, at least the data locally could be re-used for interactivity
418and other pages.
419
420Even on pages that have little to no interactivity, you pay the cost. To use
421the Next.js documentation as an example, loading [its
422homepage](https://nextjs.org/docs) loads an page that is around 750kB (250kB of
423HTML and the 500kB of script tags), and content is in there twice.
424
425You can verify that by pressing <kbd>Cmd</kbd> + <kbd>Opt</kbd> + <kbd>u</kbd>
426on Mac or <kbd>Ctrl</kbd> + <kbd>u</kbd> on other platforms. And then
427<kbd>Cmd</kbd> / <kbd>Ctrl</kbd> + <kbd>f</kbd> to locate any string of the
428blog, such as "building full-stack web applications". It's there twice. And
429**there is no way around this**, since it's a fundamental piece of React Server
430Components.
431
432This RSC format certainly has more waste. But I really don't feel like digging into
433why the string `/_next/static/chunks/6192a3719cda7dcc.js` appears 27 separate
434times. What the hell, guys? Is your bandwidth free???
435
436[islands]: https://www.patterns.dev/vanilla/islands-architecture/
437
438<h3 id='turbopack'>Turbopack Sucks</>
439
440This section is not constructive.
441
442- Turbopack isn't fast
443- Turbopack emits code that is hard to debug in a debugger (in development mode)
444- Turbopack throws bad error messages in many cases
445
446I wouldn't have given this point a section in the blog normally, but I want to
447point out three actual examples directly from the project.
448
449The first is a place where during some refactoring to satisfy the Server/Client
450component models, I accidentally made a Client component `async`. This one was
451quite annoying because it didn't say at all where the issue was, but only
452contained the <b.server>server</b> stack trace.
453
454![Next.js error](/file/2025/blog-everyone-hates-nextjs/asyncerror.png)
455
456Another case of a terrible error message:
457
458![Next.js error](/file/2025/blog-everyone-hates-nextjs/nexterror.png)
459
460> After fixing the underlying issue in this second error (which I cannot recall),
461> the Dev server hung and had to be restarted to recover.
462
463The final one is the dozen times I place a debugger breakpoint and the
464variable name `hello` gets turned into
465`__TURBOPACK__imported__module__$5b$project$5d2f$client$2f$src$2f$utils$2f$filename$2e$ts__$5b$app$2d$client$5d$__$28$ecmascript$29$__["hello"]`
466and other bullshit.
467
468Okay. This all sucks. What can we do?
469
470<h2 id='ditching-nextjs'>Seamlessly Ditching Next.js and Vercel at Work</>
471
472There are two types of web projects:
473
474- A web site with mostly static content.
475- A web app with majorly dynamic and interactive components.
476
477And Next.js is the wrong tool for both of these jobs. If you're in the first
478category with a static web site, go for [Astro] or [Fresh]. For everyone who
479needs the full power of React, this section is about how I replaced the vendor
480locked Next with [TanStack Start], incrementally and seamlessly.
481
482[Astro]: https://astro.build/
483[Fresh]: https://fresh.deno.dev/
484[TanStack Start]: https://tanstack.com/start/latest
485
486It started with this Vite config.
487
488```ts filename="vite.config.ts"
489const config = defineConfig(({ mode }) => {
490 const env = loadEnv(mode, process.cwd(), "NEXT_PUBLIC_");
491 return {
492 // Use the Next.js default port 3000
493 server: { port: 3000 },
494 // Use the Next.js default env prefix "NEXT_PUBLIC_"
495 define: Object.fromEntries(Object.entries(env).map(
496 ([k, v]) => [`process.env.${k}`, JSON.stringify(v)])),
497 plugins: [
498 viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
499 tailwindcss(),
500 // For ease of understanding from coworkers, I started porting
501 // the routes in `src/tanstack-routes`. When the migration was
502 // done, it would go back to the default `src/routes`.
503 tanstackStart({
504 router: { routesDirectory: "src/tanstack-routes" },
505 }),
506 viteReact(),
507 ],
508 resolve: {
509 // The key to the incremental migration: redirect `next` elsewhere
510 alias: { next: path.resolve("./src/tanstack-next/") },
511 conditions: ["tanstack"],
512 extensions: [
513 // Allow a file named like `utils/session.tanstack.ts` to
514 // override `utils/session.ts` when imported.
515 ".tanstack.tsx", ".tanstack.ts",
516 // Default import extensions
517 ".mjs", ".js", ".mts", ".ts",
518 ".jsx", ".tsx", ".json",
519 ],
520 },
521 };
522});
523```
524
525Then, I looked for every usage of a Next.js API, and either removed it or made
526a stub for TanStack. For example, `src/tanstack-next/link.tsx` implements
527`next/link`:
528
529```tsx filename="src/tanstack-next/link.tsx"
530import { Link } from "@tanstack/react-router";
531import type { LinkProps } from "next/link";
532
533export default function LinkAdapter({ href, ...rest }: LinkProps) {
534 return <Link {...rest} to={href as unknown as any} />;
535}
536```
537
538> Some of these stubs can be extremely simple. Starting out, my implementation
539> of `useRouter` was just `return {}`, but later I had to add a couple methods
540> to the object. The code here doesn't have to be clean, because it is
541> temporary.
542
543Now, the new site can import nearly every client component by either stubbing
544out the Next.js APIs it needs, or by using the `.tanstack.ts` extension to
545re-implement logic on a file-by-file basis. And shortly after, I got the site's
546homepage to work in TanStack Start, and we merged the branch.
547
548![My "nextgate" PR](/file/2025/blog-everyone-hates-nextjs/pr.png)
549
550> This first PR only supported one of our pages, and was able to do it in a
551> thousand lines of added code, and 40 lines deleted. I had previous patches to
552> remove the few uses of `next/image` and `next/font`.
553
554What was left was porting every other route over. The one thing we lose in
555migrating from Next.js to any other framework is the ability to `await`
556data-fetching functions in the UI. In practice, moving every route into a
557`loader` function made it much more clear what happened when a page was SSR'd.
558For pages that had multiple fetches, these could be combined into a single,
559special API call that would return all of the relevant data for that page.
560
561To re-iterate in bold font: <strong style='color:var(--secondary)'>The
562migration path from Server Components is to just simplify your code &mdash; RSC
563inherently drives you down a chaotic road of things you do not need</strong>.
564Nearly every complex part of our site got easier to understand for all
565engineers. The exception to this was having everyone get used to the new file
566system routing conventions. With enough examples, we all got the hang of it.
567
568With the incremental migration in place, new code did not break the existing
569deployment. TanStack slowly took over the codebase, and we eventually deleted
570all of the Next.js stubs and gained all of the beautiful [type-safety features]
571that the TanStack Router provides. At the end, the site performed faster from
572every angle: Development Mode, Production page load times, Soft navigations,
573and at a lower price than our Next depoyment with Vercel.
574
575[type-safety features]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety
576
577We're not the only ones seeing the change. While I try and keep myself off of
578social media, someone sent me [the results of Brian Anglin's work at
579Superwall][superwall-twitter], showing incredible CPU reductions on TanStack
580Start. I also recall ChatGPT switching from Next.js to Remix (random online
581chatter: [[1][chatgpt-1]] [[2][chatgpt-2]] [[3][chatgpt-3]]) a year ago.
582
583[superwall-twitter]: https://twitter.com/BriansAngles/status/1978834116079436242#m
584[chatgpt-1]: https://xcancel.com/ryanflorence/status/1831379475654947233
585[chatgpt-2]: https://old.reddit.com/r/reactjs/comments/1f97zgr/chatgpt_migrates_from_nextjs_to_remix
586[chatgpt-3]: https://old.reddit.com/r/nextjs/comments/1f92jdv/chatgptcom_switched_from_nextjs_to_remix
587
588<h3 id='next-metadata'>`next/metadata` is Great</>
589
590In my opinion, this is one of the only good APIs Next.js has, and was the one
591place in our code where moving to TanStack made things harder to do. Instead of
592worsening the code, I just ported their metadata API into a regular function,
593so everyone can use it. Originally, I had a 1:1 port on NPM, but earlier this
594year I simplified it's API into one short and understandable
595file. As of this blog post, I have added a TanStack-compatible
596`meta.toTags` API, which can be installed from [JSR][lib-jsr], [NPM][lib-npm],
597or simply copied into your project.
598
599> **notice**: Due to time constraints with writing this article, the library
600> has not yet been updated. I'll probably get around to it by the ~~end of this
601> week (Oct 24th)~~ some time soon... As a placeholder, I'm able to share the
602> version that is used at work to my website:
603> [`meta.tanstack.ts`](https://paperclover.net/file/2025/blog-everyone-hates-nextjs/meta.tanstack.ts).
604
605```tsx
606// once in your project
607import * as meta from "@clo/lib/meta";
608
609export const defineHead = meta.toTags.bind(null, {
610 // site-wide options
611 base: new URL("https://paperclover.net"),
612 titleTemplate: (title) => [title, "paper clover"]
613 .filter(Boolean).join(' | '),
614 // ...
615});
616
617// for each page...
618export const Route = createFileRoute("/blog")({
619 head: () =>
620 defineHead({
621 title: "clover's blog", // templated with `titleTemplate`
622 description: "a catgirl meows about her technology viewpoints",
623 canonical: "/blog", // joined with `base`
624
625 // When specified, configures Open Graph and Twitter embed,
626 // using the page title and description as the default.
627 // The defaults are good, but it supports more options.
628 embed: {
629 image: "/img/blog.webp",
630 },
631
632 // Every exotic meta tag is done with a JSX fragment. This
633 // doesn't render React, it just loops through the tags.
634 // My goal was to cover the most common 99% of uses.
635 extra: <>
636 <meta name="site-verification" content="waffles" />,
637 </>,
638 }),
639
640 component: Page,
641});
642
643function Page() {
644 ...
645}
646```
647
648My version wasn't concerned with covering the entire space of Next.js's metadata
649object, but instead uses inline JSX to fill that gap.
650
651[lib-jsr]: https://jsr.io/@clo/lib
652[lib-npm]: https://npmjs.com/@paperclover/lib
653
654<h3 id='vercel-og'>`next/og` is Good Too</>
655
656No strong opinions. I just want to remind everyone that the `@vercel/og` package exists.
657
658<h2 id='experience-feels-like-the-usual'>My Experience Feels like the Usual</>
659
660At the Next.js Conf 2024, everyone there was raving about Server Components. I
661forget exactly who I talked to, but the big people were all in on this. I,
662having implemented the bundler end of RSC, saw a couple of the problems in the
663format. With Next 15 "stabilizing" the App Router last year, many companies are
664building their products on it, realizing these pitfalls first-hand.
665
666I came into the Next.js game late, only starting in June with version 15.
667But everyone I've talked to at events sympathize with my notes. All the people
668I talked to on the subject at Bun's 1.3 Party agreed with me. Even some people
669at Vercel told me they don't like how Next.js is to actually use.
670
671I hope as TanStack Start stabilizes, it becomes the Next.js replacement everyone
672wants.
673
674<h2 id='prefer-respectful-tools'>Prefer Tools that Respect You</>
675
676A lot of in the JavaScript ecosystem is a mess. That mess is why web
677development gets made fun of. There were a lot of times I thought that working
678with the web was an unrecoverable mess, but the mess was actually just the
679commonly-used libraries I surrounded myself with. When that is peeled back,
680modern web development technologies are awesome.
681
682I've been making this website from scratch without any framework since late
6832024, by writing systems like my own [TUI progress widget][progress], [static
684file proxy][file-cache], incremental build system, and many more components.
685Working on this code has produced some of my best coding sessions (by
686happiness) in years. The viewers of *[paper clover]* get a better quality
687website; the mini-libraries I create get [extracted for public use][lib],
688everyone wins.
689
690This level of from-scratch is too much for most people, especially at the
691workplace. I say that at the minimum, we should only give our attention and
692money to high quality tools that respect us. And Next.js and the company behind
693it, Vercel, are not that.
694
695If you use Next.js, and feel that the experience doesn't remind you of respect
696too, consider whether you and your colleagues want to continue supporting their
697[serverless empire]. The Vite ecosystem seems pretty decent to build on right
698now, but I still have little experience in using their tools at scale in
699production. The [Vite+ launch from Void0][vite-plus] seems interesting, but
700only time will tell if these venture-funded tools will respect us (end-users
701and developers) long term.
702
703Next.js Conf 2025, as of writing, is [tomorrow][next-conf]. Instead of
704purchasing a $800 ticket, I decided to put that money [toward the TanStack
705team][tanstack-donate] for [respecting and improving the web development
706ecosystem][tanstack-ethos].
707
708[paper clover]: https://paperclover.net/
709[lib]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib#readme
710[progress]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib/progress.ts
711[file-cache]: https://git.paperclover.net/clo/sitegen/src/branch/master/src/file-viewer/cache.ts
712
713[next-conf]: https://nextjs.org/conf
714[vite-plus]: https://viteplus.dev/
715[tanstack-ethos]: https://tanstack.com/ethos
716[tanstack-donate]: https://github.com/sponsors/tannerlinsley
717[serverless empire]: https://youtu.be/SCIfWhAheVw
718
719## What the Future Holds
720
721Slowly, I've been replacing many pieces of software that disrespect me with
722better alternatives. Some examples of this are GitHub, Visual Studio Code,
723DaVinci Resolve, Discord, Google Drive/Workspace, along many more. I plan to
724write more on this blog about the technical things I do (that progress library,
725the purpose of my own site generator, learnings from my current job), including
726some of my past projects at Bun (details on HMR, the crash reporter, and the
727crazy system for bundling built-in modules). If it interests you, please
728subscribe to the email list:
729
730<a href="mailto:subscribe@paperclover.net?subject=paper%20clover%20mailing%20list&body=I%20would%20like%20to%20be%20subscribed%20to%20the%20following%20mailing%20lists%3A%0A%0A-%20Technical%20Blog%20Posts%20-%20YES%0A-%20Art%20(Original%20Music%2FVideo)%20-%20YES%0A%0A(feel%20free%20to%20write%20whatever%20else%20you%20want)">click here to send an email to <code>subscribe@paperclover.net</code>, requesting that you would like to be added to the mailing list.</a> (i manage this mailing list manually)
731
732[back to top](#top) &mdash; [ask a question about this article](/q+a)
733
734<br />
735<br />
736<br />
737<br />
738<br />
739<br />
740<footer>
7412025 (c) paper clover
742</footer>