diff --git a/framework/hot.ts b/framework/hot.ts index 3695084aa3edbff84c537ad35fa75e8265e48355..e0257d2a472346efb5c80f750cd7d1728540ec14 100644 --- a/framework/hot.ts +++ b/framework/hot.ts @@ -174,6 +174,7 @@ function loadMarko(module: NodeJS.Module, filepath: string) { function loadMdx(module: NodeJS.Module, filepath: string) { const input = fs.readFileSync(filepath); + console.log(filepath); const out = mdx.compileSync(input, { jsxImportSource: "#jsx", providerImportSource: "#mdx", diff --git a/src/bin/deploy.ts b/src/bin/deploy.ts index c17ca3edae9022f4dab8398ccb991784e5b6a67a..313b127cf3fb32b8cba59859016ffb31127fe599 100644 --- a/src/bin/deploy.ts +++ b/src/bin/deploy.ts @@ -4,7 +4,7 @@ export async function main() { args: [ "-a", "--progress", - "clo@zenith:/mnt/storage1/clover/Documents/Config/paperclover/cache.sqlite", + "clo@paperclover.net:~/paperclover.net/.clover/cache.sqlite", ".clover/", ], progress: progress.start("download file cache"), diff --git a/src/blog/blog.css b/src/blog/blog.css index 2c845a45d9283687548596773d6a411b5e699f9b..92fd06ec68dedb08f921ba37737b2ea4c233f386 100644 --- a/src/blog/blog.css +++ b/src/blog/blog.css @@ -23,6 +23,7 @@ p { display: flex; gap: 0.5rem; font-size: 80%; + flex-wrap: wrap; } .tag { background-color: #0005; diff --git a/src/blog/layout.tsx b/src/blog/layout.tsx index 7ad9cb7d6c81d5eb2c90ef59da8acff4d0fd2684..0cbf49b21d7927de8116584e3b86bbea3a1f1269 100644 --- a/src/blog/layout.tsx +++ b/src/blog/layout.tsx @@ -12,9 +12,9 @@ export function Layout({ meta: { title, description }, date, tags, children }) {
{/* back to clover's garden */} - back to the home page + back to the home page -

{title}

+

{title}

{description}

diff --git a/src/blog/pages/webdev/everyone-hates-nextjs.mdx b/src/blog/pages/webdev/everyone-hates-nextjs.mdx deleted file mode 100644 index 9899ee83251418b33d174b56a216c46b6d638230..0000000000000000000000000000000000000000 --- a/src/blog/pages/webdev/everyone-hates-nextjs.mdx +++ /dev/null @@ -1,831 +0,0 @@ -import TableOfContents from "../../tags/table-of-contents.tsx"; -import Heading from "../../tags/heading.tsx"; -import { Layout } from "../../layout.tsx"; -export { theme } from "../../layout.tsx"; - -export const meta = { - title: "Everyone Hates Next.js", - description: "A critique of React Server Components in Next.js 15," - + " and how my company escaped it.", - keywords: ["webdev", "technical analysis", "opinion"], - authors: ["clover caruso"], - embed: { - thumbnail: "/open-graph/everyone-hates-nextjs.png" - }, - canonical: "/blog/webdev/everyone-hates-nextjs" -}; - - - -As I've been using [Next.js] professionally on my employer's web app, I find the -core design of their App Router and [React Server Components] (RSC) to be -extremely frustrating. And it's not small bugs or that the API is confusing, -but large disagreements about the fundamental design decisions that Vercel and -the React team made when building it. - -The more webdev events I go to, the more I see people who dislike Next.js, but -still get stuck using it. By the end of this article, I will share how me and -my colleagues escaped this hell, seamlessly migrating our entire frontend to -[TanStack Start]. - -[Next.js]: https://nextjs.org -[React Server Components]: https://react.dev/reference/rsc/server-components - - - -- [A Technical Review, What are Server Components?][§1] -- [Real-world Pitfalls of the App Router][§2] - - [Optimistic Updates are Impossible][§2.1] - - [Every Navigation is Another Fetch][§2.2] - - [Layouts are Artificially Restricted][§2.3] - - [You Still Download All the Content Twice][§2.4] - - [Turbopack Sucks][§2.5] -- [Seamlessly Ditching Next.js and Vercel at Work][§3] - - [`next/metadata` is Great][§3.1] - - [`next/og` is Good Too][§3.2] -- [My Experience Feels Like the Usual][§4] -- [Prefer Tools that Respect You][§5] - - - -[§1]: #technical-review - -A Technical Review, What are Server Components? - -The pitch of RSC is that components are put into two categories, -"server" components and "client" -components. Server components don't have `useState`, `useEffect`, but can be -`async function`s and refer to backend tools like directly calling into a -database. Client components are the existing -model, where there is code on the backend to generate HTML text and frontend -code to manage the DOM using `window.document.*`. - -> The first disaster: naming!! React is now using the words -> "server" and "client" to refer to -> a very specific things, ignoring their existing definitions. This would be -> fine, except Client components can run on the backend -> too! In this article, I'll be using the terms "backend" and -> "frontend" to describe the two execution environments that web apps -> exist in: a Node.js process and a Web browser, respectively. - -This Server/Client component model -is interesting. Since built-ins like `` get serialized across the -network, data fetching can be very trivially modeled with async server components, and the fallback UI works as if it were -client-side. - -```tsx filename="src/app/[username]/page.tsx" tint="server" -// For this article, server components will be highlighted in red -export default async function Page({ params }) { - // Page params are given as a resolved promise - const { username } = await params; - - // The components `UserInfo` and `UserPostList` will be run at the same - // time. Once `UserInfo` is ready, the visitor will see the page with a - // `PostListSkeleton` if the post list is not yet ready. - return
- - - }> - - -
-} - -// Waterfalls are avoided by having multiple components, which -// are all evaluated at the same time. - -async function UserInfo({ username }) { - const user = await fetchUserInfo(username); - return <> -

{user.displayName}

- {user.bio ? : ""} - -} - -async function UserPostList({ username }) { - const posts = await fetchUserPostList(username); - return /* post list ui omitted for brevity */; -} -``` - -If we ignore the 40kB gzipped bundle size of React itself, the above example -has zero JavaScript for the UI and data fetching — it just streams the -markup! For example, the imagined markdown parser within the `` -component stays on the backend. When an interactive frontend is needed, Client -components can be created by putting them in a file starting with `"use -client"`. - -```tsx filename="src/components/CopyButton.tsx" tint="client" -"use client"; // This comment marks the file for client-side bundling. - -export function CopyButton({ url }) { - return <> - {url} - - -} -``` -```tsx filename="src/app/q+a/Card.tsx" tint="server" -export function Card() { - return
-
- {/* Make the browser import the copy button */} - -
-

- {/* Process markdown on the backend */} - -

-
-} -``` - -[§2]: #real-world-pitfalls - -Real-world Pitfalls of the App Router - -After quitting [Bun] as a runtime engineer (I implemented [Server Components -bundling] and [a RSC template][bun-rsc] there), I joined a small company working on the -front lines: a Next.js app with a Hono backend. The following notes are -simplifications from the real world problems I've encountered when trying to -maintain and develop new features. As a result of all of these, everyone's time -is wasted either working around design flaws, or explaining to each other why -what should be a non-issue is an immovable object. - -[Bun]: https://bun.com -[Server Components bundling]: https://github.com/oven-sh/bun/blob/67f0c3e016aa479738469adac2b79a1862b88122/src/bake/bake.d.ts -[bun-rsc]: https://github.com/oven-sh/bun/tree/e7790894d92b730758ecadf971cb935063508dfb/src/bake/bun-framework-react - -[§2.1]: #optimistic-updates - -Optimistic Updates are Impossible - -The Next.js documentation for performing mutations [does not mention optimistic -updates][nextjs-updating-data]; it appears this case was not thought about. -Components rendered by the React Server, by design, can -not be modified after mounting. Elements that could change need to be inside a -client component, but data fetching cannot happen on the client components, -even during SSR on the backend. This results in awkwardly small server -components that only do data fetching and then have a client component that -contains a mostly-static version of the page. - -```tsx filename="src/app/user/[username]/page.tsx" tint="server" - -export default async function Page() { - const user = await fetchUserInfo(username); - return - - ; -} -``` -```tsx filename="src/app/user/[username]/UserProfile.tsx" tint="client" - -"use client"; // Must separate the client code into a second file! - -export function UserProfile({ user: initialUser }) { - // There are many great state management libraries out there; - // for simplicity, this example will use one state cell. - const [user, optimisticUpdateUser] = useState(initialUser); - - async function onEdit(newUser) { - optimisticUpdateUser(newUser); - const resp = await fetch("...", { - method: 'POST', - body: JSON.stringify(newUser), - ... // (headers, credentials, tracing, and more) - }) - if (!resp.ok) /* always remember to test for errors! */ - } - - return
{/* user interface with editable fields... */}
: -} -``` - -As more of the page needs interactivity, it gets messier trying to keep the -static parts truly server-side. On the work app, nearly every piece of UI -displays some dynamic data. A [`WebSocket`][ws] synchronizes data live as it -updates (for example, a user card's online state along with their basic -profile). Since these component setups are harder to understand and maintain -for engineers, almost all of our pages are entirely `"use client"` with a -`page.tsx` that defines the data fetching. - -A more concrete example of what this looks like in practice with the -data-fetching library we use at work, [TanStack Query]. - -[TanStack Query]: https://github.com/tanstack/query#readme - -```ts filename="src/queries/users.ts" -// At work, there is a helper function `defineQuery` for type safety. -// Fetchers are trivial and can run on the backend or the frontend. -export const queryUserInfo = (username) => ({ - queryKey: ['user', username], - queryFn: async ({ ... }) => /* fetch data */ -}); -``` -```tsx filename="src/app/user/[username]/page.tsx" tint="server" -export default async function Page({ params }) { - const { username } = await params; - - // There's no global state in the React Server. Since layouts - // are executed in parallel, the TanStack `QueryClient` has to - // be reconstructed multiple times per route. - const queryClient = new QueryClient(); - await queryClient.ensureQueryData(queryUserInfo(username)); - - // HydrationBoundary is a client component that passes JSON - // data from the React server to the client component. - return - - ; -} -``` -```tsx filename="src/app/user/[username]/ClientPage.tsx" tint="client" -"use client"; -export function ClientPage() { - const { username } = useParams(); - const { data: user } = useSuspenseQuery(queryUserInfo(username)); - - // ... some hooks - - return
- {/* ... an interactive web page */} -
; -} -``` - -This example has to be three separate files because of the rules of server -component bundling. (The client component needs `"use client"`, and server -component files often can't be imported on the client due to server-only -imports.). In the Pages router, this could've been a single file because of the -tree-shaking that `getStaticProps` and `getServerSideProps` has. - -[ws]: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API -[nextjs-updating-data]: https://nextjs.org/docs/app/getting-started/updating-data - - -[§2.2]: #redundant-fetches - -Every Navigation is Another Fetch - -Since the App Router starts every page as a server component, with (ideally) -small areas of interactivity, a navigation to a new page *has* to fetch the -Next.js server, regardless of what data the client already has available! Even -with a a `loading.tsx` file, opening `/`, navigating to `/other`, and then -going back to `/` will show the loading state while it re-fetches the homepage. - -The only case this works is for **perfectly static content**, where instant -navigations and prefetching work great. But **web apps are not static**, they -have lots of dynamic content. Being logged in affects the homepage, which is -infuriating because the client literally has everything needed to display the -page instantly. It's not like the cookies changed. - -> **aside**: In further testing on a blank project, I observe cases where the -> Next frontend code would pre-fetch routes, but **without any real contents**. -> On the hello world example, this was a 1.8kB RSC payload that pointed to 2 -> different JS chunks 4 separate times. This is just pure waste of our -> bandwidth and egress, especially considering all of this information is -> re-fetched when I actually click the link. -> -> ```json whitespace="pre-wrap" -> 1:"$Sreact.fragment" -> 2:I[39756,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"] -> 3:I[37457,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"] -> 4:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"ViewportBoundary"] -> 6:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"MetadataBoundary"] -> 7:"$Sreact.suspense" -> 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} -> 5:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]] -> 9:I[27201,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"IconMark"] -> 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",{}]] -> ``` -> -> In review, I found there is actually some content in here: the loading state. -> Do you see it? -> -> ```json -> ["$","div","l",{"children":"loading..."}] -> ``` -> -> It's still a lot of waste, since all of this data gets re-emitted in the -> actual page RSC. - -The solution to this appears to be [`staleTime`][nextjs-stale], but it's marked -experimental and "not recommended for production". The fact this is a -non-default afterthought configuration option is embarassing. Even it we used -it, you cannot make multiple pages that refer to the same underlying data share -any of it. - -One form of loading state that cannot be represented with the App Router is -having a page such as a page like a git project's issue page, and clicking on a -user name to navigate to their profile page. With `loading.tsx`, the entire -page is a skeleton, but when modeling these queries with TanStack Query it is -possible to show the username and avatar instantly while the user's bio and -repositories are fetched in. Server components don't support this form of -navigation because the data is only available in rendered components, so it -must be re-fetched. - -[nextjs-stale]: https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes -[nextjs-stale-bug]: https://github.com/vercel/next.js/issues/70661 - -In our Next.js site, we have this line of code on our server component data -fetchers to make soft navigations faster by skipping the data fetch phase all -together. - -```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server" -export function serverSidePrefetchQueries(queries) { - if ((await headers()).get("next-url")) { - // This is a soft-navigation. SKIP the prefetching to make it faster. - // The client might already have this data, and if not, they have the - // loading state. Ideally, this server request wouldn't exist -- The - // client side has nearly ALL the code since the app is written mostly - // as client components. Kind of a design flaw of the App router TBH. - return; - } - // ... data prefetching-logic ... -} -``` - -In addition to this, `loading.tsx` should contain the `useQuery` calls so that -while the network request for the empty RSC happens, the data is being fetched -if it actually is needed. In fact, the `loading.tsx` state can just be the -actual client component, and you'll see the client page. - -```tsx filename="src/app/user/[username]/loading.tsx" tint="client" -"use client"; -export default function PageLoadingSkeleton() { - return ; -} -``` - -> At work, we just make our `loading.tsx` files contain the `useQuery` -> calls and show a skeleton. This is because when Next.js loads the actual Server -> Component, no matter what, the entire page re-mounts. No VDOM diffing here, -> meaning all hooks (`useState`) will reset slightly after the request -> completes. I tried to reproduce a simple case where I was *begging* Next.js to -> just *update the existing DOM* and preserve state, but it just doesn't. -> Thankfully, the time the blank RSC call takes is short enough. - -[§2.3]: #layout-restrictions - -Layouts are Artificially Restricted - -Layouts can perform data fetching, but they can't observe or alter the request -in any way. This is done so that Next.js can fetch and cache layouts whenever they -wants. In every other framework, layouts are just regular components that have -no feature difference compared to page components. - -Fetching layouts in isolation is a cute idea, but it ends up being silly -because it also means that any data fetching has to be re-done per layout. You -can't share a `QueryClient`; instead, you must rely on their [monkey-patched -`fetch`][nextjs-fetch] to cache the same `GET` request like they promise. - -When a coworker asks me about why Next.js rejects some code, I've given up on -explaining the technical intricacies and just say *"It's a Next.js Skill Issue, -I'm going to blow it up soon don't worry."* These rules are too hard for normal -developers to understand. - -[nextjs-fetch]: https://nextjs.org/docs/app/api-reference/functions/fetch - -[§2.4]: #rsc-payload - -You Still Download All the Content Twice - -Unlike the ["Islands Architecture"][islands], Server Components still have to -be hydrated on the frontend to support `Suspense` and preserving client -component state. When doing soft navigations, the "RSC Payload" (which is not -HTML at all) is retrieved by `fetch`. On a fresh reload, HTML is needed for the -[first paint], but the information about Client components and `Suspense` is -not contained within that HTML. React's solution is to **send a second copy of -the entire page's markup**. An example of what a Next.js production server -would send in a dynamic page render would be something like this: - -[first paint]: https://web.dev/articles/fcp - -```html filename="GET /user/clover" - - - - {link and meta tags} - - - {server side render} - - - - -
- {server side render of a Suspense boundary} -
- - - - - -``` - -This solution **doubles the size of the initial HTML payload**. Except it's -worse, because the RSC payload includes JSON quoted in JS string literals, which -format is much less efficient than HTML. While it seems to compress fine -with brotli and render fast in the browser, this is wasteful. With the -hydration pattern, at least the data locally could be re-used for interactivity -and other pages. - -Even on pages that have little to no interactivity, you pay the cost. To use -the Next.js documentation as an example, loading [its -homepage](https://nextjs.org/docs) loads an page that is around 750kB (250kB of -HTML and the 500kB of script tags), and content is in there twice. - -You can verify that by pressing Cmd + Opt + u -on Mac or Ctrl + u on other platforms. And then -Cmd / Ctrl + f to locate any string of the -blog, such as "building full-stack web applications". It's there twice. And -**there is no way around this**, since it's a fundamental piece of React Server -Components. - -This RSC format certainly has more waste. But I really don't feel like digging into -why the string `/_next/static/chunks/6192a3719cda7dcc.js` appears 27 separate -times. What the hell, guys? Is your bandwidth free??? - -[islands]: https://www.patterns.dev/vanilla/islands-architecture/ - -[§2.5]: #turbopack - -Turbopack Sucks - -This section is not constructive. - -- Turbopack isn't fast -- Turbopack emits code that is hard to debug in a debugger (in development mode) -- Turbopack throws bad error messages in many cases - -I wouldn't have given this point a section in the blog normally, but I want to -point out three actual examples directly from the project. - -The first is a place where during some refactoring to satisfy the Server/Client -component models, I accidentally made a Client component `async`. This one was -quite anoying because it didn't say at all where the issue was, but only -contained the server stack trace. - -![Next.js error](/file/2025/blog-everyone-hates-nextjs/asyncerror.png) - -Another case of a terrible error message: - -![Next.js error](/file/2025/blog-everyone-hates-nextjs/nexterror.png) - -> After fixing the underlying issue in this second error (which I cannot recall), -> the Dev server hung and had to be restarted to recover. - -The final one is the dozen times I place a debugger breakpoint and the -variable name `hello` gets turned into -`__TURBOPACK__imported__module__$5b$project$5d2f$client$2f$src$2f$utils$2f$filename$2e$ts__$5b$app$2d$client$5d$__$28$ecmascript$29$__["hello"]` -and other bullshit. - -Okay. This all sucks. What can we do? - -[§3]: #ditching-nextjs - -Seamlessly Ditching Next.js and Vercel at Work - -There are two types of web projects: - -- A web site with mostly static content. -- A web app with majorly dynamic and interactive components. - -And Next.js is the wrong tool for both of these jobs. If you're in the first -category with a static web site, go for [Astro] or [Fresh]. For everyone who -needs the full power of React, this section is about how I replaced the vendor -locked Next with [TanStack Start], incrementally and seamlessly. - -[Astro]: https://astro.build/ -[Fresh]: https://fresh.deno.dev/ -[TanStack Start]: https://tanstack.com/start/latest - -It started with this Vite config. - -```ts filename="vite.config.ts" -const config = defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), "NEXT_PUBLIC_"); - return { - // Use the Next.js default port 3000 - server: { port: 3000 }, - // Use the Next.js default env prefix "NEXT_PUBLIC_" - define: Object.fromEntries(Object.entries(env).map( - ([k, v]) => [`process.env.${k}`, JSON.stringify(v)])), - plugins: [ - viteTsConfigPaths({ projects: ["./tsconfig.json"] }), - tailwindcss(), - // For ease of understanding from coworkers, I started porting - // the routes in `src/tanstack-routes`. When the migration was - // done, it would go back to the default `src/routes`. - tanstackStart({ - router: { routesDirectory: "src/tanstack-routes" }, - }), - viteReact(), - ], - resolve: { - // The key to the incremental migration: redirect `next` elsewhere - alias: { next: path.resolve("./src/tanstack-next/") }, - conditions: ["tanstack"], - extensions: [ - // Allow a file named like `utils/session.tanstack.ts` to - // override `utils/session.ts` when imported. - ".tanstack.tsx", ".tanstack.ts", - // Default import extensions - ".mjs", ".js", ".mts", ".ts", - ".jsx", ".tsx", ".json", - ], - }, - }; -}); -``` - -Then, I looked for every usage of a Next.js API, and either removed it or made -a stub for TanStack. For example, `src/tanstack-next/link.tsx` implements -`next/link`: - -```tsx filename="src/tanstack-next/link.tsx" -import { Link } from "@tanstack/react-router"; -import type { LinkProps } from "next/link"; - -export default function LinkAdapter({ href, ...rest }: LinkProps) { - return ; -} -``` - -> Some of these stubs can be extremely simple. Starting out, my implementation -> of `useRouter` was just `return {}`, but later I had to add a couple methods -> to the object. The code here doesn't have to be clean, because it is -> temporary. - -Now, the new site can import nearly every client component by either stubbing -out the Next.js APIs it needs, or by using the `.tanstack.ts` extension to -re-implement logic on a file-by-file basis. And shortly after, I got the site's -homepage to work in TanStack Start, and we merged the branch. - -![My "nextgate" PR](/file/2025/blog-everyone-hates-nextjs/pr.png) - -> This first PR only supported one of our pages, and was able to do it in a -> thousand lines of added code, and 40 lines deleted. I had previous patches to -> remove the few uses of `next/image` and `next/font`. - -What was left was porting every other route over. The one thing we lose in -migrating from Next.js to any other framework is the ability to `await` -data-fetching functions in the UI. In practice, moving every route into a -`loader` function made it much more clear what happened when a page was SSR'd. -For pages that had multiple fetches, these could be combined into a single, -special API call that would return all of the relevant data for that page. - -To re-iterate in bold font: The -migration path from Server Components is to just simplify your code — RSC -inherently drives you down a chaotic road of things you do not need. -Nearly every complex part of our site got easier to understand for all -engineers. The exception to this was having everyone get used to the new file -system routing conventions. With enough examples, we all got the hang of it. - -With the incremental migration in place, new code did not break the existing -deployment. TanStack slowly took over the codebase, and we eventually deleted -all of the Next.js stubs and gained all of the beautiful [type-safety features] -that the TanStack Router provides. At the end, the site performed faster from -every angle: Development Mode, Production page load times, Soft navigations, -and at a lower price than our Next depoyment with Vercel. - -[type-safety features]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety - -We're not the only ones seeing the change. While I try and keep myself off of -social media, someone sent me [the results of Brian Anglin's work at -Superwall][superwall-twitter], showing incredible CPU reductions on TanStack -Start. I also recall ChatGPT switching from Next.js to Remix (random online -chatter: [[1][chatgpt-1]] [[2][chatgpt-2]] [[3][chatgpt-3]]) a year ago. - -[superwall-twitter]: https://twitter.com/BriansAngles/status/1978834116079436242#m -[chatgpt-1]: https://xcancel.com/ryanflorence/status/1831379475654947233 -[chatgpt-2]: https://old.reddit.com/r/reactjs/comments/1f97zgr/chatgpt_migrates_from_nextjs_to_remix -[chatgpt-3]: https://old.reddit.com/r/nextjs/comments/1f92jdv/chatgptcom_switched_from_nextjs_to_remix - -[§3.1]: #next-metadata - -next/metadata is Great - -In my opinion, this is one of the only good APIs Next.js has, and was the one -place in our code where moving to TanStack made things harder to do. Instead of -worsening the code, I just ported their metadata API into a regular function, -so everyone can use it. Originally, I had a 1:1 port on NPM, but earlier this -year I simplified it's API into one short and understandable -file. As of this blog post, I have added a TanStack-compatible -`meta.toTags` API, which can be installed from [JSR][lib-jsr], [NPM][lib-npm], -or simply copied into your project. - -> **notice**: Due to time constraints with writing this article, the library -> has not yet been updated. I'll probably get around to it by the end of this -> week (Oct 24th). As a placeholder, I'm able to share the version that is used -> at work to my website: [`meta.tanstack.ts`](https://paperclover.net/file/2025/blog-everyone-hates-nextjs/meta.tanstack.ts). - -```tsx -// once in your project -import * as meta from "@clo/lib/meta.ts"; - -export const defineHead = meta.toTags.bind(null, { - // site-wide options - base: new URL("https://paperclover.net"), - titleTemplate: (title) => [title, "paper clover"] - .filter(Boolean).join(' | '), - // ... -}); - -// for each page... -export const Route = createFileRoute("/blog")({ - head: () => - defineHead({ - title: "clover's blog", // templated with `titleTemplate` - description: "a catgirl meows about her technology viewpoints", - canonical: "/blog", // joined with `base` - - // When specified, configures Open Graph and Twitter embed, - // using the page title and description as the default. - // The defaults are good, but it supports more options. - embed: {}, - - // Every exotic meta tag is done with a JSX fragment. This - // doesn't render React, it just loops through the tags. - // My goal was to cover the most common 99% of uses. - extra: <> - , - , - }), - - component: Page, -}); - -function Page() { - ... -} -``` - -My version wasn't concerned with covering the entire space of Next.js's metadata -object, but instead uses inline JSX to fill that gap. - -[lib-jsr]: https://jsr.io/@clo/lib -[lib-npm]: https://npmjs.com/@paperclover/lib - -[§3.2]: #vercel-og - -next/og is Good Too - -No strong opinions. I just want to remind everyone that the `@vercel/og` package exists. - -[§4]: #experience-feels-like-the-usual - -My Experience Feels like the Usual - -At the Next.js Conf 2024, everyone there was raving about Server Components. I -forget exactly who I talked to, but the big people were all in on this. I, -having implemented the bundler end of RSC, saw a couple of the problems in the -format. With Next 15 "stabilizing" the App Router last year, many companies are -building their products on it, realizing these pitfalls first-hand. - -I came into the Next.js game late, only starting in June with version 15. -But everyone I've talked to at events sympathize with my notes. All the people -I talked to on the subject at Bun's 1.3 Party agreed with me. Even some people -at Vercel told me they don't like how Next.js is to actually use. - -I hope as TanStack Start stabilizes, it becomes the Next.js replacement everyone -wants. - -[§5]: #prefer-respectful-tools - -Prefer Tools that Respect You - -A lot of in the JavaScript ecosystem is a mess. That mess is why web -development gets made fun of. There were a lot of times I thought that working -with the web was an unrecoverable mess, but the mess was actually just the -commonly-used libraries I surrounded myself with. When that is peeled back, -modern web development technologies are awesome. - -I've been making this website from scratch without any framework since late -2024, by writing systems like my own [TUI progress widget][progress], [static -file proxy][file-cache], incremental build system, and many more components. -Working on this code has produced some of my best coding sessions (by -happiness) in years. The viewers of *[paper clover]* get a better quality -website; the mini-libraries I create get [extracted for public use][lib], -everyone wins. - -This level of from-scratch is too much for most people, especially at the -workplace. I say that at the minimum, we should only give our attention and -money to high quality tools that respect us. And Next.js and the company behind -it, Vercel, are not that. - -If you use Next.js, and feel that the experience doesn't remind you of respect -too, consider whether you and your colleagues want to continue supporting their -[serverless empire]. The Vite ecosystem seems pretty decent to build on right -now, but I still have little experience in using their tools at scale in -production. The [Vite+ launch from Void0][vite-plus] seems interesting, but -only time will tell if these venture-funded tools will respect us (end-users -and developers) long term. - -Next.js Conf 2025, as of writing, is [tomorrow][next-conf]. Instead of -purchasing a $800 ticket, I decided to put that money [toward the TanStack -team][tanstack-donate] for [respecting and improving the web development -ecosystem][tanstack-ethos]. - -[paper clover]: https://paperclover.net/ -[lib]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib#readme -[progress]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib/progress.ts -[file-cache]: https://git.paperclover.net/clo/sitegen/src/branch/master/src/file-viewer/cache.ts - -[next-conf]: https://nextjs.org/conf -[vite-plus]: https://viteplus.dev/ -[tanstack-ethos]: https://tanstack.com/ethos -[tanstack-donate]: https://github.com/sponsors/tannerlinsley -[serverless empire]: https://youtu.be/SCIfWhAheVw - -## What the Future Holds - -Slowly, I've been replacing many pieces of software that disrespect me with -better alternatives. Some examples of this are GitHub, Visual Studio Code, -DaVinci Resolve, Discord, Google Drive/Workspace, along many more. I plan to -write more on this blog about the technical things I do (that progress library, -the purpose of my own site generator, learnings from my current job), including -some of my past projects at Bun (details on HMR, the crash reporter, and the -crazy system for bundling built-in modules). If it interests you, please -subscribe to the email list: - -click here to send an email to subscribe@paperclover.net, requesting that you would like to be added to the mailing list. (i manage this mailing list manually) - -[back to top](#top) — [ask a question about this article](/q+a) - -
-
-
-
-
-
-
-2025 (c) paper clover -
- -
- -
- diff --git a/src/blog/pages/webdev/one-year-next-app-router.mdx b/src/blog/pages/webdev/one-year-next-app-router.mdx new file mode 100644 index 0000000000000000000000000000000000000000..ca6e3765d326a10e2d013b435a29b49dde18a22b --- /dev/null +++ b/src/blog/pages/webdev/one-year-next-app-router.mdx @@ -0,0 +1,836 @@ +import TableOfContents from "../../tags/table-of-contents.tsx"; +import Heading from "../../tags/heading.tsx"; +import { Layout } from "../../layout.tsx"; +export { theme } from "../../layout.tsx"; + +export const meta = { + title: "One Year with Next.js App Router — Why We're Moving On", + description: "A critique of React Server Components and Next.js 15.", + keywords: ["webdev", "technical analysis", "opinion"], + authors: ["clover caruso"], + embed: { + thumbnail: "/open-graph/next-js.png" + }, + twitter: { + image: "https://paperclover.net/open-graph/next-js.png" + }, + canonical: "/blog/webdev/one-year-next-app-router" +}; + + + +As I've been using [Next.js] professionally on my employer's web app, I find the +core design of their App Router and [React Server Components] (RSC) to be +extremely frustrating. And it's not small bugs or that the API is confusing, +but large disagreements about the fundamental design decisions that Vercel and +the React team made when building it. + +The more webdev events I go to, the more I see people who dislike Next.js, but +still get stuck using it. By the end of this article, I will share how me and +my colleagues escaped this hell, seamlessly migrating our entire frontend to +[TanStack Start]. + +[Next.js]: https://nextjs.org +[React Server Components]: https://react.dev/reference/rsc/server-components + + + +- [A Technical Review: What are Server Components?][§1] +- [Real-world Pitfalls of the App Router][§2] + - [Optimistic Updates are Impossible][§2.1] + - [Every Navigation is Another Fetch][§2.2] + - [Layouts are Artificially Restricted][§2.3] + - [You Still Download All the Content Twice][§2.4] + - [Turbopack Sucks][§2.5] +- [Seamlessly Ditching Next.js and Vercel at Work][§3] + - [`next/metadata` is Great][§3.1] + - [`next/og` is Good Too][§3.2] +- [My Experience Feels Like the Usual][§4] +- [Prefer Tools that Respect You][§5] + + + +[§1]: #technical-review + +A Technical Review: What are Server Components? + +The pitch of RSC is that components are put into two categories, +"server" components and "client" +components. Server components don't have `useState`, `useEffect`, but can be +`async function`s and refer to backend tools like directly calling into a +database. Client components are the existing +model, where there is code on the backend to generate HTML text and frontend +code to manage the DOM using `window.document.*`. + +> The first disaster: naming!! React is now using the words +> "server" and "client" to refer to +> a very specific things, ignoring their existing definitions. This would be +> fine, except Client components can run on the backend +> too! In this article, I'll be using the terms "backend" and +> "frontend" to describe the two execution environments that web apps +> exist in: a Node.js process and a Web browser, respectively. + +This Server/Client component model +is interesting. Since built-ins like `` get serialized across the +network, data fetching can be very trivially modeled with async server components, and the fallback UI works as if it were +client-side. + +```tsx filename="src/app/[username]/page.tsx" tint="server" +// For this article, server components will be highlighted in red +export default async function Page({ params }) { + // Page params are given as a resolved promise + const { username } = await params; + + // The components `UserInfo` and `UserPostList` will be run at the same + // time. Once `UserInfo` is ready, the visitor will see the page with a + // `PostListSkeleton` if the post list is not yet ready. + return
+ + + }> + + +
+} + +// Waterfalls are avoided by having multiple components, which +// are all evaluated at the same time. + +async function UserInfo({ username }) { + const user = await fetchUserInfo(username); + return <> +

