Back to Insights
Web Development

My Site Shipped Zeros to Google: An Animated Counter SSG Bug

My homepage showed "2+ Projects Shipped" in the browser and "0+ Projects Shipped" to every crawler that fetched it. Here is how a standard animated counter pattern silently poisoned my static HTML, and the two-line fix.

Muhammad Taki Ahmed
Muhammad Taki AhmedFounder & Software Developer at Raydrim
August 19, 2026
6 min read
My Site Shipped Zeros to Google: An Animated Counter SSG Bug

The symptom: a number only humans could see

I have a stats strip on the raydrim.com homepage. Four numbers: projects shipped, code ownership, service areas, response time. In a browser they read 2+, 100%, 6, <24hr. They count up as you scroll past. Nice little touch.

Then I fetched my own homepage with a tool that does not execute JavaScript, and got this:

0+  Projects Shipped
0%  Code Ownership
0   Service Areas
<0hr Response Time

"Less than zero hours response time" is a funny thing to promise. It is a much less funny thing to discover has been sitting in your served HTML for weeks, because that is the version a crawler reads, the version a link preview scrapes, and the version a human reviewer sees if their fetch does not run scripts.

Why useState(0) is a trap in SSG

Here is the counter component, and it is the same one you will find in a hundred tutorials:

export default function AnimatedCounter({ value, duration = 2 }: Props) {
  const [count, setCount] = useState(0);          // <-- the bug
  const ref = useRef<HTMLSpanElement>(null);
  const isInView = useInView(ref, { once: true, amount: 0.5 });

  useEffect(() => {
    if (!isInView) return;
    // ...requestAnimationFrame ramp from 0 to value
  }, [isInView, value, duration]);

  return <span ref={ref}>{Math.round(count)}</span>;
}

Read it as a static site generator would. At build time there is no browser, no viewport, no IntersectionObserver. React renders the component exactly once to a string. count is whatever useState was initialised with, and useEffect never runs — effects are a client-only concept.

So the generated HTML contains <span>0</span>. Always. The real value only appears after JavaScript loads, hydration completes, the element scrolls into view, and the animation finishes.

The insidious part is that it looks perfect in development and perfect in production, because you are looking at it in a browser. The broken output is only visible if you read the file on disk or fetch without JS.

The fix: render the truth, then animate

Initialise state with the real value, then knock it back to zero on mount — client-side only — before animating up:

export default function AnimatedCounter({ value, duration = 2 }: Props) {
  const [count, setCount] = useState(value);      // SSG emits the real number
  const ref = useRef<HTMLSpanElement>(null);
  const isInView = useInView(ref, { once: true, amount: 0.5 });
  const hasAnimated = useRef(false);

  useEffect(() => {
    if (!isInView || hasAnimated.current) return;
    hasAnimated.current = true;
    setCount(0);
    // ...requestAnimationFrame ramp from 0 to value
  }, [isInView, value, duration]);

  return <span ref={ref}>{Math.round(count)}</span>;
}

Now the static file says 2. A visitor with JavaScript sees it drop to zero and count back up, which is the effect I wanted anyway. A visitor without JavaScript, or a crawler, sees 2 and moves on.

The principle generalises: the server-rendered state should be the finished state, not the starting frame of an animation. Animation is a client-side enhancement. If your prerendered HTML represents frame zero, you have shipped frame zero to everyone who does not run your JavaScript.

The same bug wearing a different hat

Once I knew what to look for, I found it again. My scroll-reveal wrapper used Framer Motion like this:

<motion.div initial="hidden" whileInView="visible" variants={variants}>

with hidden defined as { opacity: 0, y: 40 }. At build time Framer Motion serialises the initial variant into an inline style, so the HTML shipped style="opacity:0;transform:translateY(40px)".

I counted the occurrences in my own build output. The services page had 23 of them. Pricing had 20. About had 17. The homepage had 15. Most of the visible content on every marketing page was invisible in the raw document, waiting on an IntersectionObserver to rescue it.

Same fix, same shape — render plain and unstyled until mounted:

const [isMounted, setIsMounted] = useState(false);
useEffect(() => setIsMounted(true), []);

if (!isMounted) {
  return <div className={className}>{children}</div>;
}

return (
  <motion.div initial="hidden" whileInView="visible" variants={variants} className={className}>
    {children}
  </motion.div>
);

One caveat worth knowing before you copy this: content that is already inside the viewport on first paint will flash visible, then hidden, then animate back in, because the swap to motion.div happens after mount. For above-the-fold sections I now skip the reveal entirely rather than animate them.

How to check your own build output

You do not need a tool for this. After next build, the prerendered files sit in .next/server/app/. Read them directly:

# Are you shipping zeros?
grep -o '>0<' .next/server/app/index.html | wc -l

# How much of the page is invisible in the raw HTML?
for f in .next/server/app/*.html; do
  echo "$f: $(grep -o 'opacity:0' "$f" | wc -l)"
done

Both commands should return zero, or something you can explain. Mine returned four and ninety-eight respectively.

The wider lesson I took from this: in a statically generated app, the browser is the friendliest possible reader of your site. Everything else — crawlers, scrapers, previews, reviewers, people on flaky connections where a script fails — sees the file, not the app. Read the file occasionally.

#Next.js#SSG#React#Debugging
Share this article
Muhammad Taki Ahmed

Written by Muhammad Taki Ahmed

Founder & Software Developer at Raydrim

Muhammad Taki Ahmed is a full-stack developer based in Dhaka, Bangladesh. He builds production web applications with Next.js, React and TypeScript, and writes about what actually broke while shipping them.

Related Technical Insights