| 1 | --- |
| 2 | layout: "#src/blog/tags/blog-layout.marko" |
| 3 | meta: |
| 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 |
| 11 | date: "Oct 21st, 2025" |
| 12 | --- |
| 13 | |
| 14 | As I've been using [Next.js] professionally on my employer's web app, I find the |
| 15 | core design of their App Router and [React Server Components] (RSC) to be |
| 16 | extremely frustrating. And it's not small bugs or that the API is confusing, |
| 17 | but large disagreements about the fundamental design decisions that Vercel and |
| 18 | the React team made when building it. |
| 19 | |
| 20 | The more webdev events I go to, the more I see people who dislike Next.js, but |
| 21 | still get stuck using it. By the end of this article, I will share how me and |
| 22 | my 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 | |
| 32 | The pitch of RSC is that components are put into two categories, |
| 33 | <b.server>"server"</b> components and <b.client>"client"</b> |
| 34 | components. Server components don't have `useState`, `useEffect`, but can be |
| 35 | `async function`s and refer to backend tools like directly calling into a |
| 36 | database. Client components are the existing |
| 37 | model, where there is code on the backend to generate HTML text and frontend |
| 38 | code 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 | |
| 48 | This <b.server>Server</b>/<b.client>Client</b> component model |
| 49 | is interesting. Since built-ins like `<Suspense />` get serialized across the |
| 50 | network, data fetching can be very trivially modeled with async |
| 51 | <b.server>server components</b>, and the fallback UI works as if it were |
| 52 | client-side. |
| 53 | |
| 54 | ```tsx filename="src/app/[username]/page.tsx" tint="server" |
| 55 | // For this article, server components will be highlighted in red |
| 56 | export 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 | |
| 75 | async 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 | |
| 83 | async function UserPostList({ username }) { |
| 84 | const posts = await fetchUserPostList(username); |
| 85 | return /* post list ui omitted for brevity */; |
| 86 | } |
| 87 | ``` |
| 88 | |
| 89 | If we ignore the 40kB gzipped bundle size of React itself, the above example |
| 90 | has zero JavaScript for the UI and data fetching &mdash; it just streams the |
| 91 | markup! For example, the imagined markdown parser within the `<Markdown />` |
| 92 | component stays on the backend. When an interactive frontend is needed, <b.client>Client |
| 93 | components</b> can be created by putting them in a file starting with `"use |
| 94 | client"`. |
| 95 | |
| 96 | ```tsx filename="src/components/CopyButton.tsx" tint="client" |
| 97 | "use client"; // This comment marks the file for client-side bundling. |
| 98 | |
| 99 | export 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" |
| 111 | export 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 | |
| 127 | After quitting [Bun] as a runtime engineer (I implemented [Server Components |
| 128 | bundling] and [a RSC template][bun-rsc] there), I joined a small company working on the |
| 129 | front lines: a Next.js app with a Hono backend. The following notes are |
| 130 | simplifications from the real world problems I've encountered when trying to |
| 131 | maintain and develop new features. As a result of all of these, everyone's time |
| 132 | is wasted either working around design flaws, or explaining to each other why |
| 133 | what 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 | |
| 141 | The Next.js documentation for performing mutations [does not mention optimistic |
| 142 | updates][nextjs-updating-data]; it appears this case was not thought about. |
| 143 | Components rendered by the <b.server>React Server</b>, by design, can |
| 144 | not be modified after mounting. Elements that could change need to be inside a |
| 145 | client component, but data fetching cannot happen on the client components, |
| 146 | even during SSR on the backend. This results in awkwardly small server |
| 147 | components that only do data fetching and then have a client component that |
| 148 | contains a mostly-static version of the page. |
| 149 | |
| 150 | ```tsx filename="src/app/user/[username]/page.tsx" tint="server" |
| 151 | |
| 152 | export 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 | |
| 163 | export 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 | |
| 182 | As more of the page needs interactivity, it gets messier trying to keep the |
| 183 | static parts truly server-side. On the work app, nearly every piece of UI |
| 184 | displays some dynamic data. A [`WebSocket`][ws] synchronizes data live as it |
| 185 | updates (for example, a user card's online state along with their basic |
| 186 | profile). Since these component setups are harder to understand and maintain |
| 187 | for engineers, almost all of our pages are entirely `"use client"` with a |
| 188 | `page.tsx` that defines the data fetching. |
| 189 | |
| 190 | A more concrete example of what this looks like in practice with the |
| 191 | data-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. |
| 198 | export 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" |
| 204 | export 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"; |
| 222 | export 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 | |
| 234 | This example has to be three separate files because of the rules of server |
| 235 | component bundling. (The client component needs `"use client"`, and server |
| 236 | component files often can't be imported on the client due to server-only |
| 237 | imports.). In the Pages router, this could've been a single file because of the |
| 238 | tree-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 | |
| 245 | Since the App Router starts every page as a server component, with (ideally) |
| 246 | small areas of interactivity, a navigation to a new page *has* to fetch the |
| 247 | Next.js server, regardless of what data the client already has available! Even |
| 248 | with a a `loading.tsx` file, opening `/`, navigating to `/other`, and then |
| 249 | going back to `/` will show the loading state while it re-fetches the homepage. |
| 250 | |
| 251 | The only case this works is for **perfectly static content**, where instant |
| 252 | navigations and prefetching work great. But **web apps are not static**, they |
| 253 | have lots of dynamic content. Being logged in affects the homepage, which is |
| 254 | infuriating because the client literally has everything needed to display the |
| 255 | page 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 | |
| 287 | The solution to this appears to be [`staleTime`][nextjs-stale], but it's marked |
| 288 | experimental and "not recommended for production". The fact this is a |
| 289 | non-default afterthought configuration option is embarrassing. Even if we used |
| 290 | it, you cannot make multiple pages that refer to the same underlying data share |
| 291 | any of it. |
| 292 | |
| 293 | One form of loading state that cannot be represented with the App Router is |
| 294 | having a page such as a page like a git project's issue page, and clicking on a |
| 295 | user name to navigate to their profile page. With `loading.tsx`, the entire |
| 296 | page is a skeleton, but when modeling these queries with TanStack Query it is |
| 297 | possible to show the username and avatar instantly while the user's bio and |
| 298 | repositories are fetched in. Server components don't support this form of |
| 299 | navigation because the data is only available in rendered components, so it |
| 300 | must 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 | |
| 305 | In our Next.js site, we have this line of code on our server component data |
| 306 | fetchers to make soft navigations faster by skipping the data fetch phase all |
| 307 | together. |
| 308 | |
| 309 | ```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server" |
| 310 | export 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 | |
| 323 | In addition to this, `loading.tsx` should contain the `useQuery` calls so that |
| 324 | while the network request for the empty RSC happens, the data is being fetched |
| 325 | if it actually is needed. In fact, the `loading.tsx` state can just be the |
| 326 | actual client component, and you'll see the client page. |
| 327 | |
| 328 | ```tsx filename="src/app/user/[username]/loading.tsx" tint="client" |
| 329 | "use client"; |
| 330 | export 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 | |
| 345 | Layouts can perform data fetching, but they can't observe or alter the request |
| 346 | in any way. This is done so that Next.js can fetch and cache layouts whenever they |
| 347 | want. In every other framework, layouts are just regular components that have |
| 348 | no feature difference compared to page components. |
| 349 | |
| 350 | Fetching layouts in isolation is a cute idea, but it ends up being silly |
| 351 | because it also means that any data fetching has to be re-done per layout. You |
| 352 | can'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 | |
| 355 | When a coworker asks me about why Next.js rejects some code, I've given up on |
| 356 | explaining the technical intricacies and just say *"It's a Next.js Skill Issue, |
| 357 | I'm going to blow it up soon don't worry."* These rules are too hard for normal |
| 358 | developers 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 | |
| 364 | Unlike the ["Islands Architecture"][islands], Server Components still have to |
| 365 | be hydrated on the frontend to support `Suspense` and preserving client |
| 366 | component state. When doing soft navigations, the "RSC Payload" (which is not |
| 367 | HTML 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 |
| 369 | not contained within that HTML. React's solution is to **send a second copy of |
| 370 | the entire page's markup**. An example of what a Next.js production server |
| 371 | would 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 | |
| 413 | This solution **doubles the size of the initial HTML payload**. Except it's |
| 414 | worse, because the RSC payload includes JSON quoted in JS string literals, |
| 415 | which is a is much less efficient format than HTML. While it seems to compress |
| 416 | fine with brotli and render fast in the browser, this is wasteful. With the |
| 417 | hydration pattern, at least the data locally could be re-used for interactivity |
| 418 | and other pages. |
| 419 | |
| 420 | Even on pages that have little to no interactivity, you pay the cost. To use |
| 421 | the Next.js documentation as an example, loading [its |
| 422 | homepage](https://nextjs.org/docs) loads an page that is around 750kB (250kB of |
| 423 | HTML and the 500kB of script tags), and content is in there twice. |
| 424 | |
| 425 | You can verify that by pressing <kbd>Cmd</kbd> + <kbd>Opt</kbd> + <kbd>u</kbd> |
| 426 | on 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 |
| 428 | blog, 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 |
| 430 | Components. |
| 431 | |
| 432 | This RSC format certainly has more waste. But I really don't feel like digging into |
| 433 | why the string `/_next/static/chunks/6192a3719cda7dcc.js` appears 27 separate |
| 434 | times. 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 | |
| 440 | This 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 | |
| 446 | I wouldn't have given this point a section in the blog normally, but I want to |
| 447 | point out three actual examples directly from the project. |
| 448 | |
| 449 | The first is a place where during some refactoring to satisfy the Server/Client |
| 450 | component models, I accidentally made a Client component `async`. This one was |
| 451 | quite annoying because it didn't say at all where the issue was, but only |
| 452 | contained the <b.server>server</b> stack trace. |
| 453 | |
| 454 |  |
| 455 | |
| 456 | Another case of a terrible error message: |
| 457 | |
| 458 |  |
| 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 | |
| 463 | The final one is the dozen times I place a debugger breakpoint and the |
| 464 | variable 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"]` |
| 466 | and other bullshit. |
| 467 | |
| 468 | Okay. This all sucks. What can we do? |
| 469 | |
| 470 | <h2 id='ditching-nextjs'>Seamlessly Ditching Next.js and Vercel at Work</> |
| 471 | |
| 472 | There 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 | |
| 477 | And Next.js is the wrong tool for both of these jobs. If you're in the first |
| 478 | category with a static web site, go for [Astro] or [Fresh]. For everyone who |
| 479 | needs the full power of React, this section is about how I replaced the vendor |
| 480 | locked 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 | |
| 486 | It started with this Vite config. |
| 487 | |
| 488 | ```ts filename="vite.config.ts" |
| 489 | const 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 | |
| 525 | Then, I looked for every usage of a Next.js API, and either removed it or made |
| 526 | a stub for TanStack. For example, `src/tanstack-next/link.tsx` implements |
| 527 | `next/link`: |
| 528 | |
| 529 | ```tsx filename="src/tanstack-next/link.tsx" |
| 530 | import { Link } from "@tanstack/react-router"; |
| 531 | import type { LinkProps } from "next/link"; |
| 532 | |
| 533 | export 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 | |
| 543 | Now, the new site can import nearly every client component by either stubbing |
| 544 | out the Next.js APIs it needs, or by using the `.tanstack.ts` extension to |
| 545 | re-implement logic on a file-by-file basis. And shortly after, I got the site's |
| 546 | homepage to work in TanStack Start, and we merged the branch. |
| 547 | |
| 548 |  |
| 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 | |
| 554 | What was left was porting every other route over. The one thing we lose in |
| 555 | migrating from Next.js to any other framework is the ability to `await` |
| 556 | data-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. |
| 558 | For pages that had multiple fetches, these could be combined into a single, |
| 559 | special API call that would return all of the relevant data for that page. |
| 560 | |
| 561 | To re-iterate in bold font: <strong style='color:var(--secondary)'>The |
| 562 | migration path from Server Components is to just simplify your code &mdash; RSC |
| 563 | inherently drives you down a chaotic road of things you do not need</strong>. |
| 564 | Nearly every complex part of our site got easier to understand for all |
| 565 | engineers. The exception to this was having everyone get used to the new file |
| 566 | system routing conventions. With enough examples, we all got the hang of it. |
| 567 | |
| 568 | With the incremental migration in place, new code did not break the existing |
| 569 | deployment. TanStack slowly took over the codebase, and we eventually deleted |
| 570 | all of the Next.js stubs and gained all of the beautiful [type-safety features] |
| 571 | that the TanStack Router provides. At the end, the site performed faster from |
| 572 | every angle: Development Mode, Production page load times, Soft navigations, |
| 573 | and 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 | |
| 577 | We're not the only ones seeing the change. While I try and keep myself off of |
| 578 | social media, someone sent me [the results of Brian Anglin's work at |
| 579 | Superwall][superwall-twitter], showing incredible CPU reductions on TanStack |
| 580 | Start. I also recall ChatGPT switching from Next.js to Remix (random online |
| 581 | chatter: [[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 | |
| 590 | In my opinion, this is one of the only good APIs Next.js has, and was the one |
| 591 | place in our code where moving to TanStack made things harder to do. Instead of |
| 592 | worsening the code, I just ported their metadata API into a regular function, |
| 593 | so everyone can use it. Originally, I had a 1:1 port on NPM, but earlier this |
| 594 | year I simplified it's API into one short and understandable |
| 595 | file. 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], |
| 597 | or 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 |
| 607 | import * as meta from "@clo/lib/meta"; |
| 608 | |
| 609 | export 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... |
| 618 | export 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 | |
| 643 | function Page() { |
| 644 | ... |
| 645 | } |
| 646 | ``` |
| 647 | |
| 648 | My version wasn't concerned with covering the entire space of Next.js's metadata |
| 649 | object, 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 | |
| 656 | No 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 | |
| 660 | At the Next.js Conf 2024, everyone there was raving about Server Components. I |
| 661 | forget exactly who I talked to, but the big people were all in on this. I, |
| 662 | having implemented the bundler end of RSC, saw a couple of the problems in the |
| 663 | format. With Next 15 "stabilizing" the App Router last year, many companies are |
| 664 | building their products on it, realizing these pitfalls first-hand. |
| 665 | |
| 666 | I came into the Next.js game late, only starting in June with version 15. |
| 667 | But everyone I've talked to at events sympathize with my notes. All the people |
| 668 | I talked to on the subject at Bun's 1.3 Party agreed with me. Even some people |
| 669 | at Vercel told me they don't like how Next.js is to actually use. |
| 670 | |
| 671 | I hope as TanStack Start stabilizes, it becomes the Next.js replacement everyone |
| 672 | wants. |
| 673 | |
| 674 | <h2 id='prefer-respectful-tools'>Prefer Tools that Respect You</> |
| 675 | |
| 676 | A lot of in the JavaScript ecosystem is a mess. That mess is why web |
| 677 | development gets made fun of. There were a lot of times I thought that working |
| 678 | with the web was an unrecoverable mess, but the mess was actually just the |
| 679 | commonly-used libraries I surrounded myself with. When that is peeled back, |
| 680 | modern web development technologies are awesome. |
| 681 | |
| 682 | I've been making this website from scratch without any framework since late |
| 683 | 2024, by writing systems like my own [TUI progress widget][progress], [static |
| 684 | file proxy][file-cache], incremental build system, and many more components. |
| 685 | Working on this code has produced some of my best coding sessions (by |
| 686 | happiness) in years. The viewers of *[paper clover]* get a better quality |
| 687 | website; the mini-libraries I create get [extracted for public use][lib], |
| 688 | everyone wins. |
| 689 | |
| 690 | This level of from-scratch is too much for most people, especially at the |
| 691 | workplace. I say that at the minimum, we should only give our attention and |
| 692 | money to high quality tools that respect us. And Next.js and the company behind |
| 693 | it, Vercel, are not that. |
| 694 | |
| 695 | If you use Next.js, and feel that the experience doesn't remind you of respect |
| 696 | too, consider whether you and your colleagues want to continue supporting their |
| 697 | [serverless empire]. The Vite ecosystem seems pretty decent to build on right |
| 698 | now, but I still have little experience in using their tools at scale in |
| 699 | production. The [Vite+ launch from Void0][vite-plus] seems interesting, but |
| 700 | only time will tell if these venture-funded tools will respect us (end-users |
| 701 | and developers) long term. |
| 702 | |
| 703 | Next.js Conf 2025, as of writing, is [tomorrow][next-conf]. Instead of |
| 704 | purchasing a $800 ticket, I decided to put that money [toward the TanStack |
| 705 | team][tanstack-donate] for [respecting and improving the web development |
| 706 | ecosystem][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 | |
| 721 | Slowly, I've been replacing many pieces of software that disrespect me with |
| 722 | better alternatives. Some examples of this are GitHub, Visual Studio Code, |
| 723 | DaVinci Resolve, Discord, Google Drive/Workspace, along many more. I plan to |
| 724 | write more on this blog about the technical things I do (that progress library, |
| 725 | the purpose of my own site generator, learnings from my current job), including |
| 726 | some of my past projects at Bun (details on HMR, the crash reporter, and the |
| 727 | crazy system for bundling built-in modules). If it interests you, please |
| 728 | subscribe 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> |
| 741 | 2025 (c) paper clover |
| 742 | </footer> |