| 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,6 +174,7 @@ function loadMarko(module: NodeJS.Module, filepath: string) { |
| 174 | 174 | ||
| 175 | function loadMdx(module: NodeJS.Module, filepath: string) { | 175 | function 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> |
| 16 | 16 | ||
| 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 @@ | ||
| 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,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 | |||