Oliver White
9 May 2026 · 8 min read
In Next.js App Router, the decision of where to put 'use client' is an architectural decision, not a performance one. Performance is a side effect. The real constraint is this: code marked 'use client' — and everything it imports — gets bundled and sent to the browser. Server Components never do. That separation is what makes the three-client Supabase setup necessary.
In the Pages Router, every component was a Client Component by default. Data fetching happened in getServerSideProps or getStaticProps, then got passed down as props. The component that rendered the data was the same kind of component that rendered a button with an onClick handler. This created pressure to colocate concerns that shouldn't be colocated.
Pages Router pattern
App Router pattern
The most concrete example of this split in AI Art Arena is the Supabase client setup. There are three clients, each serving a different rendering context:
Using createClient() (which calls cookies()) inside a page marked export const revalidate = 60 will silently break ISR. Next.js will ignore the revalidate directive and serve the page dynamically on every request. Use createPublicClient() for any page that needs CDN caching.
Every time you write 'use client', you're making a statement: this code and everything it imports will be bundled and sent to the browser. That's not a problem — it's the right call for interactive components. But it should be a conscious decision, not a default.
| Component | Type | Reason |
|---|---|---|
| app/contest/[id]/page.tsx | Server | Fetches contest + artworks, checks session server-side |
| components/contest/ArtworkCard.tsx | Client | onClick vote handler, localStorage, hover state |
| components/contest/LiveVoteCount.tsx | Client | Supabase Realtime subscription via useEffect |
| components/contest/ContestHeader.tsx | Server | Static week/date display, no interactivity |
| components/contest/ContestTimer.tsx | Client | useEffect countdown, window.setInterval |
| app/archive/page.tsx | Server | Static grid of past contests, ISR revalidate=60 |
| app/leaderboard/page.tsx | Server | Ranked list, ISR revalidate=60, no interactivity |
The most common mistake when migrating to the App Router is reaching for useEffect + fetch in a Client Component when the data should come from the server. Here's the rule: if you're fetching data that's the same for every user (contest results, artwork details, archive) — it belongs in a Server Component. If you're fetching data that requires the user's session or changes in real time — that's the client's job.
A useful mental model: Server Components are functions that run once at request time and return HTML. Client Components are JavaScript modules that run in the browser continuously. If your component doesn't need to run continuously — it probably doesn't need 'use client'.
Client Components
8
in the entire codebase
Server Components
40+
zero JavaScript shipped
First JS bundle
~95kb
gzipped
Interactive islands
Vote, Timer, Realtime, Menu
only what needs the browser
The discipline pays off measurably. The contest page — which shows 4 artwork cards, a countdown timer, and live vote counts — ships less than 100kb of JavaScript because only the interactive parts are Client Components. The rest is server-rendered HTML that arrives pre-built.
Built with this methodology
Using createClient() with cookies() inside a revalidate=60 page silently breaks ISR. Here's the three-client Supabase setup that keeps Server Components server-rendered, CDN-cached correctly, and the use client boundary deliberate.
From the build log