The Lovable Bug We Kept Running Into (And How We Solved It Once and For All)
A recurring bug pattern across three Lovable projects: state sync between Next.js server components and Supabase realtime. Root cause, fix template, and the reusable provider we now ship by default.
The same bug has now shown up in three Lovable projects we picked up from founders this quarter. Different products, different teams, same symptoms. A dashboard looks fine the moment you open it, realtime updates fire and the UI moves, but refresh the page or navigate away and back and the data reverts. Sometimes the opposite happens: a row you deleted reappears, then vanishes half a second later, then reappears again on the next click. Users email support saying the app is haunted.
After the third project we stopped treating it as a one-off and wrote down the fix. This is that writeup. If you shipped a Lovable app that uses Supabase realtime and Next.js server components, you almost certainly have some version of this bug hiding in your repo right now.
The report always sounds like a flaky UI problem. A Kanban card moves on one screen, the second screen is slow to catch up. A comment thread shows a new message, you refresh, the message disappears for a second, then reappears. A notification count jumps from 3 to 4 and back to 3.
When you open the network tab it looks fine. The Supabase websocket is connected, events are firing, the Postgres changes payload matches what you expect. The client handler runs. React state updates. And then, inexplicably, the next render wipes it.
The tell that you are in this exact bug, not something else, is that the regression happens specifically on soft navigation or router.refresh, not on a hard reload. A hard reload gets the latest data because the browser throws away everything. A soft navigation pulls from the Next.js route cache, which never heard about the realtime event.
Why Lovable Apps Are Especially Prone
Lovable scaffolds a Next.js App Router project with server components for the initial fetch and a client component for interactivity. That is the right architecture. The problem is the generated glue between the two layers assumes a static data model, and realtime is anything but static.
The default pattern Lovable tends to emit looks roughly like this. A server component fetches the initial list:
// app/rooms/page.tsx
import { createClient } from "@/lib/supabase/server";
export default async function RoomsPage() {
const supabase = createClient();
const { data: rooms } = await supabase.from("rooms").select("*");
return <RoomsList initialRooms={rooms ?? []} />;
}
And a client component subscribes to changes:
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
export function RoomsList({ initialRooms }) {
const [rooms, setRooms] = useState(initialRooms);
const supabase = createClient();
useEffect(() => {
supabase
.channel("rooms")
.on("postgres_changes", { event: "*", schema: "public", table: "rooms" }, (payload) => {
setRooms((prev) => reconcile(prev, payload));
})
.subscribe();
}, []);
return rooms.map((r) => <Row key={r.id} room={r} />);
}
Read that useEffect again. There is no cleanup. There is nothing preventing duplicate subscriptions on remount. And initialRooms is snapshotted into useState once and never reconciled with the server again. Every one of those decisions is a bug in production. Together they compound into the flicker.
Root Cause One: The Server Component Cache Never Heard About Realtime
Next.js 14 and 15 cache server component output aggressively. The Full Route Cache holds the RSC payload for a given URL, and the Data Cache holds fetch responses. Supabase realtime is a websocket event from Postgres to the browser. Nothing in that path runs on the Next.js server, so nothing invalidates either cache.
The user triggers a realtime event, your client state updates, everything looks right. Then the user clicks a link and comes back. Next.js serves the cached RSC payload. Your client component re-initializes with initialRooms from the stale server payload, overwriting whatever realtime had built up.
This is the stale-render-after-realtime pattern, and it is invisible in dev because dev mode disables most of the RSC cache. You only see it in production or in a preview deploy, which is why founders often ship the bug and discover it later when users complain.
Root Cause Two: useEffect Without Cleanup Creates Zombie Channels
The generated useEffect subscribes on mount and never unsubscribes. Under React Strict Mode in development, effects run twice, so you immediately have two channels. In production, any state change in a parent that causes this component to remount creates another channel without tearing down the previous one. Over the course of a session a single user can accumulate a dozen open subscriptions, all firing the same handler, all setting state.
You see this as events that appear to fire multiple times. A single insert logs three handler invocations. A toast notification shows twice. Your reconcile function has to be idempotent or the list grows by duplicates.
The fix is the most boring React hygiene imaginable, but Lovable omits it:
useEffect(() => {
const channel = supabase
.channel(`rooms:${userId}`)
.on("postgres_changes", { event: "*", schema: "public", table: "rooms" }, handler)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId]);
Three things to notice. The channel name includes a stable user identifier so concurrent tabs get separate channels instead of colliding. The returned cleanup calls removeChannel, not unsubscribe, because the former also releases the channel from the client's internal registry. The dependency array is narrow and stable so the effect does not tear down and rebuild on every render.
Root Cause Three: router.refresh and revalidatePath Are Not Interchangeable
Once you know the server cache is the problem, the instinct is to call router.refresh() from the realtime handler. That is half the answer, and it is the half that trips people up.
router.refresh is a client-side instruction that tells the Next.js router to re-fetch the current route's RSC payload from the server. It invalidates the Router Cache. It does not invalidate the Data Cache. If your server component fetches through a cached fetch call, or uses the React cache() dedupe wrapper, the re-render on the server will happily return the same cached bytes.
revalidatePath and revalidateTag are the server-side counterparts. They actually purge the Data Cache entries. But they can only be called from a server action or route handler. Your realtime handler runs in the browser, so you cannot call them directly.
The decision tree we use on every project now looks like this. If the data is genuinely live and should never be cached, mark the route export const dynamic = "force-dynamic" or wrap the fetch in unstable_noStore(). Then router.refresh() is enough, because there is no Data Cache to worry about. If the data benefits from caching for non-subscribed visitors, keep the fetch cached but tag it, and have the realtime client hit a small API route that calls revalidateTag. The cost is one extra round trip per change, paid only by subscribed clients.
// app/api/revalidate-rooms/route.ts
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
export async function POST() {
revalidateTag("rooms");
return NextResponse.json({ ok: true });
}
The server fetch tags itself so revalidateTag can find it:
const { data } = await supabase
.from("rooms")
.select("*")
.throwOnError();
// for fetch-based reads, pass next: { tags: ["rooms"] }
For Supabase queries that do not go through fetch, the pragmatic move is to accept force-dynamic on the live routes. The caching wins on a realtime-heavy page are usually marginal anyway.
Root Cause Four: The Singleton Browser Client and HMR
This one is subtler and we have hit it twice. Lovable's generated @/lib/supabase/client often exports a fresh createBrowserClient call every time it is imported. Under hot module reload, each edit creates a new client instance while the old one is still holding open websockets. Your dev server ends up with three or four zombie Supabase clients, each with its own subscriptions.
The fix is a module-level singleton guarded for the browser:
// lib/supabase/client.ts
"use client";
import { createBrowserClient } from "@supabase/ssr";
let client: ReturnType<typeof createBrowserClient> | undefined;
export function createClient() {
if (client) return client;
client = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
return client;
}
One client per browser tab, not one per import. HMR reloads the module, but the websocket state lives on the old module instance which gets garbage collected. No more zombie channels from dev edits.
The Provider Pattern We Ship By Default
Once all four root causes are handled, the cleanest way to apply them consistently is a provider component. Server component passes initial data in as props, provider owns the subscription and the state, consumers read from context. This is the template we now drop into every Lovable project we pick up.
"use client";
import { createContext, useContext, useEffect, useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
type Room = { id: string; name: string; updated_at: string };
const RoomsContext = createContext<Room[]>([]);
export function RoomsProvider({
userId,
initialRooms,
children,
}: {
userId: string;
initialRooms: Room[];
children: React.ReactNode;
}) {
const [rooms, setRooms] = useState(initialRooms);
const supabase = useMemo(() => createClient(), []);
const router = useRouter();
useEffect(() => {
setRooms(initialRooms);
}, [initialRooms]);
useEffect(() => {
const channel = supabase
.channel(`rooms:${userId}`)
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "rooms" },
(payload) => {
setRooms((prev) => reconcile(prev, payload));
router.refresh();
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [supabase, userId, router]);
return <RoomsContext.Provider value={rooms}>{children}</RoomsContext.Provider>;
}
export const useRooms = () => useContext(RoomsContext);
function reconcile(prev: Room[], payload: any): Room[] {
switch (payload.eventType) {
case "INSERT":
if (prev.some((r) => r.id === payload.new.id)) return prev;
return [...prev, payload.new];
case "UPDATE":
return prev.map((r) => (r.id === payload.new.id ? payload.new : r));
case "DELETE":
return prev.filter((r) => r.id !== payload.old.id);
default:
return prev;
}
}
Two details worth calling out. The second useEffect resets local state whenever initialRooms changes, which is exactly what happens after router.refresh completes and the server component re-renders with fresh data. That reset is the reconciliation step that keeps server truth and client optimistic state aligned. Without it, a successful server refresh would still be overridden by stale client state until the next realtime event.
The reconcile function guards against the duplicate-insert case where realtime delivers an event for a row that the user just created optimistically. Idempotency here is cheap insurance.
What To Check In Your Own Codebase Right Now
Four greps will tell you if you have this bug. Search for .subscribe() without a nearby removeChannel. Search for useEffect blocks containing .channel( without a return statement. Search for router.refresh in realtime handlers and check whether the target route is dynamic or cached. And look at your lib/supabase/client.ts to see whether it returns a fresh instance on every call.
If any of those show red flags, the bug is in your repo. It may not have manifested loudly yet because your user base is small or your realtime volume is low, but it will, and it will be intermittent and hard to reproduce, which is the worst kind of bug to debug under support pressure.
When To Call The Last Mile In
Most of our Lovable pickups start with a founder saying the app works but something feels off. Realtime is almost always where that off feeling lives, because it is the one place where the generated code and the framework defaults disagree in a way that compiles and runs cleanly. No error message. No stack trace. Just unhappy users.
We ship this provider pattern, the singleton client, the route-level dynamic flags, and the reconcile helpers as a bundle. A typical Lovable project takes us under a day to audit and fix end to end. If you are sitting on a project that works in demos but breaks in the hands of real users, that is the last mile, and it is what we do. Send us the repo and we will tell you within an afternoon whether this is your bug. Most of the time, it is.
> Ship the Lovable app you demoed. We handle the last mile, realtime, auth edges, deploy hardening, so your users never see the flicker.
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
MVP Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss vibe coders for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.