Dev.to WebDev πŸ›  Dev πŸ‘ 1 πŸ“– 11 min read

How to Keep Client-Side Animation From Taking Over Your Next.js Architecture

How to Keep Client-Side Animation From Taking Over Your Next.js Architecture A single animated section can quietly change the architecture of an otherwise server-first Next.js page. The hero needs pointer tracking, so

How to Keep Client-Side Animation From Taking Over Your Next.js Architecture

A single animated section can quietly change the architecture of an otherwise server-first Next.js page.

The hero needs pointer tracking, so "use client" appears at the top of the file.

Then a feature section needs a scroll timeline.

Later, someone adds a WebGL background that depends on browser APIs.

Before long, a page that originally needed a few interactive pieces has most of its component tree living behind a client boundary.

The animation still works.

The architecture has drifted.

In the App Router, "use client" defines a client module boundary. Once you cross it, modules imported into that client graph are part of the browser-side application.

So the useful rule is not:

Avoid client-side animation.

It is:

Put the client boundary around the smallest piece of interaction that actually needs the browser.

Everything else can stay server-first.

The problem is boundary creep

Most animation requirements are legitimate reasons to run code in the browser.

You might need:

  • window
  • pointer position
  • layout measurement
  • scroll progress
  • ResizeObserver
  • IntersectionObserver
  • an animation timeline
  • Canvas
  • WebGL
  • component state

None of those automatically mean the entire section or page should become a Client Component.

Imagine a product hero containing:

  • a heading
  • supporting copy
  • a CTA
  • pricing or product data
  • one animated visual

Only one part needs browser-side behavior.

If the whole hero gets "use client", the rendering boundary now reflects the needs of the animation rather than the needs of the interface.

That tends to spread.

Future developers see a client-owned section and add more client-only state to it. Server-renderable content becomes coupled to browser code. Animation dependencies start creeping upward through the component tree.

The better question is:

Which component actually owns the interaction?

That component should usually own the client boundary too.

Keep "use client" as low as practical

Consider this:

"use client"

export default function Hero() {
  return (
    <section>
      <h1>Build faster</h1>
      <p>Server-renderable marketing content.</p>
      <AnimatedProduct />
    </section>
  )
}

It works.

But the entire Hero is now part of the client module graph because one child needs animation.

A cleaner split is:

export default function Hero() {
  return (
    <section>
      <h1>Build faster</h1>
      <p>Server-renderable marketing content.</p>
      <AnimatedProduct />
    </section>
  )
}

Then isolate the animated piece:

"use client"

export function AnimatedProduct() {
  return <div>{/* animation logic */}</div>
}

Now the browser-specific behavior lives where it belongs.

The surrounding content can remain authored and rendered as Server Components while only the animated leaf enters the client graph.

This becomes much more useful when the animation also owns:

  • event listeners
  • observers
  • GSAP timelines
  • scroll triggers
  • pointer state
  • resize handling
  • cleanup

A good animation boundary tends to keep those responsibilities together.

Server-rendered content can still pass through a client wrapper

There is another pattern that is easy to miss.

Sometimes the animation needs to wrap content, but it does not need to own that content.

For example:

export default function Page() {
  return (
    <Reveal>
      <article>
        <h2>Server-rendered content</h2>
        <p>This content does not need client-side state.</p>
      </article>
    </Reveal>
  )
}

Then:

"use client"

export function Reveal({
  children,
}: {
  children: React.ReactNode
}) {
  return <div>{children}</div>
}

The important detail is the direction of composition.

The Server Component composes the content and passes it into the Client Component as children. The client module is not reaching back and importing a Server Component directly.

That pattern is useful for:

  • reveal wrappers
  • viewport-triggered sections
  • animated shells
  • carousels containing server-provided markup
  • interactive containers
  • transition wrappers

It lets the interaction stay client-side without moving all meaningful content there too.

Values crossing the boundary still need to follow React and Next.js serialization rules where applicable. Client-only functions and browser state should remain on the client side.

Before adding an animation library, ask whether CSS is enough

A surprising amount of motion does not need client-side JavaScript at all.

For example:

.card {
  transform: translateY(0);
  opacity: 1;
  transition:
    transform 220ms ease,
    opacity 220ms ease;
}

.card:hover {
  transform: translateY(-4px);
}

No hooks.

No animation runtime.

No lifecycle.

No "use client".

This is not an argument for replacing Motion or GSAP with CSS everywhere.

A hover state and a pinned scroll sequence are different problems.

The useful rule is to choose the smallest mechanism that solves the interaction.

