RIZKYEMHA
โ† Back to Blog๐Ÿ‡ฎ๐Ÿ‡ฉ Indonesia
Next.jsRSCGSAPi18nReact

RSC & Animations: Fix the Wrong Assumption

May 16, 2026ยท8 min read
RSC & Animations: Fix the Wrong Assumption

Where It Started

When I got serious about Next.js App Router, I fell in love with RSC almost immediately. Data fetching directly in components, no messy loading states, smaller bundle sizes โ€” it felt like the future.

Then I tried to add animations, and the doubt crept in.

My hero section was supposed to be fully animated โ€” the title sliding up from below, text fading in, an image sweeping in from the side. I was using GSAP. And GSAP needs useGSAP, which means "use client", which means... the entire hero section becomes CSR?

I started to wonder: do I have to choose between smooth animations and doing RSC properly?

The answer is no. But getting there meant I had to truly understand how Next.js renders components from the ground up.


The First Misconception: CSR "Spreads" Through Children

My initial assumption: if the parent is CSR, its children automatically become CSR too.

It makes logical sense โ€” if a component runs on the client, everything inside it runs on the client too, right?

Turns out it's not that simple.

// AnimatedSection.tsx โ€” CSR
"use client"
export default function AnimatedSection({ children }) {
  useGSAP(() => {
    gsap.from(".animate", { opacity: 0, stagger: 0.2 })
  })
  return <section>{children}</section>
}
// page.tsx โ€” RSC
export default function Page() {
  return (
    <AnimatedSection>
      <h1 className="animate">Hero Title</h1>
      <p className="animate">A long description here.</p>
    </AnimatedSection>
  )
}

The <h1> and <p> here are still rendered on the server. Not on the client.

Why?


How Next.js Actually Renders Components

To really understand this, I had to step back and trace the full flow from the beginning.

1. A request hits the server. Next.js starts execution from the root โ€” usually page.tsx โ€” and works top-down. Every component gets checked: does it have "use client" or not?

2. RSC components run on the server; CSR components are skipped for now. Components without "use client" are rendered immediately on the server. Components with "use client" are recorded as placeholders โ€” "the client will handle these later."

3. The result isn't plain HTML โ€” it's the RSC Payload. The RSC Payload is a serialized tree containing the rendered HTML from RSC components, placeholders where CSR components belong, and references to the JS bundles they need.

4. The browser receives the RSC Payload + initial HTML. The HTML is displayed immediately โ€” which is why SEO stays intact. The JS bundles for CSR components are sent along too.

5. Hydration โ€” CSR components come alive. React in the browser reads the RSC Payload and fills the placeholders with CSR components, which are now executed on the client. This is where useGSAP() finally runs.


The Key Insight: Who Wrote the JSX?

Back to the earlier example. <h1> and <p> are written in page.tsx โ€” an RSC file. That means the server is responsible for rendering them. They're already HTML before they ever reach AnimatedSection.

AnimatedSection only receives the render output, not raw JSX.

SERVER                          CLIENT
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Page (RSC)
โ”œโ”€ render <h1> โ†’ โœ… HTML
โ”œโ”€ render <p>  โ†’ โœ… HTML
โ””โ”€ AnimatedSection โ†’ โธ๏ธ placeholder
                                AnimatedSection โ†’ โ–ถ๏ธ executes
                                โ””โ”€ receives children (already HTML)
                                โ””โ”€ useGSAP() runs โ†’ animations live

CSR spreads through imports, not through props or children.

// โŒ This is what makes HeroContent become CSR
"use client"
import HeroContent from "./HeroContent" // imported inside a CSR file
 
export default function AnimatedSection() {
  return (
    <section>
      <HeroContent />
    </section>
  )
}
// โœ… This is safe โ€” HeroContent stays RSC
"use client"
export default function AnimatedSection({ children }) {
  return <section>{children}</section>
}
 
// In page.tsx (RSC):
// <AnimatedSection><HeroContent /></AnimatedSection>

The Second Problem: i18n and RSC

Once the animation situation was sorted, I ran into something else: internationalization.

I wanted to implement i18n โ€” users could switch languages. My first instinct: store the locale in localStorage, read it with a hook, done.

The problem is, SSR can't wait for a value from the client. If the locale is read from localStorage, the server has no idea which language to use during render. The result: server sends the default language first, then after hydration the content switches โ€” flicker, layout shift, broken SEO.

The fix: any data the server needs must arrive before or alongside the request โ€” not after the page loads.

Two approaches that work:

Option 1: Locale in the URL

mysite.com/en/about
mysite.com/id/about

This is the cleanest solution. The server knows the locale from the URL โ€” no cookie, no client involvement. next-intl uses this approach.

Option 2: Locale in a Cookie

// middleware.ts
export function middleware(request: NextRequest) {
  const locale = request.cookies.get("locale")?.value ?? "en"
  request.headers.set("x-locale", locale)
  return NextResponse.next()
}

The cookie is read by the server before rendering begins. The client sets the cookie when the user switches languages โ€” every subsequent request already has the correct locale.


params, Not useParams

One consequence of the URL-based approach: in RSC, I can't use useParams โ€” that's a hook, client-only. Instead, use params passed as props.

// app/[locale]/page.tsx โ€” RSC
export default async function Page({ params }) {
  const { locale } = await params
  const t = await getDictionary(locale)
 
  return <HeroSection title={t.hero.title} />
}

For a shallow component tree, prop drilling from here is fine. For larger projects, next-intl offers getTranslations, which any RSC can call directly without drilling:

// Any RSC component
import { getTranslations } from "next-intl/server"
 
export default async function HeroSection() {
  const t = await getTranslations("hero")
  return <h1>{t("title")}</h1>
}

Note the distinction: getTranslations for RSC, useTranslations for CSR. A small difference, but an important one.


What I Actually Learned

A few things from all of this that changed how I think about Next.js architecture:

RSC isn't about "no JS on the client." It's about moving work that doesn't need to be on the client โ€” data fetching, heavy dependencies โ€” to the server. Animations need the client. That's completely fine.

CSR spreads through imports, not children. If you want to contain the "spread", don't import large components inside a CSR file. Pass them as children from an RSC instead.

SSR can't wait for the client. Anything the server needs must be available before the request โ€” via URL, cookie, or header. Not after the page has loaded.

i18n libraries have two modes too. react-i18next is purely CSR. next-intl has both a server version and a client version. Choose based on what you actually need โ€” don't blindly follow tutorials that weren't written with RSC in mind.


My hero section now renders on the server โ€” static content, SEO-safe, no layout shift. But the animations still run smoothly with GSAP, because the animation wrapper is a CSR component that receives its children from RSC.

It doesn't have to be one or the other. Both can work together โ€” once you understand how Next.js actually renders things.

โ† All ArticlesShare on X โ†—