authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-22 16:52:51-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-27 01:42:00-07:00
logb83d921d19b5312662def9fdb5d51a9885b187dd
tree6c34c17d6164b8d7cde1752851ac3ad8d3e29796
parentba98937008851c4ff2471c9823c751b792f2c3f1
signature Commit is signed but in an unrecognized format.

chore: rename blog post

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,6 +174,7 @@ function loadMarko(module: NodeJS.Module, filepath: string) {
174174
175function loadMdx(module: NodeJS.Module, filepath: string) {175function loadMdx(module: NodeJS.Module, filepath: string) {
176 const input = fs.readFileSync(filepath);176 const input = fs.readFileSync(filepath);
177 console.log(filepath);
177 const out = mdx.compileSync(input, {178 const out = mdx.compileSync(input, {
178 jsxImportSource: "#jsx",179 jsxImportSource: "#jsx",
179 providerImportSource: "#mdx",180 providerImportSource: "#mdx",
src/bin/deploy.ts+1-1
...@@ -4,7 +4,7 @@ export async function main() {...@@ -4,7 +4,7 @@ export async function main() {
4 args: [4 args: [
5 "-a",5 "-a",
6 "--progress",6 "--progress",
7 "clo@zenith:/mnt/storage1/clover/Documents/Config/paperclover/cache.sqlite",7 "clo@paperclover.net:~/paperclover.net/.clover/cache.sqlite",
8 ".clover/",8 ".clover/",
9 ],9 ],
10 progress: progress.start("download file cache"),10 progress: progress.start("download file cache"),
src/blog/blog.css+1
...@@ -23,6 +23,7 @@ p {...@@ -23,6 +23,7 @@ p {
23 display: flex;23 display: flex;
24 gap: 0.5rem;24 gap: 0.5rem;
25 font-size: 80%;25 font-size: 80%;
26 flex-wrap: wrap;
26}27}
27.tag {28.tag {
28 background-color: #0005;29 background-color: #0005;
src/blog/layout.tsx+2-2
...@@ -12,9 +12,9 @@ export function Layout({ meta: { title, description }, date, tags, children }) {...@@ -12,9 +12,9 @@ export function Layout({ meta: { title, description }, date, tags, children }) {
12 <main>12 <main>
13 <header>13 <header>
14 {/* <a href="/blog">back to clover's garden</a> */}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>
1616
17 <h1>{title}</h1>17 <h1 style="max-width: 30ch">{title}</h1>
18 <p class="description">18 <p class="description">
19 <em>{description}</em>19 <em>{description}</em>
20 </p>20 </p>
src/blog/pages/webdev/everyone-hates-nextjs.mdx deleted-831
...@@ -1,831 +0,0 @@
1import TableOfContents from "../../tags/table-of-contents.tsx";
2import Heading from "../../tags/heading.tsx";
3import { Layout } from "../../layout.tsx";
4export { theme } from "../../layout.tsx";
5
6export 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
25As I've been using [Next.js] professionally on my employer's web app, I find the
26core design of their App Router and [React Server Components] (RSC) to be
27extremely frustrating. And it's not small bugs or that the API is confusing,
28but large disagreements about the fundamental design decisions that Vercel and
29the React team made when building it.
30
31The more webdev events I go to, the more I see people who dislike Next.js, but
32still get stuck using it. By the end of this article, I will share how me and
33my 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
63The pitch of RSC is that components are put into two categories,
64<b class='server'>"server"</b> components and <b class='client'>"client"</b>
65components. Server components don't have `useState`, `useEffect`, but can be
66`async function`s and refer to backend tools like directly calling into a
67database. Client components are the existing
68model, where there is code on the backend to generate HTML text and frontend
69code 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
79This <b class='server'>Server</b>/<b class='client'>Client</b> component model
80is interesting. Since built-ins like `<Suspense />` get serialized across the
81network, data fetching can be very trivially modeled with async <b
82class='server'>server components</b>, and the fallback UI works as if it were
83client-side.
84
85```tsx filename="src/app/[username]/page.tsx" tint="server"
86// For this article, server components will be highlighted in red
87export 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
106async 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
114async function UserPostList({ username }) {
115 const posts = await fetchUserPostList(username);
116 return /* post list ui omitted for brevity */;
117}
118```
119
120If we ignore the 40kB gzipped bundle size of React itself, the above example
121has zero JavaScript for the UI and data fetching &mdash; it just streams the
122markup! For example, the imagined markdown parser within the `<Markdown />`
123component stays on the backend. When an interactive frontend is needed, <b class='client'>Client
124components</b> can be created by putting them in a file starting with `"use
125client"`.
126
127```tsx filename="src/components/CopyButton.tsx" tint="client"
128"use client"; // This comment marks the file for client-side bundling.
129
130export 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"
142export 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
163After quitting [Bun] as a runtime engineer (I implemented [Server Components
164bundling] and [a RSC template][bun-rsc] there), I joined a small company working on the
165front lines: a Next.js app with a Hono backend. The following notes are
166simplifications from the real world problems I've encountered when trying to
167maintain and develop new features. As a result of all of these, everyone's time
168is wasted either working around design flaws, or explaining to each other why
169what 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
182The Next.js documentation for performing mutations [does not mention optimistic
183updates][nextjs-updating-data]; it appears this case was not thought about.
184Components rendered by the <b class='server'>React Server</b>, by design, can
185not be modified after mounting. Elements that could change need to be inside a
186client component, but data fetching cannot happen on the client components,
187even during SSR on the backend. This results in awkwardly small server
188components that only do data fetching and then have a client component that
189contains a mostly-static version of the page.
190
191```tsx filename="src/app/user/[username]/page.tsx" tint="server"
192
193export 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
204export 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
223As more of the page needs interactivity, it gets messier trying to keep the
224static parts truly server-side. On the work app, nearly every piece of UI
225displays some dynamic data. A [`WebSocket`][ws] synchronizes data live as it
226updates (for example, a user card's online state along with their basic
227profile). Since these component setups are harder to understand and maintain
228for engineers, almost all of our pages are entirely `"use client"` with a
229`page.tsx` that defines the data fetching.
230
231A more concrete example of what this looks like in practice with the
232data-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.
239export 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"
245export 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";
263export 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
275This example has to be three separate files because of the rules of server
276component bundling. (The client component needs `"use client"`, and server
277component files often can't be imported on the client due to server-only
278imports.). In the Pages router, this could've been a single file because of the
279tree-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
292Since the App Router starts every page as a server component, with (ideally)
293small areas of interactivity, a navigation to a new page *has* to fetch the
294Next.js server, regardless of what data the client already has available! Even
295with a a `loading.tsx` file, opening `/`, navigating to `/other`, and then
296going back to `/` will show the loading state while it re-fetches the homepage.
297
298The only case this works is for **perfectly static content**, where instant
299navigations and prefetching work great. But **web apps are not static**, they
300have lots of dynamic content. Being logged in affects the homepage, which is
301infuriating because the client literally has everything needed to display the
302page 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
334The solution to this appears to be [`staleTime`][nextjs-stale], but it's marked
335experimental and "not recommended for production". The fact this is a
336non-default afterthought configuration option is embarassing. Even it we used
337it, you cannot make multiple pages that refer to the same underlying data share
338any of it.
339
340One form of loading state that cannot be represented with the App Router is
341having a page such as a page like a git project's issue page, and clicking on a
342user name to navigate to their profile page. With `loading.tsx`, the entire
343page is a skeleton, but when modeling these queries with TanStack Query it is
344possible to show the username and avatar instantly while the user's bio and
345repositories are fetched in. Server components don't support this form of
346navigation because the data is only available in rendered components, so it
347must 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
352In our Next.js site, we have this line of code on our server component data
353fetchers to make soft navigations faster by skipping the data fetch phase all
354together.
355
356```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server"
357export 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
370In addition to this, `loading.tsx` should contain the `useQuery` calls so that
371while the network request for the empty RSC happens, the data is being fetched
372if it actually is needed. In fact, the `loading.tsx` state can just be the
373actual client component, and you'll see the client page.
374
375```tsx filename="src/app/user/[username]/loading.tsx" tint="client"
376"use client";
377export 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
397Layouts can perform data fetching, but they can't observe or alter the request
398in any way. This is done so that Next.js can fetch and cache layouts whenever they
399wants. In every other framework, layouts are just regular components that have
400no feature difference compared to page components.
401
402Fetching layouts in isolation is a cute idea, but it ends up being silly
403because it also means that any data fetching has to be re-done per layout. You
404can'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
407When a coworker asks me about why Next.js rejects some code, I've given up on
408explaining the technical intricacies and just say *"It's a Next.js Skill Issue,
409I'm going to blow it up soon don't worry."* These rules are too hard for normal
410developers 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
421Unlike the ["Islands Architecture"][islands], Server Components still have to
422be hydrated on the frontend to support `Suspense` and preserving client
423component state. When doing soft navigations, the "RSC Payload" (which is not
424HTML 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
426not contained within that HTML. React's solution is to **send a second copy of
427the entire page's markup**. An example of what a Next.js production server
428would 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
470This solution **doubles the size of the initial HTML payload**. Except it's
471worse, because the RSC payload includes JSON quoted in JS string literals, which
472format is much less efficient than HTML. While it seems to compress fine
473with brotli and render fast in the browser, this is wasteful. With the
474hydration pattern, at least the data locally could be re-used for interactivity
475and other pages.
476
477Even on pages that have little to no interactivity, you pay the cost. To use
478the Next.js documentation as an example, loading [its
479homepage](https://nextjs.org/docs) loads an page that is around 750kB (250kB of
480HTML and the 500kB of script tags), and content is in there twice.
481
482You can verify that by pressing <kbd>Cmd</kbd> + <kbd>Opt</kbd> + <kbd>u</kbd>
483on 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
485blog, 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
487Components.
488
489This RSC format certainly has more waste. But I really don't feel like digging into
490why the string `/_next/static/chunks/6192a3719cda7dcc.js` appears 27 separate
491times. 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
502This 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
508I wouldn't have given this point a section in the blog normally, but I want to
509point out three actual examples directly from the project.
510
511The first is a place where during some refactoring to satisfy the Server/Client
512component models, I accidentally made a Client component `async`. This one was
513quite anoying because it didn't say at all where the issue was, but only
514contained the <b class='server'>server</b> stack trace.
515
516![Next.js error](/file/2025/blog-everyone-hates-nextjs/asyncerror.png)
517
518Another case of a terrible error message:
519
520![Next.js error](/file/2025/blog-everyone-hates-nextjs/nexterror.png)
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
525The final one is the dozen times I place a debugger breakpoint and the
526variable 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"]`
528and other bullshit.
529
530Okay. 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
539There 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
544And Next.js is the wrong tool for both of these jobs. If you're in the first
545category with a static web site, go for [Astro] or [Fresh]. For everyone who
546needs the full power of React, this section is about how I replaced the vendor
547locked 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
553It started with this Vite config.
554
555```ts filename="vite.config.ts"
556const 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
592Then, I looked for every usage of a Next.js API, and either removed it or made
593a stub for TanStack. For example, `src/tanstack-next/link.tsx` implements
594`next/link`:
595
596```tsx filename="src/tanstack-next/link.tsx"
597import { Link } from "@tanstack/react-router";
598import type { LinkProps } from "next/link";
599
600export 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
610Now, the new site can import nearly every client component by either stubbing
611out the Next.js APIs it needs, or by using the `.tanstack.ts` extension to
612re-implement logic on a file-by-file basis. And shortly after, I got the site's
613homepage to work in TanStack Start, and we merged the branch.
614
615![My "nextgate" PR](/file/2025/blog-everyone-hates-nextjs/pr.png)
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
621What was left was porting every other route over. The one thing we lose in
622migrating from Next.js to any other framework is the ability to `await`
623data-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.
625For pages that had multiple fetches, these could be combined into a single,
626special API call that would return all of the relevant data for that page.
627
628To re-iterate in bold font: <strong style='color:var(--secondary)'>The
629migration path from Server Components is to just simplify your code &mdash; RSC
630inherently drives you down a chaotic road of things you do not need</strong>.
631Nearly every complex part of our site got easier to understand for all
632engineers. The exception to this was having everyone get used to the new file
633system routing conventions. With enough examples, we all got the hang of it.
634
635With the incremental migration in place, new code did not break the existing
636deployment. TanStack slowly took over the codebase, and we eventually deleted
637all of the Next.js stubs and gained all of the beautiful [type-safety features]
638that the TanStack Router provides. At the end, the site performed faster from
639every angle: Development Mode, Production page load times, Soft navigations,
640and 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
644We're not the only ones seeing the change. While I try and keep myself off of
645social media, someone sent me [the results of Brian Anglin's work at
646Superwall][superwall-twitter], showing incredible CPU reductions on TanStack
647Start. I also recall ChatGPT switching from Next.js to Remix (random online
648chatter: [[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
662In my opinion, this is one of the only good APIs Next.js has, and was the one
663place in our code where moving to TanStack made things harder to do. Instead of
664worsening the code, I just ported their metadata API into a regular function,
665so everyone can use it. Originally, I had a 1:1 port on NPM, but earlier this
666year I simplified it's API into one short and understandable
667file. 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],
669or 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
678import * as meta from "@clo/lib/meta.ts";
679
680export 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...
689export 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
712function Page() {
713 ...
714}
715```
716
717My version wasn't concerned with covering the entire space of Next.js's metadata
718object, 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
730No 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
739At the Next.js Conf 2024, everyone there was raving about Server Components. I
740forget exactly who I talked to, but the big people were all in on this. I,
741having implemented the bundler end of RSC, saw a couple of the problems in the
742format. With Next 15 "stabilizing" the App Router last year, many companies are
743building their products on it, realizing these pitfalls first-hand.
744
745I came into the Next.js game late, only starting in June with version 15.
746But everyone I've talked to at events sympathize with my notes. All the people
747I talked to on the subject at Bun's 1.3 Party agreed with me. Even some people
748at Vercel told me they don't like how Next.js is to actually use.
749
750I hope as TanStack Start stabilizes, it becomes the Next.js replacement everyone
751wants.
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
760A lot of in the JavaScript ecosystem is a mess. That mess is why web
761development gets made fun of. There were a lot of times I thought that working
762with the web was an unrecoverable mess, but the mess was actually just the
763commonly-used libraries I surrounded myself with. When that is peeled back,
764modern web development technologies are awesome.
765
766I've been making this website from scratch without any framework since late
7672024, by writing systems like my own [TUI progress widget][progress], [static
768file proxy][file-cache], incremental build system, and many more components.
769Working on this code has produced some of my best coding sessions (by
770happiness) in years. The viewers of *[paper clover]* get a better quality
771website; the mini-libraries I create get [extracted for public use][lib],
772everyone wins.
773
774This level of from-scratch is too much for most people, especially at the
775workplace. I say that at the minimum, we should only give our attention and
776money to high quality tools that respect us. And Next.js and the company behind
777it, Vercel, are not that.
778
779If you use Next.js, and feel that the experience doesn't remind you of respect
780too, consider whether you and your colleagues want to continue supporting their
781[serverless empire]. The Vite ecosystem seems pretty decent to build on right
782now, but I still have little experience in using their tools at scale in
783production. The [Vite+ launch from Void0][vite-plus] seems interesting, but
784only time will tell if these venture-funded tools will respect us (end-users
785and developers) long term.
786
787Next.js Conf 2025, as of writing, is [tomorrow][next-conf]. Instead of
788purchasing a $800 ticket, I decided to put that money [toward the TanStack
789team][tanstack-donate] for [respecting and improving the web development
790ecosystem][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
805Slowly, I've been replacing many pieces of software that disrespect me with
806better alternatives. Some examples of this are GitHub, Visual Studio Code,
807DaVinci Resolve, Discord, Google Drive/Workspace, along many more. I plan to
808write more on this blog about the technical things I do (that progress library,
809the purpose of my own site generator, learnings from my current job), including
810some of my past projects at Bun (details on HMR, the crash reporter, and the
811crazy system for bundling built-in modules). If it interests you, please
812subscribe 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>
8252025 (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 @@
1import TableOfContents from "../../tags/table-of-contents.tsx";
2import Heading from "../../tags/heading.tsx";
3import { Layout } from "../../layout.tsx";
4export { theme } from "../../layout.tsx";
5
6export 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
27As I've been using [Next.js] professionally on my employer's web app, I find the
28core design of their App Router and [React Server Components] (RSC) to be
29extremely frustrating. And it's not small bugs or that the API is confusing,
30but large disagreements about the fundamental design decisions that Vercel and
31the React team made when building it.
32
33The more webdev events I go to, the more I see people who dislike Next.js, but
34still get stuck using it. By the end of this article, I will share how me and
35my 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
65The pitch of RSC is that components are put into two categories,
66<b class='server'>"server"</b> components and <b class='client'>"client"</b>
67components. Server components don't have `useState`, `useEffect`, but can be
68`async function`s and refer to backend tools like directly calling into a
69database. Client components are the existing
70model, where there is code on the backend to generate HTML text and frontend
71code 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
81This <b class='server'>Server</b>/<b class='client'>Client</b> component model
82is interesting. Since built-ins like `<Suspense />` get serialized across the
83network, data fetching can be very trivially modeled with async <b
84class='server'>server components</b>, and the fallback UI works as if it were
85client-side.
86
87```tsx filename="src/app/[username]/page.tsx" tint="server"
88// For this article, server components will be highlighted in red
89export 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
108async 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
116async function UserPostList({ username }) {
117 const posts = await fetchUserPostList(username);
118 return /* post list ui omitted for brevity */;
119}
120```
121
122If we ignore the 40kB gzipped bundle size of React itself, the above example
123has zero JavaScript for the UI and data fetching &mdash; it just streams the
124markup! For example, the imagined markdown parser within the `<Markdown />`
125component stays on the backend. When an interactive frontend is needed, <b class='client'>Client
126components</b> can be created by putting them in a file starting with `"use
127client"`.
128
129```tsx filename="src/components/CopyButton.tsx" tint="client"
130"use client"; // This comment marks the file for client-side bundling.
131
132export 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"
144export 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
165After quitting [Bun] as a runtime engineer (I implemented [Server Components
166bundling] and [a RSC template][bun-rsc] there), I joined a small company working on the
167front lines: a Next.js app with a Hono backend. The following notes are
168simplifications from the real world problems I've encountered when trying to
169maintain and develop new features. As a result of all of these, everyone's time
170is wasted either working around design flaws, or explaining to each other why
171what 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
184The Next.js documentation for performing mutations [does not mention optimistic
185updates][nextjs-updating-data]; it appears this case was not thought about.
186Components rendered by the <b class='server'>React Server</b>, by design, can
187not be modified after mounting. Elements that could change need to be inside a
188client component, but data fetching cannot happen on the client components,
189even during SSR on the backend. This results in awkwardly small server
190components that only do data fetching and then have a client component that
191contains a mostly-static version of the page.
192
193```tsx filename="src/app/user/[username]/page.tsx" tint="server"
194
195export 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
206export 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
225As more of the page needs interactivity, it gets messier trying to keep the
226static parts truly server-side. On the work app, nearly every piece of UI
227displays some dynamic data. A [`WebSocket`][ws] synchronizes data live as it
228updates (for example, a user card's online state along with their basic
229profile). Since these component setups are harder to understand and maintain
230for engineers, almost all of our pages are entirely `"use client"` with a
231`page.tsx` that defines the data fetching.
232
233A more concrete example of what this looks like in practice with the
234data-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.
241export 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"
247export 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";
265export 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
277This example has to be three separate files because of the rules of server
278component bundling. (The client component needs `"use client"`, and server
279component files often can't be imported on the client due to server-only
280imports.). In the Pages router, this could've been a single file because of the
281tree-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
294Since the App Router starts every page as a server component, with (ideally)
295small areas of interactivity, a navigation to a new page *has* to fetch the
296Next.js server, regardless of what data the client already has available! Even
297with a a `loading.tsx` file, opening `/`, navigating to `/other`, and then
298going back to `/` will show the loading state while it re-fetches the homepage.
299
300The only case this works is for **perfectly static content**, where instant
301navigations and prefetching work great. But **web apps are not static**, they
302have lots of dynamic content. Being logged in affects the homepage, which is
303infuriating because the client literally has everything needed to display the
304page 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
336The solution to this appears to be [`staleTime`][nextjs-stale], but it's marked
337experimental and "not recommended for production". The fact this is a
338non-default afterthought configuration option is embarrassing. Even if we used
339it, you cannot make multiple pages that refer to the same underlying data share
340any of it.
341
342One form of loading state that cannot be represented with the App Router is
343having a page such as a page like a git project's issue page, and clicking on a
344user name to navigate to their profile page. With `loading.tsx`, the entire
345page is a skeleton, but when modeling these queries with TanStack Query it is
346possible to show the username and avatar instantly while the user's bio and
347repositories are fetched in. Server components don't support this form of
348navigation because the data is only available in rendered components, so it
349must 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
354In our Next.js site, we have this line of code on our server component data
355fetchers to make soft navigations faster by skipping the data fetch phase all
356together.
357
358```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server"
359export 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
372In addition to this, `loading.tsx` should contain the `useQuery` calls so that
373while the network request for the empty RSC happens, the data is being fetched
374if it actually is needed. In fact, the `loading.tsx` state can just be the
375actual client component, and you'll see the client page.
376
377```tsx filename="src/app/user/[username]/loading.tsx" tint="client"
378"use client";
379export 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
399Layouts can perform data fetching, but they can't observe or alter the request
400in any way. This is done so that Next.js can fetch and cache layouts whenever they
401want. In every other framework, layouts are just regular components that have
402no feature difference compared to page components.
403
404Fetching layouts in isolation is a cute idea, but it ends up being silly
405because it also means that any data fetching has to be re-done per layout. You
406can'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
409When a coworker asks me about why Next.js rejects some code, I've given up on
410explaining the technical intricacies and just say *"It's a Next.js Skill Issue,
411I'm going to blow it up soon don't worry."* These rules are too hard for normal
412developers 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
423Unlike the ["Islands Architecture"][islands], Server Components still have to
424be hydrated on the frontend to support `Suspense` and preserving client
425component state. When doing soft navigations, the "RSC Payload" (which is not
426HTML 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
428not contained within that HTML. React's solution is to **send a second copy of
429the entire page's markup**. An example of what a Next.js production server
430would 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
472This solution **doubles the size of the initial HTML payload**. Except it's
473worse, because the RSC payload includes JSON quoted in JS string literals,
474which is a is much less efficient format than HTML. While it seems to compress
475fine with brotli and render fast in the browser, this is wasteful. With the
476hydration pattern, at least the data locally could be re-used for interactivity
477and other pages.
478
479Even on pages that have little to no interactivity, you pay the cost. To use
480the Next.js documentation as an example, loading [its
481homepage](https://nextjs.org/docs) loads an page that is around 750kB (250kB of
482HTML and the 500kB of script tags), and content is in there twice.
483
484You can verify that by pressing <kbd>Cmd</kbd> + <kbd>Opt</kbd> + <kbd>u</kbd>
485on 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
487blog, 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
489Components.
490
491This RSC format certainly has more waste. But I really don't feel like digging into
492why the string `/_next/static/chunks/6192a3719cda7dcc.js` appears 27 separate
493times. 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
504This 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
510I wouldn't have given this point a section in the blog normally, but I want to
511point out three actual examples directly from the project.
512
513The first is a place where during some refactoring to satisfy the Server/Client
514component models, I accidentally made a Client component `async`. This one was
515quite annoying because it didn't say at all where the issue was, but only
516contained the <b class='server'>server</b> stack trace.
517
518![Next.js error](/file/2025/blog-everyone-hates-nextjs/asyncerror.png)
519
520Another case of a terrible error message:
521
522![Next.js error](/file/2025/blog-everyone-hates-nextjs/nexterror.png)
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
527The final one is the dozen times I place a debugger breakpoint and the
528variable 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"]`
530and other bullshit.
531
532Okay. 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
541There 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
546And Next.js is the wrong tool for both of these jobs. If you're in the first
547category with a static web site, go for [Astro] or [Fresh]. For everyone who
548needs the full power of React, this section is about how I replaced the vendor
549locked 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
555It started with this Vite config.
556
557```ts filename="vite.config.ts"
558const 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
594Then, I looked for every usage of a Next.js API, and either removed it or made
595a stub for TanStack. For example, `src/tanstack-next/link.tsx` implements
596`next/link`:
597
598```tsx filename="src/tanstack-next/link.tsx"
599import { Link } from "@tanstack/react-router";
600import type { LinkProps } from "next/link";
601
602export 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
612Now, the new site can import nearly every client component by either stubbing
613out the Next.js APIs it needs, or by using the `.tanstack.ts` extension to
614re-implement logic on a file-by-file basis. And shortly after, I got the site's
615homepage to work in TanStack Start, and we merged the branch.
616
617![My "nextgate" PR](/file/2025/blog-everyone-hates-nextjs/pr.png)
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
623What was left was porting every other route over. The one thing we lose in
624migrating from Next.js to any other framework is the ability to `await`
625data-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.
627For pages that had multiple fetches, these could be combined into a single,
628special API call that would return all of the relevant data for that page.
629
630To re-iterate in bold font: <strong style='color:var(--secondary)'>The
631migration path from Server Components is to just simplify your code &mdash; RSC
632inherently drives you down a chaotic road of things you do not need</strong>.
633Nearly every complex part of our site got easier to understand for all
634engineers. The exception to this was having everyone get used to the new file
635system routing conventions. With enough examples, we all got the hang of it.
636
637With the incremental migration in place, new code did not break the existing
638deployment. TanStack slowly took over the codebase, and we eventually deleted
639all of the Next.js stubs and gained all of the beautiful [type-safety features]
640that the TanStack Router provides. At the end, the site performed faster from
641every angle: Development Mode, Production page load times, Soft navigations,
642and 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
646We're not the only ones seeing the change. While I try and keep myself off of
647social media, someone sent me [the results of Brian Anglin's work at
648Superwall][superwall-twitter], showing incredible CPU reductions on TanStack
649Start. I also recall ChatGPT switching from Next.js to Remix (random online
650chatter: [[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
664In my opinion, this is one of the only good APIs Next.js has, and was the one
665place in our code where moving to TanStack made things harder to do. Instead of
666worsening the code, I just ported their metadata API into a regular function,
667so everyone can use it. Originally, I had a 1:1 port on NPM, but earlier this
668year I simplified it's API into one short and understandable
669file. 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],
671or 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
681import * as meta from "@clo/lib/meta.ts";
682
683export 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...
692export 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
717function Page() {
718 ...
719}
720```
721
722My version wasn't concerned with covering the entire space of Next.js's metadata
723object, 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
735No 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
744At the Next.js Conf 2024, everyone there was raving about Server Components. I
745forget exactly who I talked to, but the big people were all in on this. I,
746having implemented the bundler end of RSC, saw a couple of the problems in the
747format. With Next 15 "stabilizing" the App Router last year, many companies are
748building their products on it, realizing these pitfalls first-hand.
749
750I came into the Next.js game late, only starting in June with version 15.
751But everyone I've talked to at events sympathize with my notes. All the people
752I talked to on the subject at Bun's 1.3 Party agreed with me. Even some people
753at Vercel told me they don't like how Next.js is to actually use.
754
755I hope as TanStack Start stabilizes, it becomes the Next.js replacement everyone
756wants.
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
765A lot of in the JavaScript ecosystem is a mess. That mess is why web
766development gets made fun of. There were a lot of times I thought that working
767with the web was an unrecoverable mess, but the mess was actually just the
768commonly-used libraries I surrounded myself with. When that is peeled back,
769modern web development technologies are awesome.
770
771I've been making this website from scratch without any framework since late
7722024, by writing systems like my own [TUI progress widget][progress], [static
773file proxy][file-cache], incremental build system, and many more components.
774Working on this code has produced some of my best coding sessions (by
775happiness) in years. The viewers of *[paper clover]* get a better quality
776website; the mini-libraries I create get [extracted for public use][lib],
777everyone wins.
778
779This level of from-scratch is too much for most people, especially at the
780workplace. I say that at the minimum, we should only give our attention and
781money to high quality tools that respect us. And Next.js and the company behind
782it, Vercel, are not that.
783
784If you use Next.js, and feel that the experience doesn't remind you of respect
785too, consider whether you and your colleagues want to continue supporting their
786[serverless empire]. The Vite ecosystem seems pretty decent to build on right
787now, but I still have little experience in using their tools at scale in
788production. The [Vite+ launch from Void0][vite-plus] seems interesting, but
789only time will tell if these venture-funded tools will respect us (end-users
790and developers) long term.
791
792Next.js Conf 2025, as of writing, is [tomorrow][next-conf]. Instead of
793purchasing a $800 ticket, I decided to put that money [toward the TanStack
794team][tanstack-donate] for [respecting and improving the web development
795ecosystem][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
810Slowly, I've been replacing many pieces of software that disrespect me with
811better alternatives. Some examples of this are GitHub, Visual Studio Code,
812DaVinci Resolve, Discord, Google Drive/Workspace, along many more. I plan to
813write more on this blog about the technical things I do (that progress library,
814the purpose of my own site generator, learnings from my current job), including
815some of my past projects at Bun (details on HMR, the crash reporter, and the
816crazy system for bundling built-in modules). If it interests you, please
817subscribe 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>
8302025 (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,13 +108,13 @@ a:where([href]:not(.custom)) {
108 border-radius: 4px;108 border-radius: 4px;
109 &:hover {109 &:hover {
110 text-decoration: underline;110 text-decoration: underline;
111 font-weight: 600;111 /* font-weight: 600; */
112 color: var(--primary);112 color: var(--primary);
113 color: lch(from var(--primary) calc(l) calc(c + 30) h);113 color: lch(from var(--primary) calc(l) calc(c + 30) h);
114 }114 }
115 &:active {115 &:active {
116 text-decoration: underline;116 text-decoration: underline;
117 font-weight: 600;117 /* font-weight: 600; */
118 text-decoration: none;118 text-decoration: none;
119 color: black;119 color: black;
120 background-color: var(--primary);120 background-color: var(--primary);
src/pages/index.marko+1-1
...@@ -14,7 +14,7 @@ export const meta = {...@@ -14,7 +14,7 @@ export const meta = {
14<main>14<main>
15 <div>15 <div>
16 <h2>posts</h2>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 <p>song: <a href="/in-the-summer">in the summer</a> (2025-08-08)</p>18 <p>song: <a href="/in-the-summer">in the summer</a> (2025-08-08)</p>
19 <p>song: <a href="/waterfalls">waterfalls</a> (2025-01-01)</p>19 <p>song: <a href="/waterfalls">waterfalls</a> (2025-01-01)</p>
20 <h2>things</h2>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