CSS is often enough for:

  • hover feedback
  • color transitions
  • simple opacity
  • transforms
  • underlines
  • basic enter/exit effects where state is already available in CSS

A text reveal should not need a 3D pipeline.

Motion does not require client ownership of the whole section

Motion works naturally with React, so it is easy to let the client boundary expand around it.

That still is not necessary.

If one card needs gesture state or animation hooks, that card can own the client-side behavior.

The section containing twelve cards does not automatically need to become client-owned.

There is also an important current Next.js nuance here.

Motion provides motion/react-client for rendering Motion components from React Server Component contexts.

That does not mean all Motion behavior suddenly runs on the server.

Hooks, gesture logic, browser-dependent measurements, stateful interaction, and other client-only behavior still belong behind an appropriate Client Component boundary.

So think about what the component actually needs.

A simple animated element and a drag interaction have very different client requirements even if both use Motion.

Imperative animation needs a clear owner

Things become more architectural once you introduce imperative systems such as:

  • GSAP timelines
  • ScrollTrigger
  • custom observers
  • layout measurement
  • manual event listeners
  • resize logic

These all create lifecycle responsibilities.

The component that owns the affected DOM should usually own those responsibilities too.

Avoid one large page-level animation controller that reaches into unrelated sections.

Something like this is easier to reason about:

FeatureStory
β”œβ”€β”€ owns its DOM
β”œβ”€β”€ creates its timeline
β”œβ”€β”€ creates its ScrollTriggers
β”œβ”€β”€ handles its resize logic
└── cleans everything up on unmount

If another section has its own animation system, let that section own it separately.

Why?

Because page-level animation scripts tend to become brittle once the layout changes.

A section becomes conditional.

Another moves into a nested layout.

A mobile breakpoint changes the DOM.

One route preserves part of the tree while another unmounts it.

Suddenly the question "who cleans this animation up?" has no obvious answer.

Local ownership keeps animation architecture composable.

Do not make the animation timeline your page structure

Scroll animation is where this mistake shows up frequently.

Suppose a desktop section pins a product image while five text states appear as the user scrolls.

On mobile, you might not want the pinned experience at all.

The content should still work as:

Feature 1
Visual 1

Feature 2
Visual 2

Feature 3
Visual 3

The animation is a presentation layer over meaningful content.

It should not be the only thing holding that content together.

That gives you a much healthier fallback when:

  • the viewport changes
  • reduced motion is enabled
  • JS fails
  • the animation library changes
  • the creative direction gets simplified later

WebGL and Canvas deserve their own island

WebGL is an even stronger case for isolation.

A 3D hero might depend on:

  • Canvas
  • WebGL
  • Three.js
  • React Three Fiber
  • shaders
  • textures
  • pointer input
  • requestAnimationFrame
  • viewport dimensions
  • device pixel ratio

None of those requirements mean the heading or CTA should become client-owned.

A better architecture looks like this:

Server-rendered Hero
β”œβ”€β”€ Heading
β”œβ”€β”€ Copy
β”œβ”€β”€ CTA
└── Client WebGL Island

The meaningful content remains ordinary HTML.

The renderer stays isolated.

This also makes it easier to:

  • provide a static fallback
  • simplify the effect on mobile
  • load the renderer later
  • reduce GPU work
  • remove the scene without rewriting the content structure

If the WebGL scene is expensive or below the fold, it may also be a good candidate for lazy loading.

ssr: false is useful, but it is not an architecture

When a package depends directly on browser APIs, the quickest reaction is often:

dynamic(() => import("./Scene"), {
  ssr: false,
})

Sometimes that is exactly right.

But in the App Router, there is an important constraint:

ssr: false must be configured from a Client Component.

So for a browser-only renderer, the structure might look like:

// SceneClient.tsx
"use client"

import dynamic from "next/dynamic"

const Scene = dynamic(() => import("./Scene"), {
  ssr: false,
})

export function SceneClient() {
  return <Scene />
}

Your Server Component can then render SceneClient.

This is a useful browser-only boundary.

It still does not answer the rest of the architecture.

ssr: false tells Next.js not to server-render that component.

It does not decide:

  • how large the client island should be
  • whether the renderer should load immediately
  • where meaningful content belongs
  • who owns cleanup
  • whether mobile gets a fallback
  • whether reduced motion changes the effect
  • whether the animation should pause offscreen

Those are separate decisions.

A whole page made client-only because one component touches window is very different from a small isolated renderer that genuinely has no server representation.

