Next.js Discord

Discord Forum

react-countup & react-intersection-observer not animating the count in Production Build

Unanswered
Egyptian Mau posted this in #help-forum
Open in Discord
Egyptian MauOP
Hello,

react-countup and react-intersection-observer is working perfectly fine in the dev env npm run dev but I am building the app for the Production Build using npm run build and then startingthe Prod Server using npm run start then unfortunately the App is not running the count animation at all, even using "use client"; and also the import 'client-only', I am sharing the code snippets for now for overview of the issue and if required I can make the repository public from private on GitHub.

{
  "name": "novareel-studios",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "biome check",
    "format": "biome format --write"
  },
  "dependencies": {
    "@base-ui/react": "^1.1.0",
    "class-variance-authority": "^0.7.1",
    "client-only": "^0.0.1",
    "clsx": "^2.1.1",
    "formik": "^2.4.9",
    "framer-motion": "^12.27.0",
    "lenis": "^1.3.17",
    "lucide-react": "^0.562.0",
    "next": "16.1.3",
    "react": "19.2.3",
    "react-countup": "^6.5.3",
    "react-dom": "19.2.3",
    "react-intersection-observer": "^10.0.2",
    "react-lite-youtube-embed": "^3.3.3",
    "server-only": "^0.0.1",
    "shadcn": "^3.7.0",
    "sonner": "^2.0.7",
    "tailwind-merge": "^3.4.0",
    "tw-animate-css": "^1.4.0",
    "yup": "^1.7.1"
  },
  "devDependencies": {
    "@biomejs/biome": "2.2.0",
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "babel-plugin-react-compiler": "1.0.0",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

30 Replies

Egyptian MauOP
below is the /src/apps/layout.tsx

import type { Metadata, Viewport } from "next"
import { Nunito_Sans } from "next/font/google"
import { Toaster } from "sonner"
import TopProgressBar from "@/components/top-progress-bar"
import { APP_INFO } from "@/constants"
import ClientLayoutWrapper from "@/custom-layout/client-layout-wrapper"

import "./globals.css"
import type { ReactNode } from "react"

const nunitoSans = Nunito_Sans({ variable: "--font-nunito-sans", display: "swap", preload: true, subsets: ["latin"] })

export const viewport: Viewport = {
  themeColor: "#000000",
  width: "device-width",
  initialScale: 1,
  maximumScale: 5,
  userScalable: true,
}

export const metadata: Metadata = {
  title: `${APP_INFO.APP_NAME} | Cinematic Storytelling & Film Production`,
  description: "Generated by create next app",
}

export default function RootLayout({
  children,
}: Readonly<{
  children: ReactNode
}>) {
  return (
    <html lang="en" className={nunitoSans.variable}>
      <body className="bg-zinc-950 text-zinc-100 selection:bg-amber-300/20 selection:text-amber-200 heritage-motif">
        <TopProgressBar />

        <ClientLayoutWrapper>
          {children}

          {/* Toast Notifications */}
          <Toaster
            position="bottom-right"
            expand={false}
            richColors
            closeButton
            duration={3000}
            toastOptions={{
              style: {
                fontFamily: nunitoSans.className,
              },
            }}
          />
        </ClientLayoutWrapper>
      </body>
    </html>
  )
}
below is the /src/apps/page.tsx:

import type { Metadata } from "next"

export default function Page() {
  return (
    <>
      <section className="relative overflow-hidden">
        <HomeHeroSection />

        <PartnerStrip />
      </section>

      <FeaturedFilmography />

      <section className="relative">
        <HomeTeamSection />

        <HomeNewsroomBlog />

        <HomeNewsletter />
      </section>
    </>
  )
}
below is the /@/components/page-components/home-page/hero-section.tsx:

"use client"

import "client-only"

import Link from "next/link"
import type { FC } from "react"

import EasedCountUp from "@/components/library/eased-count-up"

import { MAIN_PARENT_LAYOUT } from "@/ui"
import WatchTrialBtnModal from "./watch-trial-btn-modal"

const HomeHeroSection: FC = () => {
  return (
    <>

      <div className={MAIN_PARENT_LAYOUT}>
        <div className="grain relative">
          <div className="pt-24 pb-14 sm:pt-28 sm:pb-16">
            <div className="mt-8 grid gap-10 lg:grid-cols-12 lg:items-end">
              <div className="lg:col-span-7">
                <div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-zinc-200">
                <dl className="mt-10 grid max-w-2xl grid-cols-3 gap-4">
                  <div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">
                    <dt className="text-xs text-zinc-300">Films Produced</dt>
                    <dd className="mt-1 text-2xl font-extrabold" data-counter="28">
                      <EasedCountUp end={28} />
                    </dd>
                  </div>
                  <div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">
                    <dt className="text-xs text-zinc-300">Awards</dt>
                    <dd className="mt-1 text-2xl font-extrabold" data-counter="54">
                      <EasedCountUp end={54} />
                    </dd>
                  </div>
                  <div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">
                    <dt className="text-xs text-zinc-300">Languages</dt>
                    <dd className="mt-1 text-2xl font-extrabold" data-counter="6">
                      <EasedCountUp end={6} />
                    </dd>
                  </div>
                </dl>
              </div>
and below is the eased-count-up.tsx:

"use client"

import "client-only"

import { type FC, useEffect, useId, useMemo } from "react"
import { useCountUp } from "react-countup"
import { useInView } from "react-intersection-observer"

import { formatCompact } from "@/utils/format-compact"

export interface EasedCountUpProps {
  end: number
  /** seconds (react-countup) */
  duration?: number
  /** IntersectionObserver threshold */
  threshold?: number
  className?: string
}

const EasedCountUp: FC<EasedCountUpProps> = (props) => {
  const { end, className = "", duration = 0.9, threshold = 0.4 } = props

  // useId() contains ":" which isn't great for DOM ids -> sanitize
  const reactId = useId()
  const id = useMemo(() => `countup-${reactId.replace(/:/g, "")}`, [reactId])

  const { ref: inViewRef, inView } = useInView({ triggerOnce: true, threshold })

  const { start, reset } = useCountUp({
    ref: id,
    start: 0,
    end,
    duration: duration,
    startOnMount: false,

    // CountUp.js signature: easingFn(t, b, c, d)
    // t = elapsed ms, b = startVal, c = end-start, d = duration ms
    easingFn: (t, b, c, d) => {
      const n = d ? Math.min(1, t / d) : 1 // normalize 0..1
      return b + c * (0.12 + 0.88 * n) // EXACT same curve
    },

    formattingFn: formatCompact,
  })

  useEffect(() => {
    if (!inView) return
    reset()
    start()
  }, [inView, reset, start])

  return (
    <span id={id} ref={inViewRef} className={className}>
      0
    </span>
  )
}

export default EasedCountUp
and this is my next.config.ts:

import type { NextConfig } from "next"

const nextConfig: NextConfig = {
  /* config options here */
  reactCompiler: true,
  reactStrictMode: true,

  experimental: {
    inlineCss: true,
  },
}

export default nextConfig
but during the npm run build and then npm run start at this point only, my this EasedCountUp is not working at all, it always stays 0
@Egyptian Mau but during the `npm run build` and then `npm run start` at this point only, my this `EasedCountUp` is not working at all, it always stays 0
Poodle
so i actually think this is a react compiler issue not an SSR thing. you have reactCompiler: true in your next config right?
Basically react-countup uses countup.js under the hood which grabs the element by id and directly changes the textContent in the dom. the react compiler doesnt like that because it assumes react owns the dom, so in prod it just
overwrites whatever countup.js did and your number stays at 0. dev mode is more chill with it which is why it works there

try throwing "use no memo" as the first line in your eased-count-up.tsx, before the "use client" line. that just tells the compiler to skip that one
file

tsx                                                                         
 "use no memo"                                                                  
 "use client"                                                                   
                                                                                
 import "client-only"                                                           
 // rest of your code stays the same                                            
 


lmk if that fixes it
@Egyptian Mau unfortunately this isn't working as well... Furthermore, I ried `reactCompiler:false` even after that it's not working
Poodle
Maybe this is an SSR hydration issue. react-countup uses document.getElementById internally which doesn't exist during server render, and after hydration it never reinitializes properly.

You can try dynamically importing EasedCountUp with ssr disabled.

In hero-section.tsx replace your current import with this:

import dynamic from "next/dynamic"

const EasedCountUp = dynamic(
() => import("@/components/library/eased-count-up"),
{
ssr: false,
loading: () => <span>0</span>,
}
)

Try removing the old static import, the rest can stay the same. This forces it to only mount on the client so countup.js can actually find the dom element.
@Egyptian Mau tried this too, but unortunately this is also not working...
Poodle
ufff you are making it hard on me man lol.
@Poodle ufff you are making it hard on me man lol.
Egyptian MauOP
lol, but I am just saying the truth and result after I am trying what you've shared!.. and Sorry to make it hard on you
@Egyptian Mau Sure
Poodle
what if you ditch the hook approach entirely and use the CountUp component instead. This avoids the whole string ID / getElementById problem.
@Egyptian Mau you mean instead of `useCountUp` I should use `<CountUp />`?
Poodle
The problem might be useCountUp with the string ID approach - it uses document.getElementById internally and that's just not playing nice with React 19 prod builds.

Try rewriting EasedCountUp to use the CountUp component instead of the hook, this skips the whole getElementById issue.
Poodle
No useId, no getElementById - the CountUp component handles its own DOM ref internally.
"use client"

import "client-only"

import type { FC } from "react"
import CountUp from "react-countup"
import { useInView } from "react-intersection-observer"

import { formatCompact } from "@/utils/format-compact"

export interface EasedCountUpProps {
  end: number
  duration?: number
  threshold?: number
  className?: string
}

const EasedCountUp: FC<EasedCountUpProps> = (props) => {
  const { end, className = "", duration = 0.9, threshold = 0.4 } = props
  const { ref, inView } = useInView({ triggerOnce: true, threshold })

  return (
    <span ref={ref} className={className}>
      {inView ? (
        <CountUp
          end={end}
          duration={duration}
          formattingFn={formatCompact}
        />
      ) : (
        "0"
      )}
    </span>
  )
}

export default EasedCountUp 


lmk if this one works @Egyptian Mau
Egyptian MauOP
"use client"

import "client-only"

import type { FC } from "react"
import CountUp from "react-countup"
// import { useInView } from "react-intersection-observer"

import { formatCompact } from "@/utils/format-compact"

export interface EasedCountUpProps {
  end: number
  /** seconds (react-countup) */
  duration?: number
  /** IntersectionObserver threshold */
  threshold?: number
  className?: string
}

const EasedCountUp: FC<EasedCountUpProps> = (props) => {
  const { end, className = "", duration = 2.75, threshold = 0.4 } = props

  // const { ref: inViewRef, inView } = useInView({ triggerOnce: true, threshold })

  return (
    <CountUp
      start={0}
      end={end}
      duration={duration}
      startOnMount={false}
      enableScrollSpy
      useEasing
      formattingFn={formatCompact}
      easingFn={(t, b, c, d) => {
        const n = d ? Math.min(1, t / d) : 1 // normalize 0..1
        return b + c * (0.12 + 0.88 * n) // EXACT same curve
      }}
      className={className}
      onEnd={() => console.log("Ended! 👏")}
      onStart={() => console.log("Started! 💨")}
    >
      {({ countUpRef }) => (
        <div>
          <span ref={countUpRef} />
        </div>
      )}
    </CountUp>
  )
}

export default EasedCountUp
I tried this @Poodle - but unfortunately this didn't work at all
on top of that no onEnd={} & onStart={} is being consoled in the browser or anywhere
even I tried:

"use client"

import "client-only"

import type { FC } from "react"
import CountUp from "react-countup"
// import { useInView } from "react-intersection-observer"

import { formatCompact } from "@/utils/format-compact"

export interface EasedCountUpProps {
  end: number
  /** seconds (react-countup) */
  duration?: number
  /** IntersectionObserver threshold */
  threshold?: number
  className?: string
}

const EasedCountUp: FC<EasedCountUpProps> = (props) => {
  const { end, className = "", duration = 2.75, threshold = 0.4 } = props

  // const { ref: inViewRef, inView } = useInView({ triggerOnce: true, threshold })

  return (
    <CountUp
      start={0}
      end={end}
      duration={duration}
      startOnMount={false}
      enableScrollSpy
      useEasing
      formattingFn={formatCompact}
      easingFn={(t, b, c, d) => {
        const n = d ? Math.min(1, t / d) : 1 // normalize 0..1
        return b + c * (0.12 + 0.88 * n) // EXACT same curve
      }}
      className={className}
      onEnd={() => console.log("Ended! 👏")}
      onStart={() => console.log("Started! 💨")}
    />
  )
}

export default EasedCountUp


this also didn't worked
@Egyptian Mau this also not working
Poodle
So onStart and onEnd not even logging means the CountUp component isn't firing at all. react-countup 6.5.3 just isn't compatible with React 19 - the internal ref handling and scroll spy are broken with it.

Honestly easiest fix is to drop react-countup and write a quick custom one. It's like 20 lines and works with React 19 no issues.
@Poodle So onStart and onEnd not even logging means the CountUp component isn't firing at all. react-countup 6.5.3 just isn't compatible with React 19 - the internal ref handling and scroll spy are broken with it. Honestly easiest fix is to drop react-countup and write a quick custom one. It's like 20 lines and works with React 19 no issues.
Egyptian MauOP
honestly speaking, I am not just struggling with this CountUp but I've Lenis & Framer Motion and that too not working at all in Production Build -- and all this works perfectly fine in dev serve
@Egyptian Mau honestly speaking, I am not just struggling with this `CountUp` but I've `Lenis` & `Framer Motion` and that too not working at all in Production Build -- and all this works perfectly fine in dev serve
Poodle
Wait if Lenis and Framer Motion are also broken then this has nothing to do with CountUp. Your whole client hydration is broken in prod - the HTML renders but the JS never takes over. Can you share the repo? No way to debug this without seeing the full setup.
Egyptian MauOP
finally found the issue, which was causing the problem
Egyptian MauOP
it was due to Hyderation issue from the lazy() as I was having lazy() but not using the Suspense which was causing the issue, and now the issue is fixed