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