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