>soren hale — frontend developer
// Writing
Server components, a year in
What moved to the server, what moved back, and the one rule that survived.
- to read
- 2 min
- published
- 2026-05-08
- filed under
- React

A year ago I moved a mid-sized product to React Server Components. Forty routes, a team of six, a deadline that did not move. This is the honest version of how it went.
What stayed on the server
Anything that reads data and does not respond to a finger: tables, detail pages, navigation, the first render of most forms. These got simpler. The data fetching sits next to the markup that uses it and there is no loading state to design, because the page arrives with the data in it.
// app/invoices/page.tsx: no client JavaScript at all
export default async function Invoices() {
const rows = await db.invoices.recent(50)
return (
<Table>
{rows.map((r) => <InvoiceRow key={r.id} invoice={r} />)}
</Table>
)
}
What moved back
Anything that has to answer in under fifty milliseconds. Filters, drag and drop, the command palette. We tried to keep the filters on the server for a month, with the URL as state. On a good connection it was fine. On a train it was not.
What surprised us
- The bundle fell by 38%, mostly libraries that only ever formatted dates and money.
- Caching became the hard problem. We wrote one page of rules and still broke them twice.
- Tests got easier. A server component is a function that returns markup.
The rule that survived
A component belongs on the client only if the user can change it faster than the network can. Everything else is a document, and documents are what servers are for.
We wrote that sentence on the wall in month three and it has settled every argument since. Would I do the migration again? Yes, but in a quarter, not a sprint, and with the caching rules written first.
// Writing
Server components, a year in
What moved to the server, what moved back, and the one rule that survived.
- to read
- 2 min
- published
- 2026-05-08
- filed under
- React

A year ago I moved a mid-sized product to React Server Components. Forty routes, a team of six, a deadline that did not move. This is the honest version of how it went.
What stayed on the server
Anything that reads data and does not respond to a finger: tables, detail pages, navigation, the first render of most forms. These got simpler. The data fetching sits next to the markup that uses it and there is no loading state to design, because the page arrives with the data in it.
// app/invoices/page.tsx: no client JavaScript at all
export default async function Invoices() {
const rows = await db.invoices.recent(50)
return (
<Table>
{rows.map((r) => <InvoiceRow key={r.id} invoice={r} />)}
</Table>
)
}
What moved back
Anything that has to answer in under fifty milliseconds. Filters, drag and drop, the command palette. We tried to keep the filters on the server for a month, with the URL as state. On a good connection it was fine. On a train it was not.
What surprised us
- The bundle fell by 38%, mostly libraries that only ever formatted dates and money.
- Caching became the hard problem. We wrote one page of rules and still broke them twice.
- Tests got easier. A server component is a function that returns markup.
The rule that survived
A component belongs on the client only if the user can change it faster than the network can. Everything else is a document, and documents are what servers are for.
We wrote that sentence on the wall in month three and it has settled every argument since. Would I do the migration again? Yes, but in a quarter, not a sprint, and with the caching rules written first.
// Writing
Server components, a year in
What moved to the server, what moved back, and the one rule that survived.
- to read
- 2 min
- published
- 2026-05-08
- filed under
- React

A year ago I moved a mid-sized product to React Server Components. Forty routes, a team of six, a deadline that did not move. This is the honest version of how it went.
What stayed on the server
Anything that reads data and does not respond to a finger: tables, detail pages, navigation, the first render of most forms. These got simpler. The data fetching sits next to the markup that uses it and there is no loading state to design, because the page arrives with the data in it.
// app/invoices/page.tsx: no client JavaScript at all
export default async function Invoices() {
const rows = await db.invoices.recent(50)
return (
<Table>
{rows.map((r) => <InvoiceRow key={r.id} invoice={r} />)}
</Table>
)
}
What moved back
Anything that has to answer in under fifty milliseconds. Filters, drag and drop, the command palette. We tried to keep the filters on the server for a month, with the URL as state. On a good connection it was fine. On a train it was not.
What surprised us
- The bundle fell by 38%, mostly libraries that only ever formatted dates and money.
- Caching became the hard problem. We wrote one page of rules and still broke them twice.
- Tests got easier. A server component is a function that returns markup.
The rule that survived
A component belongs on the client only if the user can change it faster than the network can. Everything else is a document, and documents are what servers are for.
We wrote that sentence on the wall in month three and it has settled every argument since. Would I do the migration again? Yes, but in a quarter, not a sprint, and with the caching rules written first.
// Start a project
Tell me what you are building.
Three quick picks so the first reply is useful. You finish the message on the contact page.
// Start a project
Tell me what you are building.
Three quick picks so the first reply is useful. You finish the message on the contact page.
// Start a project
Tell me what you are building.
Three quick picks so the first reply is useful. You finish the message on the contact page.