Lazy loading and disabling SSR solve different problems

These two decisions are often bundled together, but they answer different questions.

Lazy loading asks:

When should this code be downloaded?

Disabling SSR asks:

Should this component render on the server at all?

A heavy scene below the fold might benefit from lazy loading.

A tiny client interaction at the top of the page probably does not need elaborate deferral.

A browser-only WebGL renderer may reasonably use both dynamic loading and ssr: false.

A simple Motion element may need neither.

Keeping these decisions separate prevents dynamic() from becoming a generic wrapper around every animation component.

Keep global animation providers smaller than you think

Once a site starts accumulating motion, it is tempting to create one global animation layer.

Some shared configuration is useful.

For example:

  • reduced-motion policy
  • transition defaults
  • common easing values
  • feature flags
  • interaction preferences

But shared policy does not mean shared implementation.

You probably do not need one global controller owning:

  • every ScrollTrigger
  • every observer
  • every timeline
  • the cursor
  • all page animations
  • every animation state

Next.js generally benefits from placing providers as deep as practical.

The same approach works well for motion.

If only the marketing section needs an animation provider, put it there.

If only one route has a custom cursor, do not make the root layout own it.

Keep global policy global. Keep local animation local.

Pause work the user cannot see

The client boundary is only the first part of the problem.

Once the animation has hydrated, you still need to decide when it should run.

A Canvas scene rendering in a hidden tab is still consuming resources.

An offscreen particle effect may still be animating.

A cursor trail may remain subscribed to pointer movement even after the relevant component is gone if cleanup is incomplete.

Depending on the effect, consider:

  • pausing render loops when the page is hidden
  • suspending expensive work while offscreen
  • disconnecting observers
  • removing event listeners
  • killing timelines on unmount
  • reducing visual complexity on mobile
  • lowering WebGL rendering cost
  • providing a static fallback

One animation doing unnecessary work may not be noticeable.

Five or six independently animated sections can make those assumptions add up quickly.

A practical architecture checklist

Before adding another animation to an App Router project, ask:

Question Preferred direction
Can CSS solve it? Keep it out of the client runtime
Does it need hooks, state, or browser APIs? Use a small Client Component
Does it need an imperative timeline? Give that timeline a local owner
Does it create listeners or observers? Keep setup and cleanup together
Does it contain meaningful content? Keep that content in HTML where possible
Does it require Canvas or WebGL? Isolate the renderer
Is the renderer expensive or below the fold? Consider lazy loading
Can it only exist in the browser? Use an appropriate client-only boundary
Does it need shared motion policy? Place the provider as deep as practical
Can it stop while hidden? Pause it
What happens with reduced motion? Simplify the interaction intentionally
What happens on touch devices? Design a real fallback

This is the kind of checklist that stops animation from gradually becoming application architecture.

How source-first effects help with this

This is also where source-first interaction libraries can be useful.

For example, Hyperiux Vault provides React and Next.js interaction patterns as editable source.

The useful part from an architecture perspective is not simply that the effect exists.

It is that you can inspect:

  • where the client boundary begins
  • what dependencies the effect uses
  • how lifecycle and cleanup are handled
  • what should change for mobile or browser-only rendering

That makes it easier to fit an effect into your existing architecture instead of restructuring the application around an opaque package.

Vault's installation docs also distinguish effects that rely on things such as scroll state, pointer movement, layout measurement, timelines, Canvas, or WebGL from components that do not inherently require the same client-side footprint.

Vault does not solve Next.js architecture for you.

Next.js still defines the rendering model. Motion, GSAP, Three.js, React Three Fiber, and browser APIs still bring their own constraints.

Editable source simply makes those constraints easier to inspect and adapt.

The goal is not zero client JavaScript

A creative Next.js site is allowed to use the browser.

Some interactions genuinely require it.

Scroll-linked storytelling needs scroll state.

Dragging needs pointer input.

A page transition needs navigation state.

A custom cursor needs pointer events.

WebGL needs a renderer.

Trying to eliminate client JavaScript entirely would eliminate useful interaction along with it.

The goal is architectural proportion.

One hero measuring the viewport does not mean the page should become client-owned.

One Canvas component does not mean the route should disable SSR.

One animated wrapper does not mean its content needs to move into a client module.

Let the animation own the browser code it actually needs.

Then stop the boundary there.

If you want to inspect how more complex React and Next.js interaction patterns handle those boundaries in editable source, you can browse the Hyperiux Vault effects library.

A good interaction can be ambitious. Its client boundary does not have to be.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.