authorgravatar for superLipbalm@gmail.comChanhee Kim <superLipbalm@gmail.com> 2025-12-09 22:06:16-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-14 01:08:14-08:00
logc2294014546473c5381f90da0ceba0de1d389f4a
tree7bb82ea7abf5b65cfab0fe00f7804434fbe989c0
parent2bed05bdbc03b105f930feed4573a5b0768bab07
signature Commit is signed but in an unrecognized format.

feat(site): nextjs post translated for korean


17 files changed, 1709 insertions(+), 917 deletions(-)

src/backend.ts+1
......@@ -9,6 +9,7 @@ app.use(logger((msg) => msg.startsWith("-->") && console.info(msg.slice(4))));
99app.use(admin.middleware);
1010
1111// Backends
12app.route("", require("./blog/backend.ts").app);
1213app.route("", require("./q+a/backend.ts").app);
1314app.route("", require("./file-viewer/backend.tsx").app);
1415app.route("", require("./friend-auth.ts").app);
src/blog/backend.ts created+13
......@@ -0,0 +1,13 @@
1export const app = new Hono();
2
3app.get("/blog/webdev/one-year-next-app-router", async (c) => {
4 return assets.serveAsset(c, "/blog/webdev/one-year-next-app-router/en", 200);
5});
6app.get("/blog/webdev/one-year-next-app-router.ko", async (c) => {
7 return assets.serveAsset(c, "/blog/webdev/one-year-next-app-router/ko", 200);
8});
9
10app.get("/blog/*", assets.notFound);
11
12import { Hono } from "#hono";
13import * as assets from "#sitegen/assets";
src/blog/blog.css+43-36
......@@ -24,16 +24,37 @@ p {
2424 gap: 0.5rem;
2525 font-size: 80%;
2626 flex-wrap: wrap;
27}
28.tag {
29 background-color: #0005;
30 border-radius: 8px;
31 padding: 0.35rem;
32 color: #fffc;
33 &.date {
34 color: var(--secondary);
27 .tag {
28 background-color: #0005;
29 border-radius: 8px;
30 padding: 0 0.6rem;
31 color: lch(from var(--fg) calc(l + 10) calc(c + 5) h / 80%);
32 height: 1.6rem;
33 display: inline-flex;
34 justify-content: center;
35 align-items: center;
36 }
37 .lang-original {
38 --primary: var(--secondary);
39 }
40 .lang {
41 color: var(--primary);
42 }
43 .lang:not(.active) {
44 text-decoration: underline;
45 text-underline-offset: 4px;
46 }
47 .active {
48 border: 2px solid var(--primary);
49 }
50 .square {
51 background-color: transparent;
52 border: 3px solid #0005;
53 padding: 0;
54 width: 1.6rem;
3555 }
3656}
57
3758hr {
3859 margin: 1rem 0;
3960 border: 2px solid var(--primary);
......@@ -43,34 +64,6 @@ img {
4364 border-radius: 8px;
4465}
4566
46#toc {
47 background-color: #0003;
48 border-radius: 8px;
49 padding: 1rem;
50
51 h2 {
52 text-decoration: none;
53 margin: 0;
54 text-transform: uppercase;
55 font-weight: bold;
56 letter-spacing: 1px;
57 font-size: 0.8rem;
58 color: #fff8;
59 }
60 ul {
61 margin: 0;
62 }
63 li {
64 list-style-type: square;
65 &::marker {
66 color: var(--primary);
67 }
68 li {
69 --primary: var(--secondary);
70 }
71 }
72}
73
7467h3 {
7568 --primary: var(--secondary);
7669}
......@@ -203,3 +196,17 @@ figure.code {
203196 }
204197 }
205198}
199
200.meta {
201 display: flex;
202 gap: 0.5rem;
203 margin-bottom: 8px;
204 color: lch(from var(--fg) l calc(c + 10) h / 50%);
205 .meta-author {
206 font-weight: bold;
207 --primary: var(--secondary);
208 }
209 .meta-translate {
210 --primary: #ffcd70;
211 }
212}
src/blog/layout.tsx deleted-31
......@@ -1,31 +0,0 @@
1import "./blog.css";
2
3export const theme = {
4 bg: "#271a30",
5 fg: "#ffffff",
6 primary: "#91ffc6",
7};
8
9export function Layout({ meta: { title, description }, date, tags, children }) {
10 return (
11 <>
12 <main>
13 <header>
14 {/* <a href="/blog">back to clover's garden</a> */}
15 <a href="/">back to the home page</a>
16
17 <h1 style="max-width: 30ch">{title}</h1>
18 <p class="description">
19 <em>{description}</em>
20 </p>
21 <div class="tag-list">
22 <div class="tag date">{date}</div>
23 {tags.map((tag) => <div class="tag">{tag}</div>)}
24 </div>
25 <hr />
26 </header>
27 {children}
28 </main>
29 </>
30 );
31}
src/blog/localization.tsx created+24
......@@ -0,0 +1,24 @@
1interface LanguageConfig {
2 title: render.Node;
3 return: render.Node;
4 writtenByClover: string;
5 translatedBy: (_: { author: string }) => render.Node;
6}
7type LanguageMap = Record<string, LanguageConfig> & { en: LanguageConfig };
8
9export const languages: LanguageMap = {
10 en: {
11 title: "English",
12 return: "back to the home page",
13 writtenByClover: `clover caruso`, // "written by clover caruso" in other languages
14 translatedBy: ({ author }) => `translated by ${author}`,
15 },
16 ko: {
17 title: "한국어",
18 return: "홈 페이지로 돌아가기",
19 writtenByClover: `clover caruso 작성`,
20 translatedBy: ({ author }) => `${author} 번역`,
21 },
22};
23
24import * as render from "lib/render.ts";
src/blog/pages/community-translations.mdx created+12
......@@ -0,0 +1,12 @@
1export const meta = { title: "about clover's community translations" };
2export const layout = { default: ({ children }) => <main>{children}</main> };
3
4<a href="/">back to the home page</a>
5
6# blog community translations
7
8if you natively speak a language that i don't have a blog page translated into,
9and would like to help spread my words to more readers, please get in touch.
10
11<code><a href="mailto:me@paperclover.net">me@paperclover.net</a></code>
12
src/blog/pages/webdev/one-year-next-app-router.mdx deleted-836
......@@ -1,836 +0,0 @@
1import TableOfContents from "../../tags/table-of-contents.tsx";
2import Heading from "../../tags/heading.tsx";
3import { Layout } from "../../layout.tsx";
4export { theme } from "../../layout.tsx";
5
6export const meta = {
7 title: "One Year with Next.js App Router — Why We're Moving On",
8 description: "A critique of React Server Components and Next.js 15.",
9 keywords: ["webdev", "technical analysis", "opinion"],
10 authors: ["clover caruso"],
11 embed: {
12 thumbnail: "/open-graph/next-js.png"
13 },
14 twitter: {
15 image: "https://paperclover.net/open-graph/next-js.png"
16 },
17 canonical: "/blog/webdev/one-year-next-app-router"
18};
19
20<Layout
21 meta={meta}
22 date={'Oct 21st, 2025'}
23 slug="one-year-next-app-router"
24 tags={meta.keywords}
25>
26
27As I've been using [Next.js] professionally on my employer's web app, I find the
28core design of their App Router and [React Server Components] (RSC) to be
29extremely frustrating. And it's not small bugs or that the API is confusing,
30but large disagreements about the fundamental design decisions that Vercel and
31the React team made when building it.
32
33The more webdev events I go to, the more I see people who dislike Next.js, but
34still get stuck using it. By the end of this article, I will share how me and
35my colleagues escaped this hell, seamlessly migrating our entire frontend to
36[TanStack Start].
37
38[Next.js]: https://nextjs.org
39[React Server Components]: https://react.dev/reference/rsc/server-components
40
41<TableOfContents>
42
43- [A Technical Review: What are Server Components?][§1]
44- [Real-world Pitfalls of the App Router][§2]
45 - [Optimistic Updates are Impossible][§2.1]
46 - [Every Navigation is Another Fetch][§2.2]
47 - [Layouts are Artificially Restricted][§2.3]
48 - [You Still Download All the Content Twice][§2.4]
49 - [Turbopack Sucks][§2.5]
50- [Seamlessly Ditching Next.js and Vercel at Work][§3]
51 - [`next/metadata` is Great][§3.1]
52 - [`next/og` is Good Too][§3.2]
53- [My Experience Feels Like the Usual][§4]
54- [Prefer Tools that Respect You][§5]
55
56</TableOfContents>
57
58[§1]: #technical-review
59
60<Heading
61 level='h2'
62 slug='technical-review'
63>A Technical Review: What are Server Components?</Heading>
64
65The pitch of RSC is that components are put into two categories,
66<b class='server'>"server"</b> components and <b class='client'>"client"</b>
67components. Server components don't have `useState`, `useEffect`, but can be
68`async function`s and refer to backend tools like directly calling into a
69database. Client components are the existing
70model, where there is code on the backend to generate HTML text and frontend
71code to manage the DOM using `window.document.*`.
72
73> The first disaster: naming!! React is now using the words
74> <b class='server'>"server"</b> and <b class='client'>"client"</b> to refer to
75> a very specific things, ignoring their existing definitions. This would be
76> fine, except <b class='client'>Client</b> components can run on the backend
77> too! In this article, I'll be using the terms <b>"backend"</b> and
78> <b>"frontend"</b> to describe the two execution environments that web apps
79> exist in: a Node.js process and a Web browser, respectively.
80
81This <b class='server'>Server</b>/<b class='client'>Client</b> component model
82is interesting. Since built-ins like `<Suspense />` get serialized across the
83network, data fetching can be very trivially modeled with async <b
84class='server'>server components</b>, and the fallback UI works as if it were
85client-side.
86
87```tsx filename="src/app/[username]/page.tsx" tint="server"
88// For this article, server components will be highlighted in red
89export default async function Page({ params }) {
90 // Page params are given as a resolved promise
91 const { username } = await params;
92
93 // The components `UserInfo` and `UserPostList` will be run at the same
94 // time. Once `UserInfo` is ready, the visitor will see the page with a
95 // `PostListSkeleton` if the post list is not yet ready.
96 return <main>
97 <UserInfo username={username} />
98
99 <Suspense fallback={<PostListSkeleton />}>
100 <UserPostList username={username} />
101 </Suspense>
102 </main>
103}
104
105// Waterfalls are avoided by having multiple components, which
106// are all evaluated at the same time.
107
108async function UserInfo({ username }) {
109 const user = await fetchUserInfo(username);
110 return <>
111 <h1>{user.displayName}</h1>
112 {user.bio ? <Markdown content={user.bio} /> : ""}
113 </>
114}
115
116async function UserPostList({ username }) {
117 const posts = await fetchUserPostList(username);
118 return /* post list ui omitted for brevity */;
119}
120```
121
122If we ignore the 40kB gzipped bundle size of React itself, the above example
123has zero JavaScript for the UI and data fetching &mdash; it just streams the
124markup! For example, the imagined markdown parser within the `<Markdown />`
125component stays on the backend. When an interactive frontend is needed, <b class='client'>Client
126components</b> can be created by putting them in a file starting with `"use
127client"`.
128
129```tsx filename="src/components/CopyButton.tsx" tint="client"
130"use client"; // This comment marks the file for client-side bundling.
131
132export function CopyButton({ url }) {
133 return <>
134 <span>{url}</span>
135 <button onClick={() => {
136 const full = new URL(url, location.href);
137 navigator.clipboard.writeText(full.href);
138 // omitting error handling, success ui, styles
139 }}>copy</button>
140 </>
141}
142```
143```tsx filename="src/app/q+a/Card.tsx" tint="server"
144export function Card() {
145 return <article>
146 <header>
147 {/* Make the browser import the copy button */}
148 <CopyButton url="/q+a/2506010139" />
149 </header>
150 <p>
151 {/* Process markdown on the backend */}
152 <Markdown content=".........." />
153 </p>
154 </article>
155}
156```
157
158[§2]: #real-world-pitfalls
159
160<Heading
161 level='h2'
162 slug='real-world-pitfalls'
163>Real-world Pitfalls of the App Router</Heading>
164
165After quitting [Bun] as a runtime engineer (I implemented [Server Components
166bundling] and [a RSC template][bun-rsc] there), I joined a small company working on the
167front lines: a Next.js app with a Hono backend. The following notes are
168simplifications from the real world problems I've encountered when trying to
169maintain and develop new features. As a result of all of these, everyone's time
170is wasted either working around design flaws, or explaining to each other why
171what should be a non-issue is an immovable object.
172
173[Bun]: https://bun.com
174[Server Components bundling]: https://github.com/oven-sh/bun/blob/67f0c3e016aa479738469adac2b79a1862b88122/src/bake/bake.d.ts
175[bun-rsc]: https://github.com/oven-sh/bun/tree/e7790894d92b730758ecadf971cb935063508dfb/src/bake/bun-framework-react
176
177[§2.1]: #optimistic-updates
178
179<Heading
180 level='h3'
181 slug='optimistic-updates'
182>Optimistic Updates are Impossible</Heading>
183
184The Next.js documentation for performing mutations [does not mention optimistic
185updates][nextjs-updating-data]; it appears this case was not thought about.
186Components rendered by the <b class='server'>React Server</b>, by design, can
187not be modified after mounting. Elements that could change need to be inside a
188client component, but data fetching cannot happen on the client components,
189even during SSR on the backend. This results in awkwardly small server
190components that only do data fetching and then have a client component that
191contains a mostly-static version of the page.
192
193```tsx filename="src/app/user/[username]/page.tsx" tint="server"
194
195export default async function Page() {
196 const user = await fetchUserInfo(username);
197 return <ProfileLayout>
198 <UserProfile user={user} />
199 </ProfileLayout>;
200}
201```
202```tsx filename="src/app/user/[username]/UserProfile.tsx" tint="client"
203
204"use client"; // Must separate the client code into a second file!
205
206export function UserProfile({ user: initialUser }) {
207 // There are many great state management libraries out there;
208 // for simplicity, this example will use one state cell.
209 const [user, optimisticUpdateUser] = useState(initialUser);
210
211 async function onEdit(newUser) {
212 optimisticUpdateUser(newUser);
213 const resp = await fetch("...", {
214 method: 'POST',
215 body: JSON.stringify(newUser),
216 ... // (headers, credentials, tracing, and more)
217 })
218 if (!resp.ok) /* always remember to test for errors! */
219 }
220
221 return <main>{/* user interface with editable fields... */}</main>:
222}
223```
224
225As more of the page needs interactivity, it gets messier trying to keep the
226static parts truly server-side. On the work app, nearly every piece of UI
227displays some dynamic data. A [`WebSocket`][ws] synchronizes data live as it
228updates (for example, a user card's online state along with their basic
229profile). Since these component setups are harder to understand and maintain
230for engineers, almost all of our pages are entirely `"use client"` with a
231`page.tsx` that defines the data fetching.
232
233A more concrete example of what this looks like in practice with the
234data-fetching library we use at work, [TanStack Query].
235
236[TanStack Query]: https://github.com/tanstack/query#readme
237
238```ts filename="src/queries/users.ts"
239// At work, there is a helper function `defineQuery` for type safety.
240// Fetchers are trivial and can run on the backend or the frontend.
241export const queryUserInfo = (username) => ({
242 queryKey: ['user', username],
243 queryFn: async ({ ... }) => /* fetch data */
244});
245```
246```tsx filename="src/app/user/[username]/page.tsx" tint="server"
247export default async function Page({ params }) {
248 const { username } = await params;
249
250 // There's no global state in the React Server. Since layouts
251 // are executed in parallel, the TanStack `QueryClient` has to
252 // be reconstructed multiple times per route.
253 const queryClient = new QueryClient();
254 await queryClient.ensureQueryData(queryUserInfo(username));
255
256 // HydrationBoundary is a client component that passes JSON
257 // data from the React server to the client component.
258 return <HydrationBoundary state={dehydrate(queryClient)}>
259 <ClientPage />
260 </HydrationBoundary>;
261}
262```
263```tsx filename="src/app/user/[username]/ClientPage.tsx" tint="client"
264"use client";
265export function ClientPage() {
266 const { username } = useParams();
267 const { data: user } = useSuspenseQuery(queryUserInfo(username));
268
269 // ... some hooks
270
271 return <main>
272 {/* ... an interactive web page */}
273 </main>;
274}
275```
276
277This example has to be three separate files because of the rules of server
278component bundling. (The client component needs `"use client"`, and server
279component files often can't be imported on the client due to server-only
280imports.). In the Pages router, this could've been a single file because of the
281tree-shaking that `getStaticProps` and `getServerSideProps` has.
282
283[ws]: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
284[nextjs-updating-data]: https://nextjs.org/docs/app/getting-started/updating-data
285
286
287[§2.2]: #redundant-fetches
288
289<Heading
290 level='h3'
291 slug='redundant-fetches'
292>Every Navigation is Another Fetch</Heading>
293
294Since the App Router starts every page as a server component, with (ideally)
295small areas of interactivity, a navigation to a new page *has* to fetch the
296Next.js server, regardless of what data the client already has available! Even
297with a a `loading.tsx` file, opening `/`, navigating to `/other`, and then
298going back to `/` will show the loading state while it re-fetches the homepage.
299
300The only case this works is for **perfectly static content**, where instant
301navigations and prefetching work great. But **web apps are not static**, they
302have lots of dynamic content. Being logged in affects the homepage, which is
303infuriating because the client literally has everything needed to display the
304page instantly. It's not like the cookies changed.
305
306> **aside**: In further testing on a blank project, I observe cases where the
307> Next frontend code would pre-fetch routes, but **without any real contents**.
308> On the hello world example, this was a 1.8kB RSC payload that pointed to 2
309> different JS chunks 4 separate times. This is just pure waste of our
310> bandwidth and egress, especially considering all of this information is
311> re-fetched when I actually click the link.
312>
313> ```json whitespace="pre-wrap"
314> 1:"$Sreact.fragment"
315> 2:I[39756,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
316> 3:I[37457,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
317> 4:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"ViewportBoundary"]
318> 6:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"MetadataBoundary"]
319> 7:"$Sreact.suspense"
320> 0:{"b":"TdwnOXsfOJapNex_HjHGt","f":[["children","other",["other",{"children":["__PAGE__",{}]}],["other",["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":null},[["$","div","l",{"children":"loading..."}],[],[]],false],["$","$1","h",{"children":[null,["$","$1","KCFxAJdIDH3BlYXAHsbcVv",{"children":[["$","$L4",null,{"children":"$L5"}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],["$","$L6","KCFxAJdIDH3BlYXAHsbcVm",{"children":["$","div",null,{"hidden":true,"children":["$","$7",null,{"fallback":null,"children":"$L8"}]}]}]]}],false]],"S":false}
321> 5:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
322> 9:I[27201,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"IconMark"]
323> 8:[["$","title","0",{"children":"Create Next App"}],["$","meta","1",{"name":"description","content":"Generated by create next app"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0b3bf435.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L9","3",{}]]
324> ```
325>
326> In review, I found there is actually some content in here: the loading state.
327> Do you see it?
328>
329> ```json
330> ["$","div","l",{"children":"loading..."}]
331> ```
332>
333> It's still a lot of waste, since all of this data gets re-emitted in the
334> actual page RSC.
335
336The solution to this appears to be [`staleTime`][nextjs-stale], but it's marked
337experimental and "not recommended for production". The fact this is a
338non-default afterthought configuration option is embarrassing. Even if we used
339it, you cannot make multiple pages that refer to the same underlying data share
340any of it.
341
342One form of loading state that cannot be represented with the App Router is
343having a page such as a page like a git project's issue page, and clicking on a
344user name to navigate to their profile page. With `loading.tsx`, the entire
345page is a skeleton, but when modeling these queries with TanStack Query it is
346possible to show the username and avatar instantly while the user's bio and
347repositories are fetched in. Server components don't support this form of
348navigation because the data is only available in rendered components, so it
349must be re-fetched.
350
351[nextjs-stale]: https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes
352[nextjs-stale-bug]: https://github.com/vercel/next.js/issues/70661
353
354In our Next.js site, we have this line of code on our server component data
355fetchers to make soft navigations faster by skipping the data fetch phase all
356together.
357
358```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server"
359export function serverSidePrefetchQueries(queries) {
360 if ((await headers()).get("next-url")) {
361 // This is a soft-navigation. SKIP the prefetching to make it faster.
362 // The client might already have this data, and if not, they have the
363 // loading state. Ideally, this server request wouldn't exist -- The
364 // client side has nearly ALL the code since the app is written mostly
365 // as client components. Kind of a design flaw of the App router TBH.
366 return;
367 }
368 // ... data prefetching-logic ...
369}
370```
371
372In addition to this, `loading.tsx` should contain the `useQuery` calls so that
373while the network request for the empty RSC happens, the data is being fetched
374if it actually is needed. In fact, the `loading.tsx` state can just be the
375actual client component, and you'll see the client page.
376
377```tsx filename="src/app/user/[username]/loading.tsx" tint="client"
378"use client";
379export default function PageLoadingSkeleton() {
380 return <ClientPage />;
381}
382```
383
384> At work, we just make our `loading.tsx` files contain the `useQuery`
385> calls and show a skeleton. This is because when Next.js loads the actual Server
386> Component, no matter what, the entire page re-mounts. No VDOM diffing here,
387> meaning all hooks (`useState`) will reset slightly after the request
388> completes. I tried to reproduce a simple case where I was *begging* Next.js to
389> just *update the existing DOM* and preserve state, but it just doesn't.
390> Thankfully, the time the blank RSC call takes is short enough.
391
392[§2.3]: #layout-restrictions
393
394<Heading
395 level='h3'
396 slug='layout-restrictions'
397>Layouts are Artificially Restricted</Heading>
398
399Layouts can perform data fetching, but they can't observe or alter the request
400in any way. This is done so that Next.js can fetch and cache layouts whenever they
401want. In every other framework, layouts are just regular components that have
402no feature difference compared to page components.
403
404Fetching layouts in isolation is a cute idea, but it ends up being silly
405because it also means that any data fetching has to be re-done per layout. You
406can't share a `QueryClient`; instead, you must rely on their [monkey-patched
407`fetch`][nextjs-fetch] to cache the same `GET` request like they promise.
408
409When a coworker asks me about why Next.js rejects some code, I've given up on
410explaining the technical intricacies and just say *"It's a Next.js Skill Issue,
411I'm going to blow it up soon don't worry."* These rules are too hard for normal
412developers to understand.
413
414[nextjs-fetch]: https://nextjs.org/docs/app/api-reference/functions/fetch
415
416[§2.4]: #rsc-payload
417
418<Heading
419 level='h3'
420 slug='rsc-payload'
421>You Still Download All the Content Twice</Heading>
422
423Unlike the ["Islands Architecture"][islands], Server Components still have to
424be hydrated on the frontend to support `Suspense` and preserving client
425component state. When doing soft navigations, the "RSC Payload" (which is not
426HTML at all) is retrieved by `fetch`. On a fresh reload, HTML is needed for the
427[first paint], but the information about Client components and `Suspense` is
428not contained within that HTML. React's solution is to **send a second copy of
429the entire page's markup**. An example of what a Next.js production server
430would send in a dynamic page render would be something like this:
431
432[first paint]: https://web.dev/articles/fcp
433
434```html filename="GET /user/clover"
435<!DOCTYPE html>
436<html>
437<head>
438 {link and meta tags}
439</head>
440<body>
441 {server side render}
442 <script>
443 // a bootstrap script that sets up global `__next_f` as
444 // an array. once React loads, this `.push` function
445 // gets overwritten to write new chunks directly to the
446 // RSC decoder. this script has some dom helpers too
447 (self.__next_f=self.__next_f||[]).push([0])
448 </script>
449 <script>
450 // the RSC payload for the application shell.
451 self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[658993,[\"/_next/st{...}"])
452 </script>
453
454 <!--
455 the closing </body> is NOT written yet, since there is a
456 suspense boundary not resolved. time passes, and only
457 then is more data is written
458 -->
459 <div class="user-post-list">
460 {server side render of a Suspense boundary}
461 </div>
462 <script>
463 // the RSC payload for the suspense boundary
464 self.__next_f.push([2,"14:[\"$\",\"div\",null,{\"children\":[[\"$\",\"h4\"{...}"])
465 </script>
466
467 <!-- HTML and script tags repeat until the entire page is done -->
468</body>
469</html>
470```
471
472This solution **doubles the size of the initial HTML payload**. Except it's
473worse, because the RSC payload includes JSON quoted in JS string literals,
474which is a is much less efficient format than HTML. While it seems to compress
475fine with brotli and render fast in the browser, this is wasteful. With the
476hydration pattern, at least the data locally could be re-used for interactivity
477and other pages.
478
479Even on pages that have little to no interactivity, you pay the cost. To use
480the Next.js documentation as an example, loading [its
481homepage](https://nextjs.org/docs) loads an page that is around 750kB (250kB of
482HTML and the 500kB of script tags), and content is in there twice.
483
484You can verify that by pressing <kbd>Cmd</kbd> + <kbd>Opt</kbd> + <kbd>u</kbd>
485on Mac or <kbd>Ctrl</kbd> + <kbd>u</kbd> on other platforms. And then
486<kbd>Cmd</kbd> / <kbd>Ctrl</kbd> + <kbd>f</kbd> to locate any string of the
487blog, such as "building full-stack web applications". It's there twice. And
488**there is no way around this**, since it's a fundamental piece of React Server
489Components.
490
491This RSC format certainly has more waste. But I really don't feel like digging into
492why the string `/_next/static/chunks/6192a3719cda7dcc.js` appears 27 separate
493times. What the hell, guys? Is your bandwidth free???
494
495[islands]: https://www.patterns.dev/vanilla/islands-architecture/
496
497[§2.5]: #turbopack
498
499<Heading
500 level='h3'
501 slug='turbopack'
502>Turbopack Sucks</Heading>
503
504This section is not constructive.
505
506- Turbopack isn't fast
507- Turbopack emits code that is hard to debug in a debugger (in development mode)
508- Turbopack throws bad error messages in many cases
509
510I wouldn't have given this point a section in the blog normally, but I want to
511point out three actual examples directly from the project.
512
513The first is a place where during some refactoring to satisfy the Server/Client
514component models, I accidentally made a Client component `async`. This one was
515quite annoying because it didn't say at all where the issue was, but only
516contained the <b class='server'>server</b> stack trace.
517
518![Next.js error](/file/2025/blog-everyone-hates-nextjs/asyncerror.png)
519
520Another case of a terrible error message:
521
522![Next.js error](/file/2025/blog-everyone-hates-nextjs/nexterror.png)
523
524> After fixing the underlying issue in this second error (which I cannot recall),
525> the Dev server hung and had to be restarted to recover.
526
527The final one is the dozen times I place a debugger breakpoint and the
528variable name `hello` gets turned into
529`__TURBOPACK__imported__module__$5b$project$5d2f$client$2f$src$2f$utils$2f$filename$2e$ts__$5b$app$2d$client$5d$__$28$ecmascript$29$__["hello"]`
530and other bullshit.
531
532Okay. This all sucks. What can we do?
533
534[§3]: #ditching-nextjs
535
536<Heading
537 level='h2'
538 slug='ditching-nextjs'
539>Seamlessly Ditching Next.js and Vercel at Work</Heading>
540
541There are two types of web projects:
542
543- A web site with mostly static content.
544- A web app with majorly dynamic and interactive components.
545
546And Next.js is the wrong tool for both of these jobs. If you're in the first
547category with a static web site, go for [Astro] or [Fresh]. For everyone who
548needs the full power of React, this section is about how I replaced the vendor
549locked Next with [TanStack Start], incrementally and seamlessly.
550
551[Astro]: https://astro.build/
552[Fresh]: https://fresh.deno.dev/
553[TanStack Start]: https://tanstack.com/start/latest
554
555It started with this Vite config.
556
557```ts filename="vite.config.ts"
558const config = defineConfig(({ mode }) => {
559 const env = loadEnv(mode, process.cwd(), "NEXT_PUBLIC_");
560 return {
561 // Use the Next.js default port 3000
562 server: { port: 3000 },
563 // Use the Next.js default env prefix "NEXT_PUBLIC_"
564 define: Object.fromEntries(Object.entries(env).map(
565 ([k, v]) => [`process.env.${k}`, JSON.stringify(v)])),
566 plugins: [
567 viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
568 tailwindcss(),
569 // For ease of understanding from coworkers, I started porting
570 // the routes in `src/tanstack-routes`. When the migration was
571 // done, it would go back to the default `src/routes`.
572 tanstackStart({
573 router: { routesDirectory: "src/tanstack-routes" },
574 }),
575 viteReact(),
576 ],
577 resolve: {
578 // The key to the incremental migration: redirect `next` elsewhere
579 alias: { next: path.resolve("./src/tanstack-next/") },
580 conditions: ["tanstack"],
581 extensions: [
582 // Allow a file named like `utils/session.tanstack.ts` to
583 // override `utils/session.ts` when imported.
584 ".tanstack.tsx", ".tanstack.ts",
585 // Default import extensions
586 ".mjs", ".js", ".mts", ".ts",
587 ".jsx", ".tsx", ".json",
588 ],
589 },
590 };
591});
592```
593
594Then, I looked for every usage of a Next.js API, and either removed it or made
595a stub for TanStack. For example, `src/tanstack-next/link.tsx` implements
596`next/link`:
597
598```tsx filename="src/tanstack-next/link.tsx"
599import { Link } from "@tanstack/react-router";
600import type { LinkProps } from "next/link";
601
602export default function LinkAdapter({ href, ...rest }: LinkProps) {
603 return <Link {...rest} to={href as unknown as any} />;
604}
605```
606
607> Some of these stubs can be extremely simple. Starting out, my implementation
608> of `useRouter` was just `return {}`, but later I had to add a couple methods
609> to the object. The code here doesn't have to be clean, because it is
610> temporary.
611
612Now, the new site can import nearly every client component by either stubbing
613out the Next.js APIs it needs, or by using the `.tanstack.ts` extension to
614re-implement logic on a file-by-file basis. And shortly after, I got the site's
615homepage to work in TanStack Start, and we merged the branch.
616
617![My "nextgate" PR](/file/2025/blog-everyone-hates-nextjs/pr.png)
618
619> This first PR only supported one of our pages, and was able to do it in a
620> thousand lines of added code, and 40 lines deleted. I had previous patches to
621> remove the few uses of `next/image` and `next/font`.
622
623What was left was porting every other route over. The one thing we lose in
624migrating from Next.js to any other framework is the ability to `await`
625data-fetching functions in the UI. In practice, moving every route into a
626`loader` function made it much more clear what happened when a page was SSR'd.
627For pages that had multiple fetches, these could be combined into a single,
628special API call that would return all of the relevant data for that page.
629
630To re-iterate in bold font: <strong style='color:var(--secondary)'>The
631migration path from Server Components is to just simplify your code &mdash; RSC
632inherently drives you down a chaotic road of things you do not need</strong>.
633Nearly every complex part of our site got easier to understand for all
634engineers. The exception to this was having everyone get used to the new file
635system routing conventions. With enough examples, we all got the hang of it.
636
637With the incremental migration in place, new code did not break the existing
638deployment. TanStack slowly took over the codebase, and we eventually deleted
639all of the Next.js stubs and gained all of the beautiful [type-safety features]
640that the TanStack Router provides. At the end, the site performed faster from
641every angle: Development Mode, Production page load times, Soft navigations,
642and at a lower price than our Next depoyment with Vercel.
643
644[type-safety features]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety
645
646We're not the only ones seeing the change. While I try and keep myself off of
647social media, someone sent me [the results of Brian Anglin's work at
648Superwall][superwall-twitter], showing incredible CPU reductions on TanStack
649Start. I also recall ChatGPT switching from Next.js to Remix (random online
650chatter: [[1][chatgpt-1]] [[2][chatgpt-2]] [[3][chatgpt-3]]) a year ago.
651
652[superwall-twitter]: https://twitter.com/BriansAngles/status/1978834116079436242#m
653[chatgpt-1]: https://xcancel.com/ryanflorence/status/1831379475654947233
654[chatgpt-2]: https://old.reddit.com/r/reactjs/comments/1f97zgr/chatgpt_migrates_from_nextjs_to_remix
655[chatgpt-3]: https://old.reddit.com/r/nextjs/comments/1f92jdv/chatgptcom_switched_from_nextjs_to_remix
656
657[§3.1]: #next-metadata
658
659<Heading
660 level='h3'
661 slug='next-metadata'
662><code>next/metadata</code> is Great</Heading>
663
664In my opinion, this is one of the only good APIs Next.js has, and was the one
665place in our code where moving to TanStack made things harder to do. Instead of
666worsening the code, I just ported their metadata API into a regular function,
667so everyone can use it. Originally, I had a 1:1 port on NPM, but earlier this
668year I simplified it's API into one short and understandable
669file. As of this blog post, I have added a TanStack-compatible
670`meta.toTags` API, which can be installed from [JSR][lib-jsr], [NPM][lib-npm],
671or simply copied into your project.
672
673> **notice**: Due to time constraints with writing this article, the library
674> has not yet been updated. I'll probably get around to it by the ~~end of this
675> week (Oct 24th)~~ some time soon... As a placeholder, I'm able to share the
676> version that is used at work to my website:
677> [`meta.tanstack.ts`](https://paperclover.net/file/2025/blog-everyone-hates-nextjs/meta.tanstack.ts).
678
679```tsx
680// once in your project
681import * as meta from "@clo/lib/meta.ts";
682
683export const defineHead = meta.toTags.bind(null, {
684 // site-wide options
685 base: new URL("https://paperclover.net"),
686 titleTemplate: (title) => [title, "paper clover"]
687 .filter(Boolean).join(' | '),
688 // ...
689});
690
691// for each page...
692export const Route = createFileRoute("/blog")({
693 head: () =>
694 defineHead({
695 title: "clover's blog", // templated with `titleTemplate`
696 description: "a catgirl meows about her technology viewpoints",
697 canonical: "/blog", // joined with `base`
698
699 // When specified, configures Open Graph and Twitter embed,
700 // using the page title and description as the default.
701 // The defaults are good, but it supports more options.
702 embed: {
703 image: "/img/blog.webp",
704 },
705
706 // Every exotic meta tag is done with a JSX fragment. This
707 // doesn't render React, it just loops through the tags.
708 // My goal was to cover the most common 99% of uses.
709 extra: <>
710 <meta name="site-verification" content="waffles" />,
711 </>,
712 }),
713
714 component: Page,
715});
716
717function Page() {
718 ...
719}
720```
721
722My version wasn't concerned with covering the entire space of Next.js's metadata
723object, but instead uses inline JSX to fill that gap.
724
725[lib-jsr]: https://jsr.io/@clo/lib
726[lib-npm]: https://npmjs.com/@paperclover/lib
727
728[§3.2]: #vercel-og
729
730<Heading
731 level='h3'
732 slug='ditching-nextjs'
733><code>next/og</code> is Good Too</Heading>
734
735No strong opinions. I just want to remind everyone that the `@vercel/og` package exists.
736
737[§4]: #experience-feels-like-the-usual
738
739<Heading
740 level='h2'
741 slug='experience-feels-like-the-usual'
742>My Experience Feels like the Usual</Heading>
743
744At the Next.js Conf 2024, everyone there was raving about Server Components. I
745forget exactly who I talked to, but the big people were all in on this. I,
746having implemented the bundler end of RSC, saw a couple of the problems in the
747format. With Next 15 "stabilizing" the App Router last year, many companies are
748building their products on it, realizing these pitfalls first-hand.
749
750I came into the Next.js game late, only starting in June with version 15.
751But everyone I've talked to at events sympathize with my notes. All the people
752I talked to on the subject at Bun's 1.3 Party agreed with me. Even some people
753at Vercel told me they don't like how Next.js is to actually use.
754
755I hope as TanStack Start stabilizes, it becomes the Next.js replacement everyone
756wants.
757
758[§5]: #prefer-respectful-tools
759
760<Heading
761 level='h2'
762 slug='prefer-respectful-tools'
763>Prefer Tools that Respect You</Heading>
764
765A lot of in the JavaScript ecosystem is a mess. That mess is why web
766development gets made fun of. There were a lot of times I thought that working
767with the web was an unrecoverable mess, but the mess was actually just the
768commonly-used libraries I surrounded myself with. When that is peeled back,
769modern web development technologies are awesome.
770
771I've been making this website from scratch without any framework since late
7722024, by writing systems like my own [TUI progress widget][progress], [static
773file proxy][file-cache], incremental build system, and many more components.
774Working on this code has produced some of my best coding sessions (by
775happiness) in years. The viewers of *[paper clover]* get a better quality
776website; the mini-libraries I create get [extracted for public use][lib],
777everyone wins.
778
779This level of from-scratch is too much for most people, especially at the
780workplace. I say that at the minimum, we should only give our attention and
781money to high quality tools that respect us. And Next.js and the company behind
782it, Vercel, are not that.
783
784If you use Next.js, and feel that the experience doesn't remind you of respect
785too, consider whether you and your colleagues want to continue supporting their
786[serverless empire]. The Vite ecosystem seems pretty decent to build on right
787now, but I still have little experience in using their tools at scale in
788production. The [Vite+ launch from Void0][vite-plus] seems interesting, but
789only time will tell if these venture-funded tools will respect us (end-users
790and developers) long term.
791
792Next.js Conf 2025, as of writing, is [tomorrow][next-conf]. Instead of
793purchasing a $800 ticket, I decided to put that money [toward the TanStack
794team][tanstack-donate] for [respecting and improving the web development
795ecosystem][tanstack-ethos].
796
797[paper clover]: https://paperclover.net/
798[lib]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib#readme
799[progress]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib/progress.ts
800[file-cache]: https://git.paperclover.net/clo/sitegen/src/branch/master/src/file-viewer/cache.ts
801
802[next-conf]: https://nextjs.org/conf
803[vite-plus]: https://viteplus.dev/
804[tanstack-ethos]: https://tanstack.com/ethos
805[tanstack-donate]: https://github.com/sponsors/tannerlinsley
806[serverless empire]: https://youtu.be/SCIfWhAheVw
807
808## What the Future Holds
809
810Slowly, I've been replacing many pieces of software that disrespect me with
811better alternatives. Some examples of this are GitHub, Visual Studio Code,
812DaVinci Resolve, Discord, Google Drive/Workspace, along many more. I plan to
813write more on this blog about the technical things I do (that progress library,
814the purpose of my own site generator, learnings from my current job), including
815some of my past projects at Bun (details on HMR, the crash reporter, and the
816crazy system for bundling built-in modules). If it interests you, please
817subscribe to the email list:
818
819<a href="mailto:subscribe@paperclover.net?subject=paper%20clover%20mailing%20list&body=I%20would%20like%20to%20be%20subscribed%20to%20the%20following%20mailing%20lists%3A%0A%0A-%20Technical%20Blog%20Posts%20-%20YES%0A-%20Art%20(Original%20Music%2FVideo)%20-%20YES%0A%0A(feel%20free%20to%20write%20whatever%20else%20you%20want)">click here to send an email to <code>subscribe@paperclover.net</code>, requesting that you would like to be added to the mailing list.</a> (i manage this mailing list manually)
820
821[back to top](#top) &mdash; [ask a question about this article](/q+a)
822
823<br />
824<br />
825<br />
826<br />
827<br />
828<br />
829<footer>
8302025 (c) paper clover
831</footer>
832
833</Layout>
834
835<br />
836
src/blog/pages/webdev/one-year-next-app-router/en.mdx created+836
......@@ -0,0 +1,836 @@
1import Heading from "@/blog/tags/heading.tsx";
2import TableOfContents from "@/blog/tags/table-of-contents.tsx";
3import { Layout } from "@/blog/tags/layout.tsx";
4export { theme } from "@/blog/tags/layout.tsx";
5
6export const meta = {
7 title: "One Year with Next.js App Router — Why We're Moving On",
8 description: "A critique of React Server Components and Next.js 15.",
9 keywords: ["webdev", "technical analysis", "opinion"],
10 authors: ["clover caruso"],
11 embed: {
12 thumbnail: "/open-graph/next-js.png"
13 },
14 twitter: {
15 image: "https://paperclover.net/open-graph/next-js.png"
16 },
17 canonical: "/blog/webdev/one-year-next-app-router"
18};
19
20<Layout
21 meta={meta}
22 date={'Oct 21st, 2025'}
23 slug="webdev/one-year-next-app-router"
24 tags={meta.keywords}
25>
26
27As I've been using [Next.js] professionally on my employer's web app, I find the
28core design of their App Router and [React Server Components] (RSC) to be
29extremely frustrating. And it's not small bugs or that the API is confusing,
30but large disagreements about the fundamental design decisions that Vercel and
31the React team made when building it.
32
33The more webdev events I go to, the more I see people who dislike Next.js, but
34still get stuck using it. By the end of this article, I will share how me and
35my colleagues escaped this hell, seamlessly migrating our entire frontend to
36[TanStack Start].
37
38[Next.js]: https://nextjs.org
39[React Server Components]: https://react.dev/reference/rsc/server-components
40
41<TableOfContents>
42
43- [A Technical Review: What are Server Components?][§1]
44- [Real-world Pitfalls of the App Router][§2]
45 - [Optimistic Updates are Impossible][§2.1]
46 - [Every Navigation is Another Fetch][§2.2]
47 - [Layouts are Artificially Restricted][§2.3]
48 - [You Still Download All the Content Twice][§2.4]
49 - [Turbopack Sucks][§2.5]
50- [Seamlessly Ditching Next.js and Vercel at Work][§3]
51 - [`next/metadata` is Great][§3.1]
52 - [`next/og` is Good Too][§3.2]
53- [My Experience Feels Like the Usual][§4]
54- [Prefer Tools that Respect You][§5]
55
56</TableOfContents>
57
58[§1]: #technical-review
59
60<Heading
61 level='h2'
62 slug='technical-review'
63>A Technical Review: What are Server Components?</Heading>
64
65The pitch of RSC is that components are put into two categories,
66<b class='server'>"server"</b> components and <b class='client'>"client"</b>
67components. Server components don't have `useState`, `useEffect`, but can be
68`async function`s and refer to backend tools like directly calling into a
69database. Client components are the existing
70model, where there is code on the backend to generate HTML text and frontend
71code to manage the DOM using `window.document.*`.
72
73> The first disaster: naming!! React is now using the words
74> <b class='server'>"server"</b> and <b class='client'>"client"</b> to refer to
75> a very specific things, ignoring their existing definitions. This would be
76> fine, except <b class='client'>Client</b> components can run on the backend
77> too! In this article, I'll be using the terms <b>"backend"</b> and
78> <b>"frontend"</b> to describe the two execution environments that web apps
79> exist in: a Node.js process and a Web browser, respectively.
80
81This <b class='server'>Server</b>/<b class='client'>Client</b> component model
82is interesting. Since built-ins like `<Suspense />` get serialized across the
83network, data fetching can be very trivially modeled with async <b
84class='server'>server components</b>, and the fallback UI works as if it were
85client-side.
86
87```tsx filename="src/app/[username]/page.tsx" tint="server"
88// For this article, server components will be highlighted in red
89export default async function Page({ params }) {
90 // Page params are given as a resolved promise
91 const { username } = await params;
92
93 // The components `UserInfo` and `UserPostList` will be run at the same
94 // time. Once `UserInfo` is ready, the visitor will see the page with a
95 // `PostListSkeleton` if the post list is not yet ready.
96 return <main>
97 <UserInfo username={username} />
98
99 <Suspense fallback={<PostListSkeleton />}>
100 <UserPostList username={username} />
101 </Suspense>
102 </main>
103}
104
105// Waterfalls are avoided by having multiple components, which
106// are all evaluated at the same time.
107
108async function UserInfo({ username }) {
109 const user = await fetchUserInfo(username);
110 return <>
111 <h1>{user.displayName}</h1>
112 {user.bio ? <Markdown content={user.bio} /> : ""}
113 </>
114}
115
116async function UserPostList({ username }) {
117 const posts = await fetchUserPostList(username);
118 return /* post list ui omitted for brevity */;
119}
120```
121
122If we ignore the 40kB gzipped bundle size of React itself, the above example
123has zero JavaScript for the UI and data fetching &mdash; it just streams the
124markup! For example, the imagined markdown parser within the `<Markdown />`
125component stays on the backend. When an interactive frontend is needed, <b class='client'>Client
126components</b> can be created by putting them in a file starting with `"use
127client"`.
128
129```tsx filename="src/components/CopyButton.tsx" tint="client"
130"use client"; // This comment marks the file for client-side bundling.
131
132export function CopyButton({ url }) {
133 return <>
134 <span>{url}</span>
135 <button onClick={() => {
136 const full = new URL(url, location.href);
137 navigator.clipboard.writeText(full.href);
138 // omitting error handling, success ui, styles
139 }}>copy</button>
140 </>
141}
142```
143```tsx filename="src/app/q+a/Card.tsx" tint="server"
144export function Card() {
145 return <article>
146 <header>
147 {/* Make the browser import the copy button */}
148 <CopyButton url="/q+a/2506010139" />
149 </header>
150 <p>
151 {/* Process markdown on the backend */}
152 <Markdown content=".........." />
153 </p>
154 </article>
155}
156```
157
158[§2]: #real-world-pitfalls
159
160<Heading
161 level='h2'
162 slug='real-world-pitfalls'
163>Real-world Pitfalls of the App Router</Heading>
164
165After quitting [Bun] as a runtime engineer (I implemented [Server Components
166bundling] and [a RSC template][bun-rsc] there), I joined a small company working on the
167front lines: a Next.js app with a Hono backend. The following notes are
168simplifications from the real world problems I've encountered when trying to
169maintain and develop new features. As a result of all of these, everyone's time
170is wasted either working around design flaws, or explaining to each other why
171what should be a non-issue is an immovable object.
172
173[Bun]: https://bun.com
174[Server Components bundling]: https://github.com/oven-sh/bun/blob/67f0c3e016aa479738469adac2b79a1862b88122/src/bake/bake.d.ts
175[bun-rsc]: https://github.com/oven-sh/bun/tree/e7790894d92b730758ecadf971cb935063508dfb/src/bake/bun-framework-react
176
177[§2.1]: #optimistic-updates
178
179<Heading
180 level='h3'
181 slug='optimistic-updates'
182>Optimistic Updates are Impossible</Heading>
183
184The Next.js documentation for performing mutations [does not mention optimistic
185updates][nextjs-updating-data]; it appears this case was not thought about.
186Components rendered by the <b class='server'>React Server</b>, by design, can
187not be modified after mounting. Elements that could change need to be inside a
188client component, but data fetching cannot happen on the client components,
189even during SSR on the backend. This results in awkwardly small server
190components that only do data fetching and then have a client component that
191contains a mostly-static version of the page.
192
193```tsx filename="src/app/user/[username]/page.tsx" tint="server"
194
195export default async function Page() {
196 const user = await fetchUserInfo(username);
197 return <ProfileLayout>
198 <UserProfile user={user} />
199 </ProfileLayout>;
200}
201```
202```tsx filename="src/app/user/[username]/UserProfile.tsx" tint="client"
203
204"use client"; // Must separate the client code into a second file!
205
206export function UserProfile({ user: initialUser }) {
207 // There are many great state management libraries out there;
208 // for simplicity, this example will use one state cell.
209 const [user, optimisticUpdateUser] = useState(initialUser);
210
211 async function onEdit(newUser) {
212 optimisticUpdateUser(newUser);
213 const resp = await fetch("...", {
214 method: 'POST',
215 body: JSON.stringify(newUser),
216 ... // (headers, credentials, tracing, and more)
217 })
218 if (!resp.ok) /* always remember to test for errors! */
219 }
220
221 return <main>{/* user interface with editable fields... */}</main>:
222}
223```
224
225As more of the page needs interactivity, it gets messier trying to keep the
226static parts truly server-side. On the work app, nearly every piece of UI
227displays some dynamic data. A [`WebSocket`][ws] synchronizes data live as it
228updates (for example, a user card's online state along with their basic
229profile). Since these component setups are harder to understand and maintain
230for engineers, almost all of our pages are entirely `"use client"` with a
231`page.tsx` that defines the data fetching.
232
233A more concrete example of what this looks like in practice with the
234data-fetching library we use at work, [TanStack Query].
235
236[TanStack Query]: https://github.com/tanstack/query#readme
237
238```ts filename="src/queries/users.ts"
239// At work, there is a helper function `defineQuery` for type safety.
240// Fetchers are trivial and can run on the backend or the frontend.
241export const queryUserInfo = (username) => ({
242 queryKey: ['user', username],
243 queryFn: async ({ ... }) => /* fetch data */
244});
245```
246```tsx filename="src/app/user/[username]/page.tsx" tint="server"
247export default async function Page({ params }) {
248 const { username } = await params;
249
250 // There's no global state in the React Server. Since layouts
251 // are executed in parallel, the TanStack `QueryClient` has to
252 // be reconstructed multiple times per route.
253 const queryClient = new QueryClient();
254 await queryClient.ensureQueryData(queryUserInfo(username));
255
256 // HydrationBoundary is a client component that passes JSON
257 // data from the React server to the client component.
258 return <HydrationBoundary state={dehydrate(queryClient)}>
259 <ClientPage />
260 </HydrationBoundary>;
261}
262```
263```tsx filename="src/app/user/[username]/ClientPage.tsx" tint="client"
264"use client";
265export function ClientPage() {
266 const { username } = useParams();
267 const { data: user } = useSuspenseQuery(queryUserInfo(username));
268
269 // ... some hooks
270
271 return <main>
272 {/* ... an interactive web page */}
273 </main>;
274}
275```
276
277This example has to be three separate files because of the rules of server
278component bundling. (The client component needs `"use client"`, and server
279component files often can't be imported on the client due to server-only
280imports.). In the Pages router, this could've been a single file because of the
281tree-shaking that `getStaticProps` and `getServerSideProps` has.
282
283[ws]: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
284[nextjs-updating-data]: https://nextjs.org/docs/app/getting-started/updating-data
285
286
287[§2.2]: #redundant-fetches
288
289<Heading
290 level='h3'
291 slug='redundant-fetches'
292>Every Navigation is Another Fetch</Heading>
293
294Since the App Router starts every page as a server component, with (ideally)
295small areas of interactivity, a navigation to a new page *has* to fetch the
296Next.js server, regardless of what data the client already has available! Even
297with a a `loading.tsx` file, opening `/`, navigating to `/other`, and then
298going back to `/` will show the loading state while it re-fetches the homepage.
299
300The only case this works is for **perfectly static content**, where instant
301navigations and prefetching work great. But **web apps are not static**, they
302have lots of dynamic content. Being logged in affects the homepage, which is
303infuriating because the client literally has everything needed to display the
304page instantly. It's not like the cookies changed.
305
306> **aside**: In further testing on a blank project, I observe cases where the
307> Next frontend code would pre-fetch routes, but **without any real contents**.
308> On the hello world example, this was a 1.8kB RSC payload that pointed to 2
309> different JS chunks 4 separate times. This is just pure waste of our
310> bandwidth and egress, especially considering all of this information is
311> re-fetched when I actually click the link.
312>
313> ```json whitespace="pre-wrap"
314> 1:"$Sreact.fragment"
315> 2:I[39756,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
316> 3:I[37457,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
317> 4:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"ViewportBoundary"]
318> 6:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"MetadataBoundary"]
319> 7:"$Sreact.suspense"
320> 0:{"b":"TdwnOXsfOJapNex_HjHGt","f":[["children","other",["other",{"children":["__PAGE__",{}]}],["other",["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":null},[["$","div","l",{"children":"loading..."}],[],[]],false],["$","$1","h",{"children":[null,["$","$1","KCFxAJdIDH3BlYXAHsbcVv",{"children":[["$","$L4",null,{"children":"$L5"}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],["$","$L6","KCFxAJdIDH3BlYXAHsbcVm",{"children":["$","div",null,{"hidden":true,"children":["$","$7",null,{"fallback":null,"children":"$L8"}]}]}]]}],false]],"S":false}
321> 5:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
322> 9:I[27201,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"IconMark"]
323> 8:[["$","title","0",{"children":"Create Next App"}],["$","meta","1",{"name":"description","content":"Generated by create next app"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0b3bf435.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L9","3",{}]]
324> ```
325>
326> In review, I found there is actually some content in here: the loading state.
327> Do you see it?
328>
329> ```json
330> ["$","div","l",{"children":"loading..."}]
331> ```
332>
333> It's still a lot of waste, since all of this data gets re-emitted in the
334> actual page RSC.
335
336The solution to this appears to be [`staleTime`][nextjs-stale], but it's marked
337experimental and "not recommended for production". The fact this is a
338non-default afterthought configuration option is embarrassing. Even if we used
339it, you cannot make multiple pages that refer to the same underlying data share
340any of it.
341
342One form of loading state that cannot be represented with the App Router is
343having a page such as a page like a git project's issue page, and clicking on a
344user name to navigate to their profile page. With `loading.tsx`, the entire
345page is a skeleton, but when modeling these queries with TanStack Query it is
346possible to show the username and avatar instantly while the user's bio and
347repositories are fetched in. Server components don't support this form of
348navigation because the data is only available in rendered components, so it
349must be re-fetched.
350
351[nextjs-stale]: https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes
352[nextjs-stale-bug]: https://github.com/vercel/next.js/issues/70661
353
354In our Next.js site, we have this line of code on our server component data
355fetchers to make soft navigations faster by skipping the data fetch phase all
356together.
357
358```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server"
359export function serverSidePrefetchQueries(queries) {
360 if ((await headers()).get("next-url")) {
361 // This is a soft-navigation. SKIP the prefetching to make it faster.
362 // The client might already have this data, and if not, they have the
363 // loading state. Ideally, this server request wouldn't exist -- The
364 // client side has nearly ALL the code since the app is written mostly
365 // as client components. Kind of a design flaw of the App router TBH.
366 return;
367 }
368 // ... data prefetching-logic ...
369}
370```
371
372In addition to this, `loading.tsx` should contain the `useQuery` calls so that
373while the network request for the empty RSC happens, the data is being fetched
374if it actually is needed. In fact, the `loading.tsx` state can just be the
375actual client component, and you'll see the client page.
376
377```tsx filename="src/app/user/[username]/loading.tsx" tint="client"
378"use client";
379export default function PageLoadingSkeleton() {
380 return <ClientPage />;
381}
382```
383
384> At work, we just make our `loading.tsx` files contain the `useQuery`
385> calls and show a skeleton. This is because when Next.js loads the actual Server
386> Component, no matter what, the entire page re-mounts. No VDOM diffing here,
387> meaning all hooks (`useState`) will reset slightly after the request
388> completes. I tried to reproduce a simple case where I was *begging* Next.js to
389> just *update the existing DOM* and preserve state, but it just doesn't.
390> Thankfully, the time the blank RSC call takes is short enough.
391
392[§2.3]: #layout-restrictions
393
394<Heading
395 level='h3'
396 slug='layout-restrictions'
397>Layouts are Artificially Restricted</Heading>
398
399Layouts can perform data fetching, but they can't observe or alter the request
400in any way. This is done so that Next.js can fetch and cache layouts whenever they
401want. In every other framework, layouts are just regular components that have
402no feature difference compared to page components.
403
404Fetching layouts in isolation is a cute idea, but it ends up being silly
405because it also means that any data fetching has to be re-done per layout. You
406can't share a `QueryClient`; instead, you must rely on their [monkey-patched
407`fetch`][nextjs-fetch] to cache the same `GET` request like they promise.
408
409When a coworker asks me about why Next.js rejects some code, I've given up on
410explaining the technical intricacies and just say *"It's a Next.js Skill Issue,
411I'm going to blow it up soon don't worry."* These rules are too hard for normal
412developers to understand.
413
414[nextjs-fetch]: https://nextjs.org/docs/app/api-reference/functions/fetch
415
416[§2.4]: #rsc-payload
417
418<Heading
419 level='h3'
420 slug='rsc-payload'
421>You Still Download All the Content Twice</Heading>
422
423Unlike the ["Islands Architecture"][islands], Server Components still have to
424be hydrated on the frontend to support `Suspense` and preserving client
425component state. When doing soft navigations, the "RSC Payload" (which is not
426HTML at all) is retrieved by `fetch`. On a fresh reload, HTML is needed for the
427[first paint], but the information about Client components and `Suspense` is
428not contained within that HTML. React's solution is to **send a second copy of
429the entire page's markup**. An example of what a Next.js production server
430would send in a dynamic page render would be something like this:
431
432[first paint]: https://web.dev/articles/fcp
433
434```html filename="GET /user/clover"
435<!DOCTYPE html>
436<html>
437<head>
438 {link and meta tags}
439</head>
440<body>
441 {server side render}
442 <script>
443 // a bootstrap script that sets up global `__next_f` as
444 // an array. once React loads, this `.push` function
445 // gets overwritten to write new chunks directly to the
446 // RSC decoder. this script has some dom helpers too
447 (self.__next_f=self.__next_f||[]).push([0])
448 </script>
449 <script>
450 // the RSC payload for the application shell.
451 self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[658993,[\"/_next/st{...}"])
452 </script>
453
454 <!--
455 the closing </body> is NOT written yet, since there is a
456 suspense boundary not resolved. time passes, and only
457 then is more data is written
458 -->
459 <div class="user-post-list">
460 {server side render of a Suspense boundary}
461 </div>
462 <script>
463 // the RSC payload for the suspense boundary
464 self.__next_f.push([2,"14:[\"$\",\"div\",null,{\"children\":[[\"$\",\"h4\"{...}"])
465 </script>
466
467 <!-- HTML and script tags repeat until the entire page is done -->
468</body>
469</html>
470```
471
472This solution **doubles the size of the initial HTML payload**. Except it's
473worse, because the RSC payload includes JSON quoted in JS string literals,
474which is a is much less efficient format than HTML. While it seems to compress
475fine with brotli and render fast in the browser, this is wasteful. With the
476hydration pattern, at least the data locally could be re-used for interactivity
477and other pages.
478
479Even on pages that have little to no interactivity, you pay the cost. To use
480the Next.js documentation as an example, loading [its
481homepage](https://nextjs.org/docs) loads an page that is around 750kB (250kB of
482HTML and the 500kB of script tags), and content is in there twice.
483
484You can verify that by pressing <kbd>Cmd</kbd> + <kbd>Opt</kbd> + <kbd>u</kbd>
485on Mac or <kbd>Ctrl</kbd> + <kbd>u</kbd> on other platforms. And then
486<kbd>Cmd</kbd> / <kbd>Ctrl</kbd> + <kbd>f</kbd> to locate any string of the
487blog, such as "building full-stack web applications". It's there twice. And
488**there is no way around this**, since it's a fundamental piece of React Server
489Components.
490
491This RSC format certainly has more waste. But I really don't feel like digging into
492why the string `/_next/static/chunks/6192a3719cda7dcc.js` appears 27 separate
493times. What the hell, guys? Is your bandwidth free???
494
495[islands]: https://www.patterns.dev/vanilla/islands-architecture/
496
497[§2.5]: #turbopack
498
499<Heading
500 level='h3'
501 slug='turbopack'
502>Turbopack Sucks</Heading>
503
504This section is not constructive.
505
506- Turbopack isn't fast
507- Turbopack emits code that is hard to debug in a debugger (in development mode)
508- Turbopack throws bad error messages in many cases
509
510I wouldn't have given this point a section in the blog normally, but I want to
511point out three actual examples directly from the project.
512
513The first is a place where during some refactoring to satisfy the Server/Client
514component models, I accidentally made a Client component `async`. This one was
515quite annoying because it didn't say at all where the issue was, but only
516contained the <b class='server'>server</b> stack trace.
517
518![Next.js error](/file/2025/blog-everyone-hates-nextjs/asyncerror.png)
519
520Another case of a terrible error message:
521
522![Next.js error](/file/2025/blog-everyone-hates-nextjs/nexterror.png)
523
524> After fixing the underlying issue in this second error (which I cannot recall),
525> the Dev server hung and had to be restarted to recover.
526
527The final one is the dozen times I place a debugger breakpoint and the
528variable name `hello` gets turned into
529`__TURBOPACK__imported__module__$5b$project$5d2f$client$2f$src$2f$utils$2f$filename$2e$ts__$5b$app$2d$client$5d$__$28$ecmascript$29$__["hello"]`
530and other bullshit.
531
532Okay. This all sucks. What can we do?
533
534[§3]: #ditching-nextjs
535
536<Heading
537 level='h2'
538 slug='ditching-nextjs'
539>Seamlessly Ditching Next.js and Vercel at Work</Heading>
540
541There are two types of web projects:
542
543- A web site with mostly static content.
544- A web app with majorly dynamic and interactive components.
545
546And Next.js is the wrong tool for both of these jobs. If you're in the first
547category with a static web site, go for [Astro] or [Fresh]. For everyone who
548needs the full power of React, this section is about how I replaced the vendor
549locked Next with [TanStack Start], incrementally and seamlessly.
550
551[Astro]: https://astro.build/
552[Fresh]: https://fresh.deno.dev/
553[TanStack Start]: https://tanstack.com/start/latest
554
555It started with this Vite config.
556
557```ts filename="vite.config.ts"
558const config = defineConfig(({ mode }) => {
559 const env = loadEnv(mode, process.cwd(), "NEXT_PUBLIC_");
560 return {
561 // Use the Next.js default port 3000
562 server: { port: 3000 },
563 // Use the Next.js default env prefix "NEXT_PUBLIC_"
564 define: Object.fromEntries(Object.entries(env).map(
565 ([k, v]) => [`process.env.${k}`, JSON.stringify(v)])),
566 plugins: [
567 viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
568 tailwindcss(),
569 // For ease of understanding from coworkers, I started porting
570 // the routes in `src/tanstack-routes`. When the migration was
571 // done, it would go back to the default `src/routes`.
572 tanstackStart({
573 router: { routesDirectory: "src/tanstack-routes" },
574 }),
575 viteReact(),
576 ],
577 resolve: {
578 // The key to the incremental migration: redirect `next` elsewhere
579 alias: { next: path.resolve("./src/tanstack-next/") },
580 conditions: ["tanstack"],
581 extensions: [
582 // Allow a file named like `utils/session.tanstack.ts` to
583 // override `utils/session.ts` when imported.
584 ".tanstack.tsx", ".tanstack.ts",
585 // Default import extensions
586 ".mjs", ".js", ".mts", ".ts",
587 ".jsx", ".tsx", ".json",
588 ],
589 },
590 };
591});
592```
593
594Then, I looked for every usage of a Next.js API, and either removed it or made
595a stub for TanStack. For example, `src/tanstack-next/link.tsx` implements
596`next/link`:
597
598```tsx filename="src/tanstack-next/link.tsx"
599import { Link } from "@tanstack/react-router";
600import type { LinkProps } from "next/link";
601
602export default function LinkAdapter({ href, ...rest }: LinkProps) {
603 return <Link {...rest} to={href as unknown as any} />;
604}
605```
606
607> Some of these stubs can be extremely simple. Starting out, my implementation
608> of `useRouter` was just `return {}`, but later I had to add a couple methods
609> to the object. The code here doesn't have to be clean, because it is
610> temporary.
611
612Now, the new site can import nearly every client component by either stubbing
613out the Next.js APIs it needs, or by using the `.tanstack.ts` extension to
614re-implement logic on a file-by-file basis. And shortly after, I got the site's
615homepage to work in TanStack Start, and we merged the branch.
616
617![My "nextgate" PR](/file/2025/blog-everyone-hates-nextjs/pr.png)
618
619> This first PR only supported one of our pages, and was able to do it in a
620> thousand lines of added code, and 40 lines deleted. I had previous patches to
621> remove the few uses of `next/image` and `next/font`.
622
623What was left was porting every other route over. The one thing we lose in
624migrating from Next.js to any other framework is the ability to `await`
625data-fetching functions in the UI. In practice, moving every route into a
626`loader` function made it much more clear what happened when a page was SSR'd.
627For pages that had multiple fetches, these could be combined into a single,
628special API call that would return all of the relevant data for that page.
629
630To re-iterate in bold font: <strong style='color:var(--secondary)'>The
631migration path from Server Components is to just simplify your code &mdash; RSC
632inherently drives you down a chaotic road of things you do not need</strong>.
633Nearly every complex part of our site got easier to understand for all
634engineers. The exception to this was having everyone get used to the new file
635system routing conventions. With enough examples, we all got the hang of it.
636
637With the incremental migration in place, new code did not break the existing
638deployment. TanStack slowly took over the codebase, and we eventually deleted
639all of the Next.js stubs and gained all of the beautiful [type-safety features]
640that the TanStack Router provides. At the end, the site performed faster from
641every angle: Development Mode, Production page load times, Soft navigations,
642and at a lower price than our Next depoyment with Vercel.
643
644[type-safety features]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety
645
646We're not the only ones seeing the change. While I try and keep myself off of
647social media, someone sent me [the results of Brian Anglin's work at
648Superwall][superwall-twitter], showing incredible CPU reductions on TanStack
649Start. I also recall ChatGPT switching from Next.js to Remix (random online
650chatter: [[1][chatgpt-1]] [[2][chatgpt-2]] [[3][chatgpt-3]]) a year ago.
651
652[superwall-twitter]: https://twitter.com/BriansAngles/status/1978834116079436242#m
653[chatgpt-1]: https://xcancel.com/ryanflorence/status/1831379475654947233
654[chatgpt-2]: https://old.reddit.com/r/reactjs/comments/1f97zgr/chatgpt_migrates_from_nextjs_to_remix
655[chatgpt-3]: https://old.reddit.com/r/nextjs/comments/1f92jdv/chatgptcom_switched_from_nextjs_to_remix
656
657[§3.1]: #next-metadata
658
659<Heading
660 level='h3'
661 slug='next-metadata'
662><code>next/metadata</code> is Great</Heading>
663
664In my opinion, this is one of the only good APIs Next.js has, and was the one
665place in our code where moving to TanStack made things harder to do. Instead of
666worsening the code, I just ported their metadata API into a regular function,
667so everyone can use it. Originally, I had a 1:1 port on NPM, but earlier this
668year I simplified it's API into one short and understandable
669file. As of this blog post, I have added a TanStack-compatible
670`meta.toTags` API, which can be installed from [JSR][lib-jsr], [NPM][lib-npm],
671or simply copied into your project.
672
673> **notice**: Due to time constraints with writing this article, the library
674> has not yet been updated. I'll probably get around to it by the ~~end of this
675> week (Oct 24th)~~ some time soon... As a placeholder, I'm able to share the
676> version that is used at work to my website:
677> [`meta.tanstack.ts`](https://paperclover.net/file/2025/blog-everyone-hates-nextjs/meta.tanstack.ts).
678
679```tsx
680// once in your project
681import * as meta from "@clo/lib/meta.ts";
682
683export const defineHead = meta.toTags.bind(null, {
684 // site-wide options
685 base: new URL("https://paperclover.net"),
686 titleTemplate: (title) => [title, "paper clover"]
687 .filter(Boolean).join(' | '),
688 // ...
689});
690
691// for each page...
692export const Route = createFileRoute("/blog")({
693 head: () =>
694 defineHead({
695 title: "clover's blog", // templated with `titleTemplate`
696 description: "a catgirl meows about her technology viewpoints",
697 canonical: "/blog", // joined with `base`
698
699 // When specified, configures Open Graph and Twitter embed,
700 // using the page title and description as the default.
701 // The defaults are good, but it supports more options.
702 embed: {
703 image: "/img/blog.webp",
704 },
705
706 // Every exotic meta tag is done with a JSX fragment. This
707 // doesn't render React, it just loops through the tags.
708 // My goal was to cover the most common 99% of uses.
709 extra: <>
710 <meta name="site-verification" content="waffles" />,
711 </>,
712 }),
713
714 component: Page,
715});
716
717function Page() {
718 ...
719}
720```
721
722My version wasn't concerned with covering the entire space of Next.js's metadata
723object, but instead uses inline JSX to fill that gap.
724
725[lib-jsr]: https://jsr.io/@clo/lib
726[lib-npm]: https://npmjs.com/@paperclover/lib
727
728[§3.2]: #vercel-og
729
730<Heading
731 level='h3'
732 slug='ditching-nextjs'
733><code>next/og</code> is Good Too</Heading>
734
735No strong opinions. I just want to remind everyone that the `@vercel/og` package exists.
736
737[§4]: #experience-feels-like-the-usual
738
739<Heading
740 level='h2'
741 slug='experience-feels-like-the-usual'
742>My Experience Feels like the Usual</Heading>
743
744At the Next.js Conf 2024, everyone there was raving about Server Components. I
745forget exactly who I talked to, but the big people were all in on this. I,
746having implemented the bundler end of RSC, saw a couple of the problems in the
747format. With Next 15 "stabilizing" the App Router last year, many companies are
748building their products on it, realizing these pitfalls first-hand.
749
750I came into the Next.js game late, only starting in June with version 15.
751But everyone I've talked to at events sympathize with my notes. All the people
752I talked to on the subject at Bun's 1.3 Party agreed with me. Even some people
753at Vercel told me they don't like how Next.js is to actually use.
754
755I hope as TanStack Start stabilizes, it becomes the Next.js replacement everyone
756wants.
757
758[§5]: #prefer-respectful-tools
759
760<Heading
761 level='h2'
762 slug='prefer-respectful-tools'
763>Prefer Tools that Respect You</Heading>
764
765A lot of in the JavaScript ecosystem is a mess. That mess is why web
766development gets made fun of. There were a lot of times I thought that working
767with the web was an unrecoverable mess, but the mess was actually just the
768commonly-used libraries I surrounded myself with. When that is peeled back,
769modern web development technologies are awesome.
770
771I've been making this website from scratch without any framework since late
7722024, by writing systems like my own [TUI progress widget][progress], [static
773file proxy][file-cache], incremental build system, and many more components.
774Working on this code has produced some of my best coding sessions (by
775happiness) in years. The viewers of *[paper clover]* get a better quality
776website; the mini-libraries I create get [extracted for public use][lib],
777everyone wins.
778
779This level of from-scratch is too much for most people, especially at the
780workplace. I say that at the minimum, we should only give our attention and
781money to high quality tools that respect us. And Next.js and the company behind
782it, Vercel, are not that.
783
784If you use Next.js, and feel that the experience doesn't remind you of respect
785too, consider whether you and your colleagues want to continue supporting their
786[serverless empire]. The Vite ecosystem seems pretty decent to build on right
787now, but I still have little experience in using their tools at scale in
788production. The [Vite+ launch from Void0][vite-plus] seems interesting, but
789only time will tell if these venture-funded tools will respect us (end-users
790and developers) long term.
791
792Next.js Conf 2025, as of writing, is [tomorrow][next-conf]. Instead of
793purchasing a $800 ticket, I decided to put that money [toward the TanStack
794team][tanstack-donate] for [respecting and improving the web development
795ecosystem][tanstack-ethos].
796
797[paper clover]: https://paperclover.net/
798[lib]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib#readme
799[progress]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib/progress.ts
800[file-cache]: https://git.paperclover.net/clo/sitegen/src/branch/master/src/file-viewer/cache.ts
801
802[next-conf]: https://nextjs.org/conf
803[vite-plus]: https://viteplus.dev/
804[tanstack-ethos]: https://tanstack.com/ethos
805[tanstack-donate]: https://github.com/sponsors/tannerlinsley
806[serverless empire]: https://youtu.be/SCIfWhAheVw
807
808## What the Future Holds
809
810Slowly, I've been replacing many pieces of software that disrespect me with
811better alternatives. Some examples of this are GitHub, Visual Studio Code,
812DaVinci Resolve, Discord, Google Drive/Workspace, along many more. I plan to
813write more on this blog about the technical things I do (that progress library,
814the purpose of my own site generator, learnings from my current job), including
815some of my past projects at Bun (details on HMR, the crash reporter, and the
816crazy system for bundling built-in modules). If it interests you, please
817subscribe to the email list:
818
819<a href="mailto:subscribe@paperclover.net?subject=paper%20clover%20mailing%20list&body=I%20would%20like%20to%20be%20subscribed%20to%20the%20following%20mailing%20lists%3A%0A%0A-%20Technical%20Blog%20Posts%20-%20YES%0A-%20Art%20(Original%20Music%2FVideo)%20-%20YES%0A%0A(feel%20free%20to%20write%20whatever%20else%20you%20want)">click here to send an email to <code>subscribe@paperclover.net</code>, requesting that you would like to be added to the mailing list.</a> (i manage this mailing list manually)
820
821[back to top](#top) &mdash; [ask a question about this article](/q+a)
822
823<br />
824<br />
825<br />
826<br />
827<br />
828<br />
829<footer>
8302025 (c) paper clover
831</footer>
832
833</Layout>
834
835<br />
836
src/blog/pages/webdev/one-year-next-app-router/ko.mdx created+624
......@@ -0,0 +1,624 @@
1import Heading from "@/blog/tags/heading.tsx";
2import TableOfContents from "@/blog/tags/table-of-contents.tsx";
3import { Layout } from "@/blog/tags/layout.tsx";
4export { theme } from "@/blog/tags/layout.tsx";
5
6export const meta = {
7 title: "Next.js 앱 라우터와 함께한 1년 — 우리가 떠나기로 한 이유",
8 description: "리액트 서버 컴포넌트와 Next.js 15에 대한 비판",
9 keywords: ["webdev", "technical analysis", "opinion"],
10 authors: ["clover caruso", "Chanhee Kim"],
11 embed: {
12 thumbnail: "/open-graph/next-js.ko.png"
13 },
14 twitter: {
15 image: "https://paperclover.net/open-graph/next-js.ko.png"
16 },
17 canonical: "/blog/webdev/one-year-next-app-router.ko"
18};
19
20<Layout
21 meta={meta}
22 date={'Oct 21st, 2025'}
23 slug="webdev/one-year-next-app-router"
24 translation={{
25 lang: "ko",
26 author: "Chanhee Kim",
27 href: "https://substack.com/@chanheekim377573",
28 date: "Dec 8th, 2025"
29 }}
30>
31
32직장에서 웹 앱 개발에 Next.js를 전문적으로 사용해오면서, 앱 라우터와 [리액트 서버 컴포넌트(React Server Components, RSC)][rsc]의 핵심 설계가 매우 답답하게 느껴졌습니다. 사소한 버그나 API의 혼란스러움이 아니라, Vercel과 리액트 팀이 이를 구축할 때 내린 근본적인 설계 결정에 대한 큰 이견이 있기 때문입니다.
33
34웹 개발 행사에 갈때마다 Next.js를 싫어함에도 계속 사용해야 하는 사람들을 더 많이 보게 됩니다. 이 글의 마지막에는 저와 동료들이 어떻게 이 지옥에서 탈출하여 전체 프론트엔드를 [TanStack Start]로 원활하게 마이그레이션했는지 공유하겠습니다.
35
36[TanStack Start]: https://tanstack.com/start/latest
37[rsc]: https://react.dev/reference/rsc/server-components
38
39<TableOfContents>
40
41- [기술 리뷰: 서버 컴포넌트란 무엇인가요?][§1]
42- [실제로 발생하는 앱 라우터의 문제점들][§2]
43 - [낙관적 업데이트는 불가능합니다][§2.1]
44 - [모든 탐색은 또 다른 페치 요청입니다][§2.2]
45 - [레이아웃은 인위적으로 제한됩니다][§2.3]
46 - [여전히 모든 콘텐츠를 두 번 다운로드합니다][§2.4]
47 - [터보팩은 구립니다][§2.5]
48- [업무에서 Next.js와 Vercel을 매끄럽게 대체하기][§3]
49 - [`next/metadata`는 훌륭합니다][§3.1]
50 - [`next/og` 또한 좋습니다][§3.2]
51- [제 경험은 일반적인 것 같아요][§4]
52- [사용자를 존중하는 도구를 선택하세요][§5]
53
54</TableOfContents>
55
56[§1]: #technical-review
57
58<Heading
59 level='h2'
60 slug='technical-review'
61>기술 리뷰: 서버 컴포넌트란 무엇인가요?</Heading>
62
63RSC의 핵심은 컴포넌트를 <b class='server'>"서버"</b> 컴포넌트와 <b class='client'>"클라이언트"</b> 컴포넌트 두 가지 범주로 분류한다는 점입니다. 서버 컴포넌트는 `useState`나 `useEffect`를 사용하지 않지만, `async function`일 수 있으며 데이터베이스에 직접 호출하는 등 백엔드 도구를 참조할 수 있습니다. 클라이언트 컴포넌트는 기존 모델로, 백엔드에서 HTML 텍스트를 생성하는 코드와 `window.document.*`를 사용하여 DOM을 관리하는 프론트엔드 코드가 존재합니다.
64
65> 첫 번째 재앙: 명명법!! 리액트는 이제 기존 정의를 무시하고 <b class='server'>"서버"</b>와 <b class='client'>"클라이언트"</b>라는 단어를 매우 특정한 개념을 가리키는 데 사용하고 있습니다. <b class='client'>클라이언트</b> 컴포넌트도 백엔드에서 실행될 수 있다는 점을 제외하면 괜찮을 텐데요! 이 글에서는 웹 앱이 존재하는 두 가지 실행 환경, 즉 Node.js 프로세스와 웹 브라우저를 각각 설명하기 위해 <b>"백엔드"</b>와 <b>"프론트엔드"</b>라는 용어를 사용할 것입니다.
66
67이 <b class='server'>"서버"</b>/<b class='client'>"클라이언트"</b> 컴포넌트 모델은 흥미롭습니다. `<Suspense />` 같은 내장 컴포넌트가 네트워크를 통해 직렬화되기 때문에, 비동기 <b class='server'>서버 컴포넌트</b>로 데이터 가져오기를 아주 간단하게 모델링할 수 있으며, 폴백 UI는 마치 클라이언트 측에서 작동하는 것처럼 동작합니다.
68
69```tsx filename="src/app/[username]/page.tsx" tint="server"
70// 이 글에서 서버 컴포넌트는 빨간색으로 강조 표시됩니다.
71export default async function Page({ params }) {
72 // Page 매개변수는 해결된 프로미스로 제공됩니다
73 const { username } = await params;
74
75 // `UserInfo` 및 `UserPostList` 컴포넌트는 동시에 실행됩니다.
76 // `UserInfo`가 준비되면, 방문자는 게시물 목록이 아직 준비되지 않은 경우
77 // `PostListSkeleton`이 포함된 페이지를 보게 됩니다.
78 return <main>
79 <UserInfo username={username} />
80
81 <Suspense fallback={<PostListSkeleton />}>
82 <UserPostList username={username} />
83 </Suspense>
84 </main>
85}
86
87// 워터폴은 여러 컴포넌트를 동시에 평가함으로써 방지됩니다.
88
89async function UserInfo({ username }) {
90 const user = await fetchUserInfo(username);
91 return <>
92 <h1>{user.displayName}</h1>
93 {user.bio ? <Markdown content={user.bio} /> : ""}
94 </>
95}
96
97async function UserPostList({ username }) {
98 const posts = await fetchUserPostList(username);
99 return /* post list ui omitted for brevity */;
100}
101```
102
103리액트 자체의 40kB gzip 압축 번들을 제외하면, 위 예시는 UI와 데이터 페칭을 위한 자바스크립트가 전혀 없습니다. 단순히 마크업을 스트리밍할 뿐이죠! 예를 들어, `<Markdown />` 컴포넌트 내부의 가상 마크다운 파서는 백엔드에 그대로 남아 있습니다. 인터랙티브한 프론트엔드가 필요할 때는, "use client"로 시작하는 파일에 컴포넌트를 배치하여 <b class='client'>클라이언트 컴포넌트</b>를 만들 수 있습니다.
104
105```tsx filename="src/components/CopyButton.tsx" tint="client"
106"use client"; // 이 주석은 파일이 클라이언트 사이드로 번들링 되도록 마킹합니다.
107
108export function CopyButton({ url }) {
109 return <>
110 <span>{url}</span>
111 <button onClick={() => {
112 const full = new URL(url, location.href);
113 navigator.clipboard.writeText(full.href);
114 // 에러 처리나 성공시 보여주는 ui는 제외했습니다
115 }}>copy</button>
116 </>
117}
118```
119```tsx filename="src/app/q+a/Card.tsx" tint="server"
120export function Card() {
121 return <article>
122 <header>
123 {/* 브라우저가 CopyButton을 import 하도록 합니다 */}
124 <CopyButton url="/q+a/2506010139" />
125 </header>
126 <p>
127 {/* 마크다운 처리는 백엔드에서 수행됩니다 */}
128 <Markdown content=".........." />
129 </p>
130 </article>
131}
132```
133
134[§2]: #real-world-pitfalls
135
136<Heading
137 level='h2'
138 slug='real-world-pitfalls'
139>실제로 발생하는 앱 라우터의 문제점들</Heading>
140
141런타임 엔지니어로 근무하던 [Bun]을 그만둔 후([서버 컴포넌트 번들링]과 [RSC 템플릿][bun-rsc]을 구현했습니다), 저는 최전선에서 일하는 소규모 회사에 합류했습니다. Hono 백엔드를 가진 Next.js 애플리케이션이었습니다. 다음 내용들은 실제 현장에서 유지보수 및 신규 기능 개발 시 마주친 문제들을 단순화한 것입니다. 이 모든 것들의 결과로 인해, 모두가 설계상의 결함을 우회하거나, 당연히 해결되어야 할 문제가 왜 해결 불가능한 장애물이 되었는지 서로 설명하는 데 시간을 낭비하게 되었습니다.
142
143[Bun]: https://bun.com
144[서버 컴포넌트 번들링]: https://github.com/oven-sh/bun/blob/67f0c3e016aa479738469adac2b79a1862b88122/src/bake/bake.d.ts
145[bun-rsc]: https://github.com/oven-sh/bun/tree/e7790894d92b730758ecadf971cb935063508dfb/src/bake/bun-framework-react
146
147[§2.1]: #optimistic-updates
148
149<Heading
150 level='h3'
151 slug='optimistic-updates'
152>낙관적 업데이트는 불가능합니다</Heading>
153
154Next.js 문서에는 변경 수행 시 [낙관적 업데이트에 대한 언급이 없습니다][nextjs-updating-data]. 이 경우를 고려하지 않은 것으로 보입니다. <b class='server'>리액트 서버</b>에서 렌더링되는 컴포넌트는 설계상 마운팅 후 수정할 수 없습니다. 변경될 수 있는 요소는 클라이언트 컴포넌트 내에 있어야 하지만, 백엔드에서 SSR(서버 측 렌더링) 중에도 클라이언트 컴포넌트에서 데이터 가져오기가 불가능합니다. 이로 인해 데이터 가져오기만 수행하는 어색하게 작은 서버 컴포넌트와, 대부분 정적인 버전의 페이지를 포함하는 클라이언트 컴포넌트가 분리되어 있는 구조가 됩니다.
155
156[nextjs-updating-data]: https://nextjs.org/docs/app/getting-started/updating-data
157
158```tsx filename="src/app/user/[username]/page.tsx" tint="server"
159export default async function Page() {
160 const user = await fetchUserInfo(username);
161 return <ProfileLayout>
162 <UserProfile user={user} />
163 </ProfileLayout>;
164}
165```
166```tsx filename="src/app/user/[username]/UserProfile.tsx" tint="client"
167"use client"; // 클라이언트 코드를 반드시 두 번째 파일로 분리해야 합니다!
168
169export function UserProfile({ user: initialUser }) {
170 // 훌륭한 상태 관리 라이브러리들이 많이 존재합니다.
171 // 단순화를 위해 이 예제에서는 하나의 상태 셀을 사용하겠습니다.
172 const [user, optimisticUpdateUser] = useState(initialUser);
173
174 async function onEdit(newUser) {
175 optimisticUpdateUser(newUser);
176 const resp = await fetch("...", {
177 method: 'POST',
178 body: JSON.stringify(newUser),
179 ... // (헤더, 자격 증명, 추적 등)
180 })
181 if (!resp.ok) /* 항상 오류 검사를 잊지 마세요! */
182 }
183
184 return <main>{/* 편집 가능한 필드가 있는 사용자 인터페이스... */}</main>:
185}
186```
187
188페이지의 상호작용 요소가 늘어날수록 정적 부분을 서버 측에서 완전히 처리하기 복잡해집니다. 업무용 앱에서는 거의 모든 UI 요소가 동적 데이터를 표시합니다. [`WebSocket`][ws]은 데이터가 업데이트될 때 실시간으로 동기화합니다(예: 사용자 카드의 온라인 상태와 기본 프로필 정보). 이러한 컴포넌트 설정은 엔지니어가 이해하고 유지하기 어렵기 때문에, 거의 모든 페이지가 데이터 가져오기를 정의하는 `page.tsx`를 통해 완전히 `"use client` 방식으로 구현됩니다.
189
190[ws]: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
191
192직장에서 사용하는 데이터 페칭 라이브러리인 [TanStack Query](https://github.com/tanstack/query#readme)를 통해 실제 적용 사례를 보다 구체적으로 살펴보겠습니다.
193
194```ts filename="src/queries/users.ts"
195// 작업 시 타입 안전성을 위해 `defineQuery` 헬퍼 함수가 사용됩니다.
196// 페처는 단순하며 백엔드나 프론트엔드에서 실행될 수 있습니다.
197export const queryUserInfo = (username) => ({
198 queryKey: ['user', username],
199 queryFn: async ({ ... }) => /* fetch data */
200});
201```
202```tsx filename="src/app/user/[username]/page.tsx" tint="server"
203export default async function Page({ params }) {
204 const { username } = await params;
205
206 // 리액트 서버에는 글로벌 상태가 없습니다.
207 // 레이아웃이 병렬로 실행되기 때문에 TanStack `QueryClient`는 경로 마다 여러 번 재구성되어야 합니다.
208 const queryClient = new QueryClient();
209 await queryClient.ensureQueryData(queryUserInfo(username));
210
211 // HydrationBoundary는 리액트 서버에서 클라이언트 컴포넌트로
212 // JSON 데이터를 전달하는 클라이언트 컴포넌트입니다.
213 return <HydrationBoundary state={dehydrate(queryClient)}>
214 <ClientPage />
215 </HydrationBoundary>;
216}
217```
218```tsx filename="src/app/user/[username]/ClientPage.tsx" tint="client"
219"use client";
220export function ClientPage() {
221 const { username } = useParams();
222 const { data: user } = useSuspenseQuery(queryUserInfo(username));
223
224 // ... 이외의 다른 훅들
225
226 return <main>
227 {/* ... 인터랙티브 웹 페이지 */}
228 </main>;
229}
230```
231
232이 예제는 서버 컴포넌트 번들링 규칙 때문에 반드시 세 개의 별도 파일로 구성되어야 합니다. (클라이언트 컴포넌트는 `"use client"`가 필요하며, 서버 전용 임포트 때문에 서버 컴포넌트 파일은 클라이언트에서 종종 임포트할 수 없습니다.) Pages 라우터에서는 `getStaticProps`와 `getServerSideProps`가 트리 셰이킹을 지원하기 때문에 단일 파일로 구현할 수 있었습니다.
233
234[§2.2]: #redundant-fetches
235
236<Heading
237 level='h3'
238 slug='redundant-fetches'
239>모든 탐색(navigation)은 또 다른 페치 요청입니다</Heading>
240
241앱 라우터는 모든 페이지를 서버 컴포넌트로 시작하며 이상적으로는 상호작용 영역이 작기 때문에, 새 페이지로 이동할 때는 클라이언트가 이미 보유한 데이터와 무관하게 Next.js 서버를 다시 호출해야 합니다! `loading.tsx` 파일이 있더라도, `/`를 열고 `/other`로 이동한 후 다시 `/`로 돌아오면 홈페이지를 재로딩하는 동안 로딩 상태가 표시됩니다.
242
243이 방법이 통하는 유일한 경우는 <b>완벽히 정적인 콘텐츠</b>일 때로, 이때는 즉각적인 탐색과 사전 로딩이 훌륭하게 작동합니다. 하지만 <b>웹 애플리케이션은 정적이지 않고</b> 다량의 동적 콘텐츠를 포함합니다. 클라이언트가 페이지를 즉시 표시하는 데 필요한 모든 것을 이미 가지고 있음에도 불구하고, 로그인 상태가 홈페이지를 변경시키는 것은 정말 짜증나는 일입니다. 쿠키가 변경된 것도 아닌데 말이죠.
244
245> 참고: 빈 프로젝트에서 추가 테스트를 진행한 결과, Next 프론트엔드 코드가 실제 콘텐츠 없이 경로를 미리 가져오는 사례를 관찰했습니다. 'Hello World' 예제에서는 1.8kB 크기의 RSC 페이로드가 2개의 서로 다른 JS 청크를 4번에 걸쳐 가리켰습니다. 이는 순전히 대역폭과 아웃바운드 트래픽을 낭비하는 행위입니다. 특히 링크를 실제로 클릭할 때 이 모든 정보를 다시 가져온다는 점을 고려하면 더욱 그렇습니다.
246>
247> ```json whitespace="pre-wrap"
248> 1:"$Sreact.fragment"
249> 2:I[39756,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
250> 3:I[37457,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"default"]
251> 4:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"ViewportBoundary"]
252> 6:I[97367,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"MetadataBoundary"]
253> 7:"$Sreact.suspense"
254> 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}
255> 5:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
256> 9:I[27201,["/_next/static/chunks/ff1a16fafef87110.js","/_next/static/chunks/7dd66bdf8a7e5707.js"],"IconMark"]
257> 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",{}]]
258> ```
259>
260> 검토해 보니 여기에 실제로 일부 내용이 있더군요. 로딩 상태입니다. 보이시나요?
261>
262> ```json
263> ["$","div","l",{"children":"loading..."}]
264> ```
265>
266> 이 모든 데이터가 실제 페이지 RSC에서 다시 전송되기 때문에 여전히 큰 낭비입니다.
267
268이 문제의 해결책으로 보이는 [`staleTime`][nextjs-stale]은 실험적 기능으로 분류되어 "실제 운영 환경에서는 권장되지 않는다"고 명시되어 있습니다. 이 기능이 비기본 설정의 추가 구성 옵션으로 처리되고 있다는 사실 자체가 당혹스럽습니다. 설령 이를 사용한다고 해도, 동일한 기본 데이터를 참조하는 여러 페이지 간에 데이터를 공유하는 것은 불가능합니다.
269
270[nextjs-stale]: https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes
271[nextjs-stale-bug]: https://github.com/vercel/next.js/issues/70661
272
273앱 라우터로는 표현할 수 없는 로딩 상태의 한 형태는, 예를 들어 Git 프로젝트의 이슈 페이지와 같은 페이지에서 사용자 이름을 클릭해 해당 프로필 페이지로 이동하는 경우입니다. `loading.tsx`를 사용하면 전체 페이지가 스켈레톤 형태로 표시되지만, TanStack Query로 이러한 쿼리를 모델링하면 사용자 정보와 저장소를 불러오는 동안 사용자 이름과 아바타를 즉시 표시할 수 있습니다. 서버 컴포넌트는 렌더링된 컴포넌트에서만 데이터가 사용 가능하기 때문에 이 형태의 탐색을 지원하지 않습니다. 따라서 데이터를 다시 가져와야 합니다.
274
275우리 Next.js 사이트의 서버 컴포넌트 데이터 페처에는 데이터 페치 단계를 완전히 건너뛰어 소프트 네비게이션을 더 빠르게 만들기 위한 코드 라인이 있습니다.
276
277```tsx filename="src/util/tanstack-query-helpers.server.ts" tint="server"
278export function serverSidePrefetchQueries(queries) {
279 if ((await headers()).get("next-url")) {
280 // 이건 소프트 네비게이션입니다. 더 빠르게 하려면 프리페칭을 건너뛰세요.
281 // 클라이언트가 이미 이 데이터를 가지고 있을 수 있으며, 그렇지 않더라도 로딩 상태를 가지고 있습니다.
282 // 이상적으로는 이 서버 요청은 없어야 합니다. 앱의 대부분이 클라이언트 컴포넌트로 작성되어
283 // 거의 모든 코드가 클라이언트 측에 있기 때문이죠. 솔직히 말해서 앱 라우터의 설계상의 결함이라고 할 수 있죠.
284 return;
285 }
286 // ... 데이터 프리 페칭 로직 ...
287}
288```
289
290또한 `loading.tsx`에는 `useQuery` 호출이 포함되어야 합니다. 이렇게 하면 빈 RSC에 대한 네트워크 요청이 발생하는 동안 실제로 필요한 경우 데이터를 가져올 수 있습니다. 실제로 `loading.tsx`의 상태는 실제 클라이언트 컴포넌트 자체일 수 있으며, 그러면 클라이언트 페이지가 표시됩니다.
291
292```tsx filename="src/app/user/[username]/loading.tsx" tint="client"
293"use client";
294export default function PageLoadingSkeleton() {
295 return <ClientPage />;
296}
297```
298
299> 업무에서는 단순히 `loading.tsx` 파일에 `useQuery` 호출을 포함하고 스켈레톤만 표시하도록 만듭니다. Next.js가 실제 서버 컴포넌트를 로드할 때면 어쨌든 페이지 전체가 재마운트되기 때문입니다. 여기서는 VDOM 비교가 발생하지 않으므로, 요청 완료 후 모든 훅(useState)이 약간의 지연후에 초기화됩니다. 기존 DOM만 업데이트하고 상태를 유지하도록 Next.js에 간청하는 간단한 사례를 재현해 보려 했지만, 그렇게 되지 않았습니다. 다행히 빈 RSC 호출에 소요되는 시간은 충분히 짧습니다.
300
301[§2.3]: #layout-restrictions
302
303<Heading
304 level='h3'
305 slug='layout-restrictions'
306>레이아웃은 인위적으로 제한됩니다</Heading>
307
308레이아웃은 데이터를 가져올 수는 있지만, 요청을 어떤 방식으로든 관찰하거나 변경할 수 없습니다. 이는 Next.js가 원하는 때에 레이아웃을 가져오고 캐시할 수 있도록 하기 위함입니다. 다른 모든 프레임워크에서는 레이아웃이 단순히 일반 컴포넌트일 뿐이며 페이지 컴포넌트와 기능상 차이는 없습니다.
309
310레이아웃을 개별적으로 로드하는 아이디어는 매력적이지만 이는 결국 모든 데이터 로드 작업이 레이아웃마다 재실행되어야 한다는 의미라서 어리석은 선택이 됩니다. `QueryClient`를 공유할 수 없습니다. 대신, 그들이 약속한 것처럼 동일한 `GET` 요청을 캐싱하려면 [몽키 패치된 `fetch` 메서드][nextjs-fetch]에 의존해야 합니다.
311
312[nextjs-fetch]: https://nextjs.org/docs/app/api-reference/functions/fetch
313
314동료가 Next.js가 왜 특정 코드를 거부하는지 묻는다면, 기술적 복잡성을 설명하는 건 포기하고 그냥 "Next.js 기술 문제야, 곧 해결할 테니까 걱정 마"라고 말하곤 합니다. 이 규칙들은 일반 개발자들이 이해하기엔 너무 까다롭습니다.
315
316[§2.4]: #rsc-payload
317
318<Heading
319 level='h3'
320 slug='rsc-payload'
321>여전히 모든 콘텐츠를 두 번 다운로드합니다</Heading>
322
323["아일랜드 아키텍처"][islands]와 달리 서버 컴포넌트는 `Suspense` 지원 및 클라이언트 컴포넌트 상태 보존을 위해 프론트엔드에서 여전히 하이드레이션되어야 합니다. 소프트 네비게이션 시 "RSC 페이로드"(HTML이 전혀 아님)는 `fetch`로 가져옵니다. 새로 고침 시 첫 번째 페인트에는 HTML이 필요하지만, 클라이언트 컴포넌트와 `Suspense` 관련 정보는 해당 HTML에 포함되어 있지 않습니다. 리액트의 해결책은 <b>전체 페이지 마크업의 두 번째 사본을 전송하는 것</b>입니다. Next.js 프로덕션 서버가 동적 페이지 렌더링 시 전송하는 예시는 다음과 같습니다.
324
325[islands]: https://www.patterns.dev/vanilla/islands-architecture/
326[first paint]: https://web.dev/articles/fcp
327
328```html filename="GET /user/clover"
329<!DOCTYPE html>
330<html>
331<head>
332 {link and meta tags}
333</head>
334<body>
335 {server side render}
336 <script>
337 // 글로벌 `__next_f`를 배열로 설정하는 부트스트랩 스크립트.
338 // 리액트가 로드되면 이 `.push` 함수가 재정의되어
339 // 새로운 청크를 RSC 디코더에 직접 기록합니다.
340 // 이 스크립트에는 DOM 헬퍼도 포함되어 있습니다.
341 (self.__next_f=self.__next_f||[]).push([0])
342 </script>
343 <script>
344 // 애플리케이션 셸용 RSC 페이로드.
345 self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[658993,[\"/_next/st{...}"])
346 </script>
347
348 <!--
349 닫는 태그 </body>는 아직 작성되지 않았습니다.
350 해결되지 않은 서스펜스 경계가 존재하기 때문입니다.
351 시간이 흐른 후에야 추가 데이터가 작성됩니다.
352 -->
353 <div class="user-post-list">
354 {server side render of a Suspense boundary}
355 </div>
356 <script>
357 // 서스펜스 경계에 대한 RSC 페이로드
358 self.__next_f.push([2,"14:[\"$\",\"div\",null,{\"children\":[[\"$\",\"h4\"{...}"])
359 </script>
360
361 <!-- HTML 및 스크립트 태그는 페이지 전체가 완료될 때까지 반복됩니다 -->
362</body>
363</html>
364```
365
366이 솔루션은 <b>초기 HTML 페이로드의 크기를 두 배로 늘립니다</b>. 하지만 더 나쁜 점은 RSC 페이로드에 JS 문자열 리터럴로 감싸져 있는 JSON이 포함되어 있다는 것입니다. 이는 HTML보다 훨씬 비효율적인 형식입니다. 브로틀리(brotli)로 잘 압축되고 브라우저에서 빠르게 렌더링되는 것처럼 보이지만, 이는 낭비입니다. 하이드레이션 패턴을 사용하면 최소한 로컬 데이터는 상호작용 및 다른 페이지에서 재사용될 수 있습니다.
367
368상호작용이 거의 없거나 전혀 없는 페이지에서도 비용을 지불하게 됩니다. [Next.js 문서](https://nextjs.org/docs)를 예로 들면, 홈페이지 로딩 시 약 750kB(HTML 250kB와 스크립트 태그 500kB)의 페이지가 로드되며, 콘텐츠가 두 번 포함됩니다.
369
370Mac에서는 <kbd>Cmd</kbd> + <kbd>Opt</kbd> + <kbd>u</kbd>를, 다른 플랫폼에서는 <kbd>Ctrl</kbd> + <kbd>u</kbd>를 눌러 확인할 수 있습니다. 그런 다음 <kbd>Cmd</kbd> / <kbd>Ctrl</kbd> + <kbd>f</kbd>를 눌러 블로그의 특정 문자열(예: "풀스택 웹 애플리케이션 구축")을 찾아보세요. 두 번 등장합니다. 이는 리액트 서버 컴포넌트의 핵심 요소이므로 <b>피할 수 없습니다</b>.
371
372이런 RSC 형식은 분명히 더 많은 낭비를 낳습니다. 하지만 정말로 `/_next/static/chunks/6192a3719cda7dcc.js`라는 문자열이 27번이나 따로따로 나타나는 이유를 캐내고 싶진 않네요. 뭐야, 너희들. 대역폭이 공짜냐???
373
374[§2.5]: #turbopack
375
376<Heading
377 level='h3'
378 slug='turbopack'
379>터보팩은 구립니다</Heading>
380
381이 섹션은 건설적이지 않습니다.
382
383- 터보팩은 빠르지 않습니다
384- 터보팩은 디버거에서 디버깅하기 어려운 코드를 생성합니다(개발 모드에서)
385- 터보팩은 많은 경우에 불명확한 오류 메시지를 출력합니다
386
387평소라면 이 점을 블로그에 별도 섹션으로 다루지 않았겠지만, 프로젝트에서 직접 가져온 세 가지 실제 사례를 지적하고자 합니다.
388
389첫 번째는 서버/클라이언트 컴포넌트 모델을 충족시키기 위한 리팩토링 과정에서 실수로 클라이언트 컴포넌트를 `async`로 만든 경우입니다. 이 문제는 문제가 발생한 위치를 전혀 알려주지 않고 <b class='server'>서버</b> 스택 트레이스만 포함하고 있어서 상당히 성가셨습니다.
390
391![Next.js error](/file/2025/blog-everyone-hates-nextjs/asyncerror.png)
392
393끔찍한 오류 메시지의 또 다른 사례입니다.
394
395![Next.js error](/file/2025/blog-everyone-hates-nextjs/nexterror.png)
396
397> 이 두 번째 오류의 근본적인 문제(기억나지 않음)를 수정한 후, 개발 서버가 멈춰서 복구하기 위해 재시작해야 했습니다.
398
399마지막으로, 디버거 중단점을 수십 번 설정할 때마다 변수 이름 `hello`가 `__TURBOPACK__imported__module__$5b$project$5d2f$client$2f$src$2f$utils$2f$filename$2e$ts__$5b$app$2d$client$5d$__$28$ecmascript$29$__["hello"]` 같은 거지같은 이름으로 바뀌는 경우입니다.
400
401네. 이 모든게 구립니다. 어쩌면 좋을까요?
402
403[§3]: #ditching-nextjs
404
405<Heading
406 level='h2'
407 slug='ditching-nextjs'
408>업무에서 Next.js와 Vercel을 매끄럽게 대체하기</Heading>
409
410웹 프로젝트에는 두 가지 유형이 있습니다.
411
412- 주로 정적 콘텐츠로 구성된 웹사이트
413- 주로 동적이고 상호작용적인 컴포넌트를 가진 웹 애플리케이션
414
415Next.js는 이 두 가지 작업 모두에 적합하지 않은 도구입니다. 정적 웹사이트를 만드는 첫 번째 유형에 해당한다면 [Astro]나 [Fresh]를 선택하세요. 리액트의 모든 기능을 필요로 하는 분들을 위해, 이 섹션에서는 벤더에 종속된 Next를 [TanStack Start]로 점진적으로 매끄럽게 교체한 방법을 설명합니다.
416
417[Astro]: https://astro.build/
418[Fresh]: https://fresh.deno.dev/
419
420이 Vite 설정부터 시작했습니다.
421
422```ts filename="vite.config.ts"
423const config = defineConfig(({ mode }) => {
424 const env = loadEnv(mode, process.cwd(), "NEXT_PUBLIC_");
425 return {
426 // Next.js 기본 포트 3000을 사용하세요
427 server: { port: 3000 },
428 // Next.js의 기본 환경 접두사 "NEXT_PUBLIC_"을 사용하십시오.
429 define: Object.fromEntries(Object.entries(env).map(
430 ([k, v]) => [`process.env.${k}`, JSON.stringify(v)])),
431 plugins: [
432 viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
433 tailwindcss(),
434 // 동료들이 이해하기 쉽도록 `src/tanstack-routes`에 있는 라우트를
435 // 포팅하기 시작했습니다. 마이그레이션이 완료되면
436 // 기본 `src/routes`로 되돌아갈 예정입니다.
437 tanstackStart({
438 router: { routesDirectory: "src/tanstack-routes" },
439 }),
440 viteReact(),
441 ],
442 resolve: {
443 // 증분 마이그레이션의 핵심: `next`를 다른 곳으로 리디렉션
444 alias: { next: path.resolve("./src/tanstack-next/") },
445 conditions: ["tanstack"],
446 extensions: [
447 // `utils/session.tanstack.ts`와 같은 이름의 파일이
448 // `utils/session.ts`를 가져올 때 덮어쓸 수 있도록 허용합니다.
449 ".tanstack.tsx", ".tanstack.ts",
450 // 기본 임포트 확장자
451 ".mjs", ".js", ".mts", ".ts",
452 ".jsx", ".tsx", ".json",
453 ],
454 },
455 };
456});
457```
458
459그런 다음 Next.js API의 모든 사용처를 찾아 제거하거나 TanStack용 스텁을 만들었습니다. 예를 들어, `src/tanstack-next/link.tsx`는 `next/link`를 구현합니다.
460
461```tsx filename="src/tanstack-next/link.tsx"
462import { Link } from "@tanstack/react-router";
463import type { LinkProps } from "next/link";
464
465export default function LinkAdapter({ href, ...rest }: LinkProps) {
466 return <Link {...rest} to={href as unknown as any} />;
467}
468```
469
470> 이러한 스텁 중 일부는 매우 간단할 수 있습니다. 처음 시작했을 때, 제가 구현한 `useRouter`는 단순히 `return {}`였지만, 나중에 객체에 몇 가지 메서드를 추가해야 했습니다. 여기 코드 자체는 임시적인 것이므로 깔끔할 필요가 없습니다.
471
472이제 새 사이트는 필요한 Next.js API를 스터빙하거나 `.tanstack.ts` 확장자를 사용해 파일별로 로직을 재구현함으로써 거의 모든 클라이언트 컴포넌트를 가져올 수 있습니다. 그리고 얼마 지나지 않아 TanStack Start에서 사이트 홈페이지를 작동시키는 데 성공했고, 해당 브랜치를 병합했습니다.
473
474![My "nextgate" PR](/file/2025/blog-everyone-hates-nextjs/pr.png)
475
476> 이 첫 번째 PR은 우리 페이지 중 하나만을 지원했으며, 추가된 코드 천 줄과 삭제된 코드 40줄로 이를 수행할 수 있었습니다. 저는 이전 패치를 통해 `next/image` 및 `next/font`의 몇 가지 사용 사례를 제거한 적이 있습니다.
477
478남은 작업은 다른 모든 경로를 포팅하는 것이었습니다. Next.js에서 다른 프레임워크로 마이그레이션할 때 잃게 되는 한 가지는 UI에서 데이터 페칭 함수를 `await`할 수 있는 기능입니다. 실제로 모든 경로를 `loader` 함수로 이동시키면 페이지가 서버 측 렌더링(SSR)될 때 어떤 일이 발생하는지 훨씬 명확해졌습니다. 여러 번의 데이터 가져오기가 필요한 페이지의 경우, 해당 페이지에 필요한 모든 관련 데이터를 반환하는 단일 특수 API 호출로 통합할 수 있었습니다.
479
480다시 한번 강조하자면, <strong style='color:var(--secondary)'>서버 컴포넌트에서의 마이그레이션 경로는 단순히 코드를 단순화하는 것입니다. RSC는 본질적으로 필요 없는 것들로 가득한 혼란스러운 길로 이끌어갑니다.</strong> 우리 사이트의 거의 모든 복잡한 부분이 모든 엔지니어에게 이해하기 쉬워졌습니다. 유일한 예외는 모두가 새로운 파일 시스템 라우팅 규칙에 익숙해져야 했던 점입니다. 충분한 예시를 통해 우리 모두 그 요령을 터득했습니다.
481
482증분 마이그레이션을 통해 신규 코드가 기존 배포를 방해하지 않았습니다. TanStack이 점진적으로 코드베이스를 인수하면서, 결국 모든 Next.js 스텁을 삭제하고 TanStack 라우터가 제공하는 모든 우수한 [타입 안전성 기능]을 확보했습니다. 최종적으로 사이트는 모든 측면에서 더 빠른 성능을 보였습니다: 개발 모드, 프로덕션 페이지 로딩 시간, 소프트 네비게이션, 그리고 Vercel을 사용한 Next 배포보다 저렴한 비용으로 구현되었습니다.
483
484[타입 안전성 기능]: https://tanstack.com/router/v1/docs/framework/react/guide/type-safety
485
486변화를 목격하는 건 우리만이 아닙니다. 저는 소셜 미디어를 멀리하려 노력하지만, 누군가 [브라이언 앵글린(Brian Anglin)이 Superwall에서 진행한 작업 결과][superwall-twitter]를 보내왔는데, TanStack Start에서 CPU 사용량이 엄청나게 감소한 걸 보여주더군요. 또한 1년 전 ChatGPT가 Next.js에서 Remix로 전환한 일도 기억납니다(관련 온라인 대화: [[1][chatgpt-1]] [[2][chatgpt-2]] [[3][chatgpt-3]]).
487
488[superwall-twitter]: https://twitter.com/BriansAngles/status/1978834116079436242#m
489[chatgpt-1]: https://xcancel.com/ryanflorence/status/1831379475654947233
490[chatgpt-2]: https://old.reddit.com/r/reactjs/comments/1f97zgr/chatgpt_migrates_from_nextjs_to_remix
491[chatgpt-3]: https://old.reddit.com/r/nextjs/comments/1f92jdv/chatgptcom_switched_from_nextjs_to_remix
492
493[§3.1]: #next-metadata
494
495<Heading
496 level='h3'
497 slug='next-metadata'
498><code>next/metadata</code>는 훌륭합니다</Heading>
499
500제 생각에 이건 Next.js가 가진 몇 안 되는 좋은 API 중 하나이며, TanStack으로 전환하면서 코드에서 유일하게 작업이 더 어려워진 부분이었습니다. 코드를 악화시키기보다는, 그냥 그들의 메타데이터 API를 일반 함수로 포팅해서 누구나 사용할 수 있게 했습니다. 원래는 NPM에 1:1 포팅 버전을 올렸지만, 올해 초에 API를 간결하고 이해하기 쉬운 하나의 파일로 단순화했습니다. 이 블로그 글 작성 시점 기준으로, TanStack 호환 `meta.toTags` API를 추가했으며, [JSR][lib-jsr]이나 [NPM][lib-npm]에서 설치하거나 단순히 프로젝트에 복사해서 사용할 수 있습니다.
501
502> 공지: 본 글 작성에 시간이 부족하여 라이브러리는 아직 업데이트되지 않았습니다. 아마도 ~~이번 주 말(10월 24일)~~ 가까운 시일 내에 처리할 예정입니다... 임시로 업무용으로 사용 중인 버전을 제 웹사이트에 공유합니다. [`meta.tanstack.ts`](https://paperclover.net/file/2025/blog-everyone-hates-nextjs/meta.tanstack.ts)
503
504```tsx
505// 프로젝트에 한 번만
506import * as meta from "@clo/lib/meta.ts";
507
508export const defineHead = meta.toTags.bind(null, {
509 // 사이트 전체 옵션
510 base: new URL("https://paperclover.net"),
511 titleTemplate: (title) => [title, "paper clover"]
512 .filter(Boolean).join(' | '),
513 // ...
514});
515
516// 각 페이지마다...
517export const Route = createFileRoute("/blog")({
518 head: () =>
519 defineHead({
520 title: "clover's blog", // `titleTemplate`으로 템플릿 처리됨
521 description: "a catgirl meows about her technology viewpoints",
522 canonical: "/blog", // `base`와 결합됨
523
524 // 지정 시, 페이지 제목과 설명을 기본값으로 사용하여
525 // Open Graph 및 Twitter 임베드를 구성합니다.
526 // 기본값도 괜찮지만, 더 많은 옵션을 지원합니다.
527 embed: {
528 image: "/img/blog.webp",
529 },
530
531 // 모든 특수 메타 태그는 JSX 프래그먼트로 처리됩니다.
532 // 이는 리액트 렌더링하지 않고 단순히 태그를 순회합니다.
533 // 제 목표는 가장 흔한 99%의 사용 사례를 커버하는 것이었습니다.
534 extra: <>
535 <meta name="site-verification" content="waffles" />,
536 </>,
537 }),
538
539 component: Page,
540});
541
542function Page() {
543 ...
544}
545```
546
547제 버전은 Next.js 메타데이터 객체의 전체 영역을 커버하는 데 초점을 두지 않았으며, 대신 인라인 JSX를 사용하여 그 공백을 메웠습니다.
548
549[lib-jsr]: https://jsr.io/@clo/lib
550[lib-npm]: https://npmjs.com/@paperclover/lib
551
552[§3.2]: #vercel-og
553
554<Heading
555 level='h3'
556 slug='ditching-nextjs'
557><code>next/og</code> 또한 좋습니다</Heading>
558
559특별한 의견은 없습니다. 그냥 `@vercel/og` 패키지가 있다는 점을 모두에게 상기시키고 싶을 뿐입니다.
560
561[§4]: #experience-feels-like-the-usual
562
563<Heading
564 level='h2'
565 slug='experience-feels-like-the-usual'
566>제 경험은 일반적인 것 같아요</Heading>
567
568Next.js Conf 2024에서 참석자 모두가 서버 컴포넌트(Server Components)에 열광했습니다. 정확히 누구와 이야기했는지는 기억나지 않지만, 주요 인사들은 모두 이 기술에 주목하고 있었습니다. RSC의 번들러 부분을 구현한 저는 이 포맷의 몇 가지 문제점을 목격했습니다. Next 15가 지난해 App Router를 "안정화"하면서 많은 기업들이 이를 기반으로 제품을 구축하고 있으며, 이러한 문제점들을 직접 경험하고 있습니다.
569
570저는 Next.js를 늦게 접하게 되어 6월에야 버전 15로 시작했습니다. 하지만 행사에서 만난 모든 분들이 제 의견에 공감해 주셨습니다. Bun의 1.3 파티에서 이 주제로 이야기한 분들도 모두 동의하셨죠. 심지어 Vercel의 몇몇 분들조차 Next.js의 실제 사용 방식이 마음에 들지 않는다고 말씀하셨습니다.
571
572TanStack Start가 안정화되면서 모두가 원하는 Next.js 대체 솔루션이 되길 바랍니다.
573
574[§5]: #prefer-respectful-tools
575
576<Heading
577 level='h2'
578 slug='prefer-respectful-tools'
579>사용자를 존중하는 도구를 선택하세요</Heading>
580
581자바스크립트 생태계는 상당 부분 엉망이고, 그 때문인지 웹 개발은 종종 조롱의 대상이 됩니다.. 저는 웹 작업을 회복 불가능한 난장판이라고 생각했던 적이 수없이 많았지만, 그 혼란은 사실 제가 둘러싸고 있던 흔히 쓰이는 라이브러리들 때문이었습니다. 그 껍질을 벗겨내면 현대 웹 개발 기술은 정말 대단합니다.
582
5832024년 말부터 프레임워크 없이 이 웹사이트를 처음부터 직접 제작해 왔습니다. 자체 개발한 [TUI 진행률 위젯][progress], [정적 파일 프록시][file-cache], 증분 빌드 시스템 등 다양한 컴포넌트를 직접 구현하며 작업했습니다. 지난 수년간 가장 행복한 코딩 경험이었습니다. *[페이퍼 클로버]* 방문자들은 더 나은 품질의 웹사이트를 이용하게 되고, 제가 만든 미니 라이브러리는 [공개적으로 활용될 수 있게 추출됩니다][lib]. 덕분에 모두가 이득을 보게 되었죠.
584
585이 정도로 완전히 처음부터 시작하는 방식은 대부분의 사람들에게, 특히 직장에서는 부담스럽습니다. 최소한 우리를 존중하는 고품질 도구에만 우리의 관심과 돈을 쏟아야 한다고 생각합니다. 그리고 Next.js와 그 배후 기업인 Vercel은 그렇지 않습니다.
586
587Next.js를 사용하면서 개발자로서 존중받는다는 느낌이 들지 않는다면, 과연 여러분과 동료들이 그들의 [서버리스 제국]을 계속 지지하고 싶은지 고민해 보세요. 현재 Vite 생태계는 구축하기에 꽤 괜찮아 보이지만, 아직 대규모 프로덕션 환경에서 그들의 도구를 사용해 본 경험은 거의 없습니다. [Void0의 Vite+ 출시 소식][vite-plus]은 흥미롭지만, 이러한 벤처 수준의 지원 도구가 장기적으로 우리(최종 사용자와 개발자)를 존중할지는 시간이 지나야 알 수 있을 것입니다.
588
589Next.js Conf 2025는 글을 쓰는 시점 기준으로 [내일][next-conf]입니다. 800달러짜리 티켓을 구매하는 대신, 웹 개발 생태계를 [존중하고 개선하는][tanstack-ethos] [TanStack 팀에 그 돈을 기부하기로 결정했습니다][tanstack-donate].
590
591[페이퍼 클로버]: https://paperclover.net/
592[lib]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib#readme
593[progress]: https://git.paperclover.net/clo/sitegen/src/branch/master/lib/progress.ts
594[file-cache]: https://git.paperclover.net/clo/sitegen/src/branch/master/src/file-viewer/cache.ts
595
596[next-conf]: https://nextjs.org/conf
597[vite-plus]: https://viteplus.dev/
598[tanstack-ethos]: https://tanstack.com/ethos
599[tanstack-donate]: https://github.com/sponsors/tannerlinsley
600[serverless empire]: https://youtu.be/SCIfWhAheVw
601
602## 앞으로의 계획
603
604점진적으로, 저는 저를 존중하지 않는 많은 소프트웨어들을 더 나은 대안으로 교체해 왔습니다. 그 예로는 GitHub, Visual Studio Code, DaVinci Resolve, Discord, Google Drive/Workspace 등이 있으며, 그 외에도 많습니다. 이 블로그에서는 제가 진행 중인 기술적 작업들(프로그레스 라이브러리, 자체 사이트 생성기의 목적, 현재 직장에서의 배움)과 Bun에서의 과거 프로젝트들(HMR, 크래시 리포터, 내장 모듈 번들링을 위한 독특한 시스템에 대한 세부 사항)에 대해 더 많이 다루려 합니다. 관심이 있으시다면 이메일 리스트에 가입해 주세요.
605
606<hr />
607
608<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)
609
610[↑](#top) &mdash; [ask a question about this article (English)](/q+a)
611
612<br />
613<br />
614<br />
615<br />
616<br />
617<br />
618<footer>
6192025 (c) paper clover
620</footer>
621
622</Layout>
623
624<br />
src/blog/tags/layout.tsx created+106
......@@ -0,0 +1,106 @@
1import "../blog.css";
2import { Path } from "#sitegen/path";
3
4export const theme = {
5 bg: "#271a30",
6 fg: "#ffffff",
7 primary: "#91ffc6",
8};
9
10interface LayoutProps {
11 meta: { title: string; description: string; keywords: string[] };
12 date: string;
13 slug: string;
14 children: render.Node;
15 translation?: {
16 lang: string;
17
18 author: string;
19 href: string;
20
21 date: string;
22 };
23}
24
25export async function Layout({
26 meta: { title, description, keywords },
27 date,
28 slug,
29 children,
30 translation,
31}: LayoutProps) {
32 const translations =
33 (await Path.resolve(import.meta.dirname, "../pages/" + slug).readDir())
34 .filter((x) => x.ext === ".mdx" && x.baseWithoutExt !== "en")
35 .map((x) => x.baseWithoutExt);
36 const t = UNWRAP(localization.languages[translation?.lang ?? "en"]);
37 return (
38 <>
39 <main>
40 <header>
41 <a href="/">{t.return}</a>
42
43 <h1 style="max-width: 30ch">{title}</h1>
44 <p class="description">
45 <em>{description}</em>
46 </p>
47 <div class="meta">
48 <a class="meta-author" href="/">{t.writtenByClover}</a>
49 <span>•</span>
50 {translation
51 ? (
52 <>
53 <a href={translation.href} class="meta-translate">
54 {t.translatedBy(translation)}
55 </a>
56 <span>•</span>
57 </>
58 )
59 : ""}
60 <span class="meta-date">{date}</span>
61 </div>
62 <div class="tag-list">
63 {translations.length > 0
64 ? (
65 <>
66 <a
67 href={`/blog/${slug}`}
68 class={["custom", "tag", "lang", "lang-original", {
69 active: !translation,
70 }]}
71 >
72 {localization.languages.en.title}
73 </a>
74 {translations.map((code) => (
75 <a
76 class={["custom", "tag", "lang", {
77 active: code === translation?.lang,
78 }]}
79 href={`/blog/${slug}.${code}`}
80 >
81 {UNWRAP(localization.languages[code]).title}
82 </a>
83 ))}
84 <a
85 href="/blog/community-translations"
86 class="custom tag square"
87 >
88 +
89 </a>
90 <span class="bar"></span>
91 </>
92 )
93 : ""}
94 {keywords.map((tag) => <div class="tag">{tag}</div>)}
95 </div>
96 <hr />
97 </header>
98 {children}
99 </main>
100 </>
101 );
102}
103
104import * as localization from "../localization.tsx";
105import { UNWRAP } from "lib/assert.ts";
106import * as render from "lib/render.ts";
src/blog/tags/table-of-contents.css created+27
......@@ -0,0 +1,27 @@
1#toc {
2 background-color: #0003;
3 border-radius: 8px;
4 padding: 1rem;
5
6 h2 {
7 text-decoration: none;
8 margin: 0;
9 text-transform: uppercase;
10 font-weight: bold;
11 letter-spacing: 1px;
12 font-size: 0.8rem;
13 color: #fff8;
14 }
15 ul {
16 margin: 0;
17 }
18 li {
19 list-style-type: square;
20 &::marker {
21 color: var(--primary);
22 }
23 li {
24 --primary: var(--secondary);
25 }
26 }
27}
src/blog/tags/table-of-contents.tsx+2
......@@ -1,3 +1,5 @@
1import "./table-of-contents.css";
2
13export default function ({ children }) {
24 return (
35 <div id="toc">
src/friend-auth.ts+3-2
......@@ -1,17 +1,18 @@
11let hardcoded = {
22 friendPassword: "",
33 perPage: {} as Record<string, string>,
4 getForFile: (file: string) => [] as string[],
4 getForFile: (_: string) => [] as string[],
55};
66try {
77 hardcoded = require("./friends/hardcoded-password.ts");
88} catch {}
9hardcoded.perPage ??= {};
910
1011export const app = new Hono();
1112
1213const cookieAge = 60 * 60 * 24 * 30; // 1 month
1314
14export const getForFile = hardcoded.getForFile;
15export const getForFile = hardcoded.getForFile ?? (() => []);
1516
1617function checkFriendsCookie(c: Context, passwords: string[]) {
1718 const cookie = c.req.header("Cookie");
src/pages/index.css+4-1
......@@ -11,7 +11,7 @@ main {
1111 }
1212}
1313h1 {
14 margin: -1.5rem 0 3rem 0;
14 margin: -0.5rem 0 3rem 0;
1515 font-size: 5rem;
1616 font-weight: 400;
1717}
......@@ -20,6 +20,9 @@ h2 {
2020 text-decoration: underline;
2121 color: #000e;
2222}
23p, h2 {
24 margin: 0.75rem 0rem;
25}
2326hr {
2427 width: 100%;
2528 border: 1px solid #000b;
src/pages/index.marko+6-2
......@@ -28,8 +28,12 @@ export const meta = {
2828 <p><a href="/q+a">questions and answers</a></p>
2929 <p><a href="/file">file browser</a></p>
3030 <p><a href="/subscribe">mailing list</a></p>
31 <p><a href="https://ko-fi.com/paper_clover">donate</a></p>
32 <p style="position: relative; z-index: 10"><a href="/rss.xml">rss feed</a></p>
31 <h2>links</h2>
32 <div style="display:flex;gap:1rem;position:relative;z-index:2">
33 <a href="mailto:hello@paperclover.net">email me</a>
34 <a href="/rss.xml">rss feed</a>
35 <a href="https://ko-fi.com/paper_clover">donate</a>
36 </div>
3337 <h1>paper clover</h1>
3438 </div>
3539</main>
src/pages/subscribe.marko+8-9
......@@ -9,20 +9,19 @@ export const meta = {
99<canvas style="display: none;"></canvas>
1010<p>
1111 the mailing list is used for big project updates. this is about once every
12 couple of months.
13</p>
14<p>
15 the list is currently managed manually. to get added, please email anything
16 with the word "subscribe" in the subject or body to the following address:
12 couple of months. to get added, please email anything with the word
13 "subscribe" in the subject or body to the following address:
1714</p>
1815<code id="subscribe" class="flex">
19 subscribe at paper clover dot net
16 <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)">subscribe@paperclover.net</a>
2017</code>
2118<p>
22 to unsubscribe, send an email asking to unsubscribe. for those who are close
23 friends with me, add your name and i'll add you to the 'friends' list, where i
24 send life updates in addition to new content (1-2 times per month).
19 i read every message, so you can say whatever else you want and i will see
20 it. there won't be a confirmation email for subscribing. for those who are
21 close friends with me, add your name and i'll add you to the 'friends' list,
22 where i send life updates in addition to new content.
2523</p>
24<p>to unsubscribe, send an email asking to unsubscribe.</p>
2625<br>
2726<br>
2827<br>
src/static/open-graph/next-js.ko.png created
Binary files /dev/null and b/src/static/open-graph/next-js.ko.png differ