{user.displayName}

+ {user.bio ? : ""} + +} + +async function UserPostList({ username }) { + const posts = await fetchUserPostList(username); + return /* post list ui omitted for brevity */; +} +``` + +If we ignore the 40kB gzipped bundle size of React itself, the above example +has zero JavaScript for the UI and data fetching — it just streams the +markup! For example, the imagined markdown parser within the `` +component stays on the backend. When an interactive frontend is needed, Client +components can be created by putting them in a file starting with `"use +client"`. + +```tsx filename="src/components/CopyButton.tsx" tint="client" +"use client"; // This comment marks the file for client-side bundling. + +export function CopyButton({ url }) { + return <> + {url} + + +} +``` +```tsx filename="src/app/q+a/Card.tsx" tint="server" +export function Card() { + return
+
+ {/* Make the browser import the copy button */} + +
+

+ {/* Process markdown on the backend */} + +

+
+} +``` + +[§2]: #real-world-pitfalls + +Real-world Pitfalls of the App Router + +After quitting [Bun] as a runtime engineer (I implemented [Server Components +bundling] and [a RSC template][bun-rsc] there), I joined a small company working on the +front lines: a Next.js app with a Hono backend. The following notes are +simplifications from the real world problems I've encountered when trying to +maintain and develop new features. As a result of all of these, everyone's time +is wasted either working around design flaws, or explaining to each other why +what should be a non-issue is an immovable object. + +[Bun]: https://bun.com +[Server Components bundling]: https://github.com/oven-sh/bun/blob/67f0c3e016aa479738469adac2b79a1862b88122/src/bake/bake.d.ts +[bun-rsc]: https://github.com/oven-sh/bun/tree/e7790894d92b730758ecadf971cb935063508dfb/src/bake/bun-framework-react + +[§2.1]: #optimistic-updates + +Optimistic Updates are Impossible + +The Next.js documentation for performing mutations [does not mention optimistic +updates][nextjs-updating-data]; it appears this case was not thought about. +Components rendered by the React Server, by design, can +not be modified after mounting. Elements that could change need to be inside a +client component, but data fetching cannot happen on the client components, +even during SSR on the backend. This results in awkwardly small server +components that only do data fetching and then have a client component that +contains a mostly-static version of the page. + +```tsx filename="src/app/user/[username]/page.tsx" tint="server" + +export default async function Page() { + const user = await fetchUserInfo(username); + return + + ; +} +``` +```tsx filename="src/app/user/[username]/UserProfile.tsx" tint="client" + +"use client"; // Must separate the client code into a second file! + +export function UserProfile({ user: initialUser }) { + // There are many great state management libraries out there; + // for simplicity, this example will use one state cell. + const [user, optimisticUpdateUser] = useState(initialUser); + + async function onEdit(newUser) { + optimisticUpdateUser(newUser); + const resp = await fetch("...", { + method: 'POST', + body: JSON.stringify(newUser), + ... // (headers, credentials, tracing, and more) + }) + if (!resp.ok) /* always remember to test for errors! */ + } + + return
{/* user interface with editable fields... */}
: +} +``` + +As more of the page needs interactivity, it gets messier trying to keep the +static parts truly server-side. On the work app, nearly every piece of UI +displays some dynamic data. A [`WebSocket`][ws] synchronizes data live as it +updates (for example, a user card's online state along with their basic +profile). Since these component setups are harder to understand and maintain +for engineers, almost all of our pages are entirely `"use client"` with a +`page.tsx` that defines the data fetching. + +A more concrete example of what this looks like in practice with the +data-fetching library we use at work, [TanStack Query]. + +[TanStack Query]: https://github.com/tanstack/query#readme + +```ts filename="src/queries/users.ts" +// At work, there is a helper function `defineQuery` for type safety. +// Fetchers are trivial and can run on the backend or the frontend. +export const queryUserInfo = (username) => ({ + queryKey: ['user', username], + queryFn: async ({ ... }) => /* fetch data */ +}); +``` +```tsx filename="src/app/user/[username]/page.tsx" tint="server" +export default async function Page({ params }) { + const { username } = await params; + + // There's no global state in the React Server. Since layouts + // are executed in parallel, the TanStack `QueryClient` has to + // be reconstructed multiple times per route. + const queryClient = new QueryClient(); + await queryClient.ensureQueryData(queryUserInfo(username)); + + // HydrationBoundary is a client component that passes JSON + // data from the React server to the client component. + return + + ; +} +``` +```tsx filename="src/app/user/[username]/ClientPage.tsx" tint="client" +"use client"; +export function ClientPage() { + const { username } = useParams(); + const { data: user } = useSuspenseQuery(queryUserInfo(username)); + + // ... some hooks + + return
+ {/* ... an interactive web page */} +
; +} +``` + +This example has to be three separate files because of the rules of server +component bundling. (The client component needs `"use client"`, and server +component files often can't be imported on the client due to server-only +imports.). In the Pages router, this could've been a single file because of the +tree-shaking that `getStaticProps` and `getServerSideProps` has. + +[ws]: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API +[nextjs-updating-data]: https://nextjs.org/docs/app/getting-started/updating-data + + +[§2.2]: #redundant-fetches + +Every Navigation is Another Fetch + +Since the App Router starts every page as a server component, with (ideally) +small areas of interactivity, a navigation to a new page *has* to fetch the +Next.js server, regardless of what data the client already has available! Even +with a a `loading.tsx` file, opening `/`, navigating to `/other`, and then +going back to `/` will show the loading state while it re-fetches the homepage. + +The only case this works is for **perfectly static content**, where instant +navigations and prefetching work great. But **web apps are not static**, they +have lots of dynamic content. Being logged in affects the homepage, which is +infuriating because the client literally has everything needed to display the +page instantly. It's not like the cookies changed. + +> **aside**: In further testing on a blank project, I observe cases where the +> Next frontend code would pre-fetch routes, but **without any real contents**. +> On the hello world example, this was a 1.8kB RSC payload that pointed to 2 +> different JS chunks 4 separate times. This is just pure waste of our +> bandwidth and egress, especially considering all of this information is +> re-fetched when I actually click the link. +> +> ```json whitespace="pre-wrap" +> 1:"$Sreact.fragment" +> 2:I[39756,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"] +> 3:I[37457,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"] +> 4:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"ViewportBoundary"] +> 6:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"MetadataBoundary"] +> 7:"$Sreact.suspense" +> 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} +> 5:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]] +> 9:I[27201,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"IconMark"] +> 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",{}]] +> ``` +> +> In review, I found there is actually some content in here: the loading state. +> Do you see it? +> +> ```json +> ["$","div","l",{"children":"loading..."}] +> ``` +> +> It's still a lot of waste, since all of this data gets re-emitted in the +> actual page RSC. + +The solution to this appears to be [`staleTime`][nextjs-stale], but it's marked +experimental and "not recommended for production". The fact this is a +non-default afterthought configuration option is embarrassing. Even if we used +it, you cannot make multiple pages that refer to the same underlying data share +any of it. + +One form of loading state that cannot be represented with the App Router is +having a page such as a page like a git project's issue page, and clicking on a +user name to navigate to their profile page. With `loading.tsx`, the entire +page is a skeleton, but when modeling these queries with TanStack Query it is +possible to show the username and avatar instantly while the user's bio and +repositories are fetched in. Server components don't support this form of +navigation because the data is only available in rendered components, so it +must be re-fetched. + +[nextjs-stale]: https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes +[nextjs-stale-bug]: https://github.com/vercel/next.js/issues/70661 + +In our Next.js site, we have this line of code on our server component data +fetchers to make soft navigations faster by skipping the data fetch phase all +together. + +```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server" +export function serverSidePrefetchQueries(queries) { + if ((await headers()).get("next-url")) { + // This is a soft-navigation. SKIP the prefetching to make it faster. + // The client might already have this data, and if not, they have the + // loading state. Ideally, this server request wouldn't exist -- The + // client side has nearly ALL the code since the app is written mostly + // as client components. Kind of a design flaw of the App router TBH. + return; + } + // ... data prefetching-logic ... +} +``` + +In addition to this, `loading.tsx` should contain the `useQuery` calls so that +while the network request for the empty RSC happens, the data is being fetched +if it actually is needed. In fact, the `loading.tsx` state can just be the +actual client component, and you'll see the client page. + +```tsx filename="src/app/user/[username]/loading.tsx" tint="client" +"use client"; +export default function PageLoadingSkeleton() { + return ; +} +``` + +> At work, we just make our `loading.tsx` files contain the `useQuery` +> calls and show a skeleton. This is because when Next.js loads the actual Server +> Component, no matter what, the entire page re-mounts. No VDOM diffing here, +> meaning all hooks (`useState`) will reset slightly after the request +> completes. I tried to reproduce a simple case where I was *begging* Next.js to +> just *update the existing DOM* and preserve state, but it just doesn't. +> Thankfully, the time the blank RSC call takes is short enough. + +[§2.3]: #layout-restrictions + +Layouts are Artificially Restricted + +Layouts can perform data fetching, but they can't observe or alter the request +in any way. This is done so that Next.js can fetch and cache layouts whenever they +want. In every other framework, layouts are just regular components that have +no feature difference compared to page components. + +Fetching layouts in isolation is a cute idea, but it ends up being silly +because it also means that any data fetching has to be re-done per layout. You +can't share a `QueryClient`; instead, you must rely on their [monkey-patched +`fetch`][nextjs-fetch] to cache the same `GET` request like they promise. + +When a coworker asks me about why Next.js rejects some code, I've given up on +explaining the technical intricacies and just say *"It's a Next.js Skill Issue, +I'm going to blow it up soon don't worry."* These rules are too hard for normal +developers to understand. + +[nextjs-fetch]: https://nextjs.org/docs/app/api-reference/functions/fetch + +[§2.4]: #rsc-payload + +You Still Download All the Content Twice + +Unlike the ["Islands Architecture"][islands], Server Components still have to +be hydrated on the frontend to support `Suspense` and preserving client +component state. When doing soft navigations, the "RSC Payload" (which is not +HTML at all) is retrieved by `fetch`. On a fresh reload, HTML is needed for the +[first paint], but the information about Client components and `Suspense` is +not contained within that HTML. React's solution is to **send a second copy of +the entire page's markup**. An example of what a Next.js production server +would send in a dynamic page render would be something like this: + +[first paint]: https://web.dev/articles/fcp + +```html filename="GET /user/clover" + + + + {link and meta tags} + + + {server side render} + + + + +
+ {server side render of a Suspense boundary} +
+ + + + + +``` + +This solution **doubles the size of the initial HTML payload**. Except it's +worse, because the RSC payload includes JSON quoted in JS string literals, +which is a is much less efficient format than HTML. While it seems to compress +fine with brotli and render fast in the browser, this is wasteful. With the +hydration pattern, at least the data locally could be re-used for interactivity +and other pages. + +Even on pages that have little to no interactivity, you pay the cost. To use +the Next.js documentation as an example, loading [its +homepage](https://nextjs.org/docs) loads an page that is around 750kB (250kB of +HTML and the 500kB of script tags), and content is in there twice. + +You can verify that by pressing Cmd + Opt + u +on Mac or Ctrl + u on other platforms. And then +Cmd / Ctrl + f to locate any string of the +blog, such as "building full-stack web applications". It's there twice. And +**there is no way around this**, since it's a fundamental piece of React Server +Components. + +This RSC format certainly has more waste. But I really don't feel like digging into +why the string `/_next/static/chunks/6192a3719cda7dcc.js` appears 27 separate +times. What the hell, guys? Is your bandwidth free??? + +[islands]: https://www.patterns.dev/vanilla/islands-architecture/ + +[§2.5]: #turbopack + +Turbopack Sucks + +This section is not constructive. + +- Turbopack isn't fast +- Turbopack emits code that is hard to debug in a debugger (in development mode) +- Turbopack throws bad error messages in many cases + +I wouldn't have given this point a section in the blog normally, but I want to +point out three actual examples directly from the project. + +The first is a place where during some refactoring to satisfy the Server/Client +component models, I accidentally made a Client component `async`. This one was +quite annoying because it didn't say at all where the issue was, but only +contained the server stack trace. + +![Next.js error](/file/2025/blog-everyone-hates-nextjs/asyncerror.png) + +Another case of a terrible error message: + +![Next.js error](/file/2025/blog-everyone-hates-nextjs/nexterror.png) + +> After fixing the underlying issue in this second error (which I cannot recall), +> the Dev server hung and had to be restarted to recover. + +The final one is the dozen times I place a debugger breakpoint and the +variable name `hello` gets turned into +`__TURBOPACK__imported__module__$5b$project$5d2f$client$2f$src$2f$utils$2f$filename$2e$ts__$5b$app$2d$client$5d$__$28$ecmascript$29$__["hello"]` +and other bullshit. + +Okay. This all sucks. What can we do? + +[§3]: #ditching-nextjs + +Seamlessly Ditching Next.js and Vercel at Work + +There are two types of web projects: + +- A web site with mostly static content. +- A web app with majorly dynamic and interactive components. + +And Next.js is the wrong tool for both of these jobs. If you're in the first +category with a static web site, go for [Astro] or [Fresh]. For everyone who +needs the full power of React, this section is about how I replaced the vendor +locked Next with [TanStack Start], incrementally and seamlessly. + +[Astro]: https://astro.build/ +[Fresh]: https://fresh.deno.dev/ +[TanStack Start]: https://tanstack.com/start/latest + +It started with this Vite config. + +```ts filename="vite.config.ts" +const config = defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), "NEXT_PUBLIC_"); + return { + // Use the Next.js default port 3000 + server: { port: 3000 }, + // Use the Next.js default env prefix "NEXT_PUBLIC_" + define: Object.fromEntries(Object.entries(env).map( + ([k, v]) => [`process.env.${k}`, JSON.stringify(v)])), + plugins: [ + viteTsConfigPaths({ projects: ["./tsconfig.json"] }), + tailwindcss(), + // For ease of understanding from coworkers, I started porting + // the routes in `src/tanstack-routes`. When the migration was + // done, it would go back to the default `src/routes`. + tanstackStart({ + router: { routesDirectory: "src/tanstack-routes" }, + }), + viteReact(), + ], + resolve: { + // The key to the incremental migration: redirect `next` elsewhere + alias: { next: path.resolve("./src/tanstack-next/") }, + conditions: ["tanstack"], + extensions: [ + // Allow a file named like `utils/session.tanstack.ts` to + // override `utils/session.ts` when imported. + ".tanstack.tsx", ".tanstack.ts", + // Default import extensions + ".mjs", ".js", ".mts", ".ts", + ".jsx", ".tsx", ".json", + ], + }, + }; +}); +``` + +Then, I looked for every usage of a Next.js API, and either removed it or made +a stub for TanStack. For example, `src/tanstack-next/link.tsx` implements +`next/link`: + +```tsx filename="src/tanstack-next/link.tsx" +import { Link } from "@tanstack/react-router"; +import type { LinkProps } from "next/link"; + +export default function LinkAdapter({ href, ...rest }: LinkProps) { + return ; +} +``` + +> Some of these stubs can be extremely simple. Starting out, my implementation +> of `useRouter` was just `return {}`, but later I had to add a couple methods +> to the object. The code here doesn't have to be clean, because it is +> temporary. + +Now, the new site can import nearly every client component by either stubbing +out the Next.js APIs it needs, or by using the `.tanstack.ts` extension to +re-implement logic on a file-by-file basis. And shortly after, I got the site's +homepage to work in TanStack Start, and we merged the branch. + +![My "nextgate" PR](/file/2025/blog-everyone-hates-nextjs/pr.png) + +> This first PR only supported one of our pages, and was able to do it in a +> thousand lines of added code, and 40 lines deleted. I had previous patches to +> remove the few uses of `next/image` and `next/font`. + +What was left was porting every other route over. The one thing we lose in +migrating from Next.js to any other framework is the ability to `await` +data-fetching functions in the UI. In practice, moving every route into a +`loader` function made it much more clear what happened when a page was SSR'd. +For pages that had multiple fetches, these could be combined into a single, +special API call that would return all of the relevant data for that page. + +To re-iterate in bold font: The +migration path from Server Components is to just simplify your code — RSC +inherently drives you down a chaotic road of things you do not need. +Nearly every complex part of our site got easier to understand for all +engineers. The exception to this was having everyone get used to the new file +system routing conventions. With enough examples, we all got the hang of it. + +With the incremental migration in place, new code did not break the existing +deployment. TanStack slowly took over the codebase, and we eventually deleted +all of the Next.js stubs and gained all of the beautiful [type-safety features] +that the TanStack Router provides. At the end, the site performed faster from +every angle: Development Mode, Production page load times, Soft navigations, +and at a lower price than our Next depoyment with Vercel. + +[type-safety features]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety + +We're not the only ones seeing the change. While I try and keep myself off of +social media, someone sent me [the results of Brian Anglin's work at +Superwall][superwall-twitter], showing incredible CPU reductions on TanStack +Start. I also recall ChatGPT switching from Next.js to Remix (random online +chatter: [[1][chatgpt-1]] [[2][chatgpt-2]] [[3][chatgpt-3]]) a year ago. + +[superwall-twitter]: https://twitter.com/BriansAngles/status/1978834116079436242#m +[chatgpt-1]: https://xcancel.com/ryanflorence/status/1831379475654947233 +[chatgpt-2]: https://old.reddit.com/r/reactjs/comments/1f97zgr/chatgpt_migrates_from_nextjs_to_remix +[chatgpt-3]: https://old.reddit.com/r/nextjs/comments/1f92jdv/chatgptcom_switched_from_nextjs_to_remix + +[§3.1]: #next-metadata + +next/metadata is Great + +In my opinion, this is one of the only good APIs Next.js has, and was the one +place in our code where moving to TanStack made things harder to do. Instead of +worsening the code, I just ported their metadata API into a regular function, +so everyone can use it. Originally, I had a 1:1 port on NPM, but earlier this +year I simplified it's API into one short and understandable +file. As of this blog post, I have added a TanStack-compatible +`meta.toTags` API, which can be installed from [JSR][lib-jsr], [NPM][lib-npm], +or simply copied into your project. + +> **notice**: Due to time constraints with writing this article, the library +> has not yet been updated. I'll probably get around to it by the ~~end of this +> week (Oct 24th)~~ some time soon... As a placeholder, I'm able to share the +> version that is used at work to my website: +> [`meta.tanstack.ts`](https://paperclover.net/file/2025/blog-everyone-hates-nextjs/meta.tanstack.ts). + +```tsx +// once in your project +import * as meta from "@clo/lib/meta.ts"; + +export const defineHead = meta.toTags.bind(null, { + // site-wide options + base: new URL("https://paperclover.net"), + titleTemplate: (title) => [title, "paper clover"] + .filter(Boolean).join(' | '), + // ... +}); + +// for each page... +export const Route = createFileRoute("/blog")({ + head: () => + defineHead({ + title: "clover's blog", // templated with `titleTemplate` + description: "a catgirl meows about her technology viewpoints", + canonical: "/blog", // joined with `base` + + // When specified, configures Open Graph and Twitter embed, + // using the page title and description as the default. + // The defaults are good, but it supports more options. + embed: { + image: "/img/blog.webp", + }, + + // Every exotic meta tag is done with a JSX fragment. This + // doesn't render React, it just loops through the tags. + // My goal was to cover the most common 99% of uses. + extra: <> + , + , + }), + + component: Page, +}); + +function Page() { + ... +} +``` + +My version wasn't concerned with covering the entire space of Next.js's metadata +object, but instead uses inline JSX to fill that gap. + +[lib-jsr]: https://jsr.io/@clo/lib +[lib-npm]: https://npmjs.com/@paperclover/lib + +[§3.2]: #vercel-og + +next/og is Good Too + +No strong opinions. I just want to remind everyone that the `@vercel/og` package exists. + +[§4]: #experience-feels-like-the-usual + +My Experience Feels like the Usual + +At the Next.js Conf 2024, everyone there was raving about Server Components. I +forget exactly who I talked to, but the big people were all in on this. I, +having implemented the bundler end of RSC, saw a couple of the problems in the +format. With Next 15 "stabilizing" the App Router last year, many companies are +building their products on it, realizing these pitfalls first-hand. + +I came into the Next.js game late, only starting in June with version 15. +But everyone I've talked to at events sympathize with my notes. All the people +I talked to on the subject at Bun's 1.3 Party agreed with me. Even some people +at Vercel told me they don't like how Next.js is to actually use. + +I hope as TanStack Start stabilizes, it becomes the Next.js replacement everyone +wants. + +[§5]: #prefer-respectful-tools + +Prefer Tools that Respect You + +A lot of in the JavaScript ecosystem is a mess. That mess is why web +development gets made fun of. There were a lot of times I thought that working +with the web was an unrecoverable mess, but the mess was actually just the +commonly-used libraries I surrounded myself with. When that is peeled back, +modern web development technologies are awesome. + +I've been making this website from scratch without any framework since late +2024, by writing systems like my own [TUI progress widget][progress], [static +file proxy][file-cache], incremental build system, and many more components. +Working on this code has produced some of my best coding sessions (by +happiness) in years. The viewers of *[paper clover]* get a better quality +website; the mini-libraries I create get [extracted for public use][lib], +everyone wins. + +This level of from-scratch is too much for most people, especially at the +workplace. I say that at the minimum, we should only give our attention and +money to high quality tools that respect us. And Next.js and the company behind +it, Vercel, are not that. + +If you use Next.js, and feel that the experience doesn't remind you of respect +too, consider whether you and your colleagues want to continue supporting their +[serverless empire]. The Vite ecosystem seems pretty decent to build on right +now, but I still have little experience in using their tools at scale in +production. The [Vite+ launch from Void0][vite-plus] seems interesting, but +only time will tell if these venture-funded tools will respect us (end-users +and developers) long term. + +Next.js Conf 2025, as of writing, is [tomorrow][next-conf]. Instead of +purchasing a $800 ticket, I decided to put that money [toward the TanStack +team][tanstack-donate] for [respecting and improving the web development +ecosystem][tanstack-ethos]. + +[paper clover]: https://paperclover.net/ +[lib]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib#readme +[progress]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib/progress.ts +[file-cache]: https://git.paperclover.net/clo/sitegen/src/branch/master/src/file-viewer/cache.ts + +[next-conf]: https://nextjs.org/conf +[vite-plus]: https://viteplus.dev/ +[tanstack-ethos]: https://tanstack.com/ethos +[tanstack-donate]: https://github.com/sponsors/tannerlinsley +[serverless empire]: https://youtu.be/SCIfWhAheVw + +## What the Future Holds + +Slowly, I've been replacing many pieces of software that disrespect me with +better alternatives. Some examples of this are GitHub, Visual Studio Code, +DaVinci Resolve, Discord, Google Drive/Workspace, along many more. I plan to +write more on this blog about the technical things I do (that progress library, +the purpose of my own site generator, learnings from my current job), including +some of my past projects at Bun (details on HMR, the crash reporter, and the +crazy system for bundling built-in modules). If it interests you, please +subscribe to the email list: + +click here to send an email to subscribe@paperclover.net, requesting that you would like to be added to the mailing list. (i manage this mailing list manually) + +[back to top](#top) — [ask a question about this article](/q+a) + +
+
+
+
+
+
+
+2025 (c) paper clover +
+ +
+ +
+ diff --git a/src/global.css b/src/global.css index 01a573a230fc25d3bd484059f3c04442b7472638..95cfa646e7c3830b7d9132e6f9bc7829dd973e1b 100644 --- a/src/global.css +++ b/src/global.css @@ -108,13 +108,13 @@ a:where([href]:not(.custom)) { border-radius: 4px; &:hover { text-decoration: underline; - font-weight: 600; + /* font-weight: 600; */ color: var(--primary); color: lch(from var(--primary) calc(l) calc(c + 30) h); } &:active { text-decoration: underline; - font-weight: 600; + /* font-weight: 600; */ text-decoration: none; color: black; background-color: var(--primary); diff --git a/src/pages/index.marko b/src/pages/index.marko index bb1441491cd7a44a0253d32cdd3cd01544b2c1e2..fdeb1681e63f4816154b1d91df81eaf932a8fd01 100644 --- a/src/pages/index.marko +++ b/src/pages/index.marko @@ -14,7 +14,7 @@ export const meta = {

posts

-

blog: Everyone Hates Next.js (2025-10-21)

+

blog: One Year with Next.js App Router — why we're moving on (2025-10-21)

song: in the summer (2025-08-08)

song: waterfalls (2025-01-01)

things

diff --git a/src/static/open-graph/everyone-hates-nextjs.png b/src/static/open-graph/everyone-hates-nextjs.png deleted file mode 100755 index aec17de48b7c4be227033d538d94fd91ff1699c7..0000000000000000000000000000000000000000 Binary files a/src/static/open-graph/everyone-hates-nextjs.png and /dev/null differ diff --git a/src/static/open-graph/next-js.png b/src/static/open-graph/next-js.png new file mode 100644 index 0000000000000000000000000000000000000000..b6070350bf3ed2a124a25cc6fe410aa1e63873e9 Binary files /dev/null and b/src/static/open-graph/next-js.png differ