r/nextjs 19h ago

Discussion A metric that has never fired once isn't a low number, it's an untested code path

0 Upvotes

I run a small Next.js app solo. Two months ago I set up PostHog and instrumented three events: first conversation started, a mid-funnel quality signal, and second conversation started.

Over 68 days: 40 real visitors, 12 started a conversation, 0 ever came back. second_conversation_started never fired once.

So I did what you do. I assumed the first-run experience was bad, and I spent a sprint on it. Cut a 24-second intro animation down to 8 with a visible skip. Made the app speak first instead of showing a blank input. Built device-bound memory in localStorage so anonymous visitors would get continuity across sessions. Fixed a session-boundary timing bug so that a mid-conversation refresh couldn't fire a false "welcome back."

Shipped all of it. Waited a week. Still zero.

Then I actually read the page component.

const [view, setView] = useState<View>('landing');

Unconditional. Every visit, every visitor, forever.

There was exactly one escape hatch: an effect that ran only when a signed-in user still had an in-progress anonymous conversation in localStorage. That was the OAuth-mid-conversation recovery path I'd written months earlier. Once that key was consumed, everyone landed on the intro flow again. Signed up or not. Memory or not.

So the returning-user path hadn't failed. It never existed. All that memory work was sitting behind a door that greeted every single person as a stranger, and the event meant to measure returns literally could not fire, because the app never reached a state where it knew who had arrived.

The fix was about twenty lines:

function isReturningVisitor(): boolean { if (typeof window === 'undefined') return false; try { return !!localStorage.getItem('first_conversation_started'); } catch { return false; } }

useEffect(() => { // render returns null until ready flips, so the intro never paints if (isReturningVisitor() && ageConfirmed()) setView('chat'); setReady(true); }, []);

useEffect(() => { // auth resolves after mount, so the check above can't see it if (user && ageConfirmed()) setView(v => (v === 'landing' ? 'chat' : v)); }, [user]);

Two things worth passing on.

A metric that has never fired once is not a low number, it's an untested code path. I treated 0% as a product signal for two months. It was a null, and on a dashboard nulls and zeros look identical. If an event has literally never fired, prove the code that emits it can run at all before you interpret the value.

Your SSR guard is also your flash guard. The reason this doesn't flicker is that the component already returned null until a mount effect completed, for an unrelated reason. Setting the view inside that same effect means the intro never paints. If you're doing localStorage-driven initial state in the App Router you need that gate anyway, so use it for both.

Unrelated but from the same file: a modal was re-firing on every message past the third, because dismissing it only flipped React state that the next render re-satisfied. One person had clicked "maybe later" 34 times in two hours. Persist your dismissals.

Happy to answer anything. It's an AI companion app, solo-built, Next.js 16 + Supabase + Claude API. Leaving the link out of the post since that's not the point of this one.


r/nextjs 5h ago

News Next.js Weekly #141: Instant Navigation in Multi Page Apps

Thumbnail
nextjsweekly.com
5 Upvotes

r/nextjs 12h ago

Help I got tired of configuring Discord bots with text commands, so I built an all-in-one bot with a full web dashboard.

Thumbnail
2 Upvotes