Web razvoj
Next.jsSupabaseRealtimePostgreSQLRLSWebSocketsApp RouterChatPresence

Next.js Supabase Realtime u 2026: End-to-End nacrt za chat, presence i suradnju

AO
Adrijan Omićević
·14 min čitanja

# Što ćete izgraditi i zašto je to važno#

Ovaj vodič je end-to-end nacrt za Next.js Supabase realtime funkcionalnosti u App Routeru: chat poruke, presence, indikatori tipkanja, feed aktivnosti i lagani signali suradnje.

Realtime je rijetko “samo subscribe i render”. U produkciji morate pokriti RLS, optimistički UI, deduplikaciju, rupe tijekom reconnecta i skaliranje fan-outa. Ovaj post daje praktičnu arhitekturu koja preživljava stvarno ponašanje korisnika.

Ako želite dodatnu pozadinu o realtime transportima i alternativama, pročitajte Next.js real-time značajke s WebSockets, SSE i Supabase Realtime. Za puni SaaS temelj, pogledajte Next.js + Supabase SaaS starter arhitekturu.

# Pregled arhitekture: trajni vs efemerni realtime#

U pravilu ćete imati dva realtime “toka”:

  1. 1

    Trajni eventi pohranjeni u Postgresu i isporučeni putem Supabase Realtime Postgres promjena.

    • Chat poruke
    • Stavke feeda aktivnosti
    • Ažuriranja dokumenata koja želite trajno spremiti
  2. 2

    Efemerni signali koji ne bi smjeli često pogađati bazu.

    • Presence i online korisnici
    • Indikatori tipkanja
    • Pozicija kursora, “gledam sekciju”, live selekcija

Supabase podržava oba:

  • Postgres change feedove za trajne podatke.
  • Presence na kanalima za efemerno stanje.

Preporučeni model kanala#

FunkcionalnostSpremišteSupabase značajkaImenovanje kanalaNapomene
Chat porukePostgresPostgres changesroom:{roomId}Pretplatite se na insertove u messages ograničene na room
Presence (online)Kanal u memorijiPresenceroom:{roomId}Jedan kanal može nositi i Presence i DB evente
TipkanjeKanal u memorijiBroadcastroom:{roomId}Broadcast ima nisku latenciju i izbjegava DB zapise
Feed aktivnostiPostgresPostgres changesworkspace:{workspaceId}Fan-out može narasti, razmislite o particioniranju
Suradnja “netko gleda”Kanal u memorijiPresence ili Broadcastdoc:{docId}Držite transient da izbjegnete amplifikaciju zapisa

🎯 Ključna poruka: Spremajte samo ono što morate auditirati ili reproducirati. Sve ostalo neka bude Presence ili Broadcast kako bi baza ostala brza, a račun predvidiv.

# Preduvjeti#

ZahtjevVerzijaNapomene
Next.js14 ili 15Primjeri za App Router pretpostavljaju moderan React Server Components setup
SupabaseNajnovijiKoristite @supabase/supabase-js v2
PostgresSupabase-managedUključite RLS i Realtime replikaciju na potrebnim tablicama
AuthSupabase AuthPrimjeri pretpostavljaju da je auth.uid() dostupan u policyjima

# Model podataka: dizajn sheme za realtime chat, presence i activity#

Dizajnirajte shemu tako da podržava:

  • Ograničavanje na room
  • Provjere članstva
  • Učinkovito sortiranje i paginaciju
  • Deduplikaciju i idempotenciju

Osnovne tablice#

TablicaSvrhaKljučni stupciRealtime eventi
workspacesGranica tenant-aid, nameRijetko
workspace_membersAutorizacijski opsegworkspace_id, user_id, rolePonekad
roomsChat kanaliid, workspace_id, nameUmjereno
room_membersPristup na razini roomaroom_id, user_idUmjereno
messagesTrajni chatid, room_id, user_id, content, created_at, client_idVisoko
activity_eventsTrajni feedid, workspace_id, type, actor_id, payload, created_atVisoko

Ključni detalj za produkciju je client_id na messages za reconciliaciju optimističkog UI-ja i deduplikaciju.

SQL shema (minimalna, production-ready)#

SQL
create table public.workspaces (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  created_at timestamptz not null default now()
);
 
create table public.workspace_members (
  workspace_id uuid not null references public.workspaces(id) on delete cascade,
  user_id uuid not null,
  role text not null default 'member',
  created_at timestamptz not null default now(),
  primary key (workspace_id, user_id)
);
 
create table public.rooms (
  id uuid primary key default gen_random_uuid(),
  workspace_id uuid not null references public.workspaces(id) on delete cascade,
  name text not null,
  created_at timestamptz not null default now()
);
 
create table public.room_members (
  room_id uuid not null references public.rooms(id) on delete cascade,
  user_id uuid not null,
  created_at timestamptz not null default now(),
  primary key (room_id, user_id)
);
 
create table public.messages (
  id uuid primary key default gen_random_uuid(),
  room_id uuid not null references public.rooms(id) on delete cascade,
  user_id uuid not null,
  content text not null,
  client_id uuid not null,
  created_at timestamptz not null default now()
);
 
create index on public.messages (room_id, created_at desc);
create unique index messages_room_client_id_unique
  on public.messages (room_id, client_id);
 
create table public.activity_events (
  id uuid primary key default gen_random_uuid(),
  workspace_id uuid not null references public.workspaces(id) on delete cascade,
  actor_id uuid not null,
  type text not null,
  payload jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now()
);
 
create index on public.activity_events (workspace_id, created_at desc);

Uključite Realtime replikaciju za tablice#

Supabase Realtime treba da su tablice u publicationu.

SQL
alter publication supabase_realtime add table public.messages;
alter publication supabase_realtime add table public.activity_events;

ℹ️ Napomena: Presence i Broadcast ne zahtijevaju replikaciju baze. Samo Postgres changes zahtijevaju.

# RLS: sigurno čitanje i pisanje bez razbijanja realtimea#

Realtime je koristan samo ako je siguran. Najčešći produkcijski incident je “korisnici vide evente koje ne bi smjeli” ili “eventi nikad ne dolaze zbog neusklađenih policyja”.

Principi koji RLS čine održivim#

  • Uvijek ograničite po workspace_id ili room_id.
  • Izbjegavajte policyje koji traže joinove s velikim tablicama bez indeksa.
  • Osigurajte da membership tablice imaju primarne ključeve i indekse.
  • Koristite exists provjere koje odgovaraju vašem modelu pristupa.

RLS policyji za chat#

SQL
alter table public.messages enable row level security;
 
create policy "read messages in rooms I belong to"
on public.messages for select
to authenticated
using (
  exists (
    select 1
    from public.room_members rm
    where rm.room_id = messages.room_id
      and rm.user_id = auth.uid()
  )
);
 
create policy "insert messages in rooms I belong to"
on public.messages for insert
to authenticated
with check (
  user_id = auth.uid()
  and exists (
    select 1
    from public.room_members rm
    where rm.room_id = messages.room_id
      and rm.user_id = auth.uid()
  )
);

RLS policyji za feed aktivnosti#

SQL
alter table public.activity_events enable row level security;
 
create policy "read activity in my workspaces"
on public.activity_events for select
to authenticated
using (
  exists (
    select 1
    from public.workspace_members wm
    where wm.workspace_id = activity_events.workspace_id
      and wm.user_id = auth.uid()
  )
);
 
create policy "insert activity in my workspaces"
on public.activity_events for insert
to authenticated
with check (
  actor_id = auth.uid()
  and exists (
    select 1
    from public.workspace_members wm
    where wm.workspace_id = activity_events.workspace_id
      and wm.user_id = auth.uid()
  )
);

⚠️ Upozorenje: Ako testirate Realtime sa service role key-jem, možete nenamjerno zaobići RLS i zaključiti da sve radi. U browseru vaš realtime kanal koristi user JWT, pa će nedostajući policyji tiho blokirati evente.

# Next.js App Router setup: server fetch + client subscribe#

Stabilan realtime UI koristi dvofazni tok podataka:

  1. 1
    Server komponenta učitava inicijalni snapshot za brz prvi render i SEO-friendly layout.
  2. 2
    Client komponenta se pretplaćuje na realtime ažuriranja i primjenjuje ih inkrementalno.

Server komponenta: inicijalne poruke#

TypeScript
// app/rooms/[roomId]/page.tsx
import { createClient } from "@/lib/supabase/server";
 
export default async function RoomPage({ params }: { params: { roomId: string } }) {
  const supabase = await createClient();
 
  const { data: messages } = await supabase
    .from("messages")
    .select("id, room_id, user_id, content, client_id, created_at")
    .eq("room_id", params.roomId)
    .order("created_at", { ascending: false })
    .limit(50);
 
  return (
    <div>
      <h1>Room</h1>
      {/* Render client component with initial snapshot */}
      {/* Pass only serializable data */}
      {/* eslint-disable-next-line */}
      {/* @ts-ignore */}
      <RoomRealtime roomId={params.roomId} initialMessages={messages ?? []} />
    </div>
  );
}

Client komponenta: subscribe + presence + optimistički insertovi#

TypeScript
"use client";
 
import { useEffect, useMemo, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client";
 
type Message = {
  id: string;
  room_id: string;
  user_id: string;
  content: string;
  client_id: string;
  created_at: string;
};
 
export function RoomRealtime({
  roomId,
  initialMessages,
}: {
  roomId: string;
  initialMessages: Message[];
}) {
  const supabase = useMemo(() => createClient(), []);
  const [messages, setMessages] = useState<Message[]>(initialMessages);
  const [typingUsers, setTypingUsers] = useState<string[]>([]);
  const seenClientIds = useRef(new Set<string>());
 
  useEffect(() => {
    for (const m of initialMessages) seenClientIds.current.add(m.client_id);
  }, [initialMessages]);
 
  useEffect(() => {
    const channel = supabase.channel(`room:${roomId}`, {
      config: { presence: { key: "user" } },
    });
 
    channel
      .on(
        "postgres_changes",
        { event: "INSERT", schema: "public", table: "messages", filter: `room_id=eq.${roomId}` },
        (payload) => {
          const m = payload.new as Message;
          if (seenClientIds.current.has(m.client_id)) return;
          seenClientIds.current.add(m.client_id);
          setMessages((prev) => [m, ...prev]);
        }
      )
      .on("broadcast", { event: "typing" }, (payload) => {
        const users = (payload.payload?.users as string[]) ?? [];
        setTypingUsers(users);
      })
      .on("presence", { event: "sync" }, () => {
        const state = channel.presenceState();
        const online = Object.keys(state);
        // You can render online count from this
      })
      .subscribe();
 
    return () => {
      supabase.removeChannel(channel);
    };
  }, [supabase, roomId]);
 
  return (
    <div>
      <div>{typingUsers.length ? "Netko tipka..." : null}</div>
      <ul>
        {messages.map((m) => (
          <li key={m.id}>{m.content}</li>
        ))}
      </ul>
    </div>
  );
}

# Optimistički UI: idempotencija, reconciliacija i redoslijed#

Optimistički UI je mjesto gdje većina realtime chat UI-jeva puca: duplikati, poruke izvan redoslijeda i “ghost” pending poruke.

Obrazac koji radi#

  • Generirajte client_id po poruci na klijentu.
  • Odmah ubacite poruku u UI sa status = pending.
  • Umetnite u Postgres s client_id.
  • Kada stigne realtime INSERT, napravite reconciliaciju po client_id.
  • Enforcajte jedinstvenost na (room_id, client_id) da jamčite idempotenciju.

Client-side funkcija za slanje (s optimističkim insertom)#

TypeScript
import { randomUUID } from "crypto";
 
async function sendMessage(opts: {
  supabase: any;
  roomId: string;
  userId: string;
  content: string;
  setMessages: any;
  seenClientIds: any;
}) {
  const clientId = randomUUID();
  const now = new Date().toISOString();
 
  opts.seenClientIds.current.add(clientId);
 
  opts.setMessages((prev: any[]) => [
    {
      id: `pending:${clientId}`,
      room_id: opts.roomId,
      user_id: opts.userId,
      content: opts.content,
      client_id: clientId,
      created_at: now,
      status: "pending",
    },
    ...prev,
  ]);
 
  const { error } = await opts.supabase.from("messages").insert({
    room_id: opts.roomId,
    user_id: opts.userId,
    content: opts.content,
    client_id: clientId,
  });
 
  if (error) {
    opts.setMessages((prev: any[]) =>
      prev.map((m) => (m.client_id === clientId ? { ...m, status: "failed" } : m))
    );
  }
}

Strategija redoslijeda#

U chatu korisnici očekuju stabilan redoslijed. Koristite created_at za prikaz, ali imajte na umu da dvije poruke pod opterećenjem mogu dijeliti isti timestamp.

Praktičan pristup:

  • Na fetchu sortirajte po (created_at, id).
  • U UI-ju ubacujte realtime stavke, a zatim po potrebi sortirajte pri renderu.
  • Ograničite duljinu liste kako biste izbjegli rast memorije.

# Presence i tipkanje: koristite efemerne kanale, ne tablice#

Presence nije problem baze podataka. Ako zapisujete presence u Postgres, stvarate:

  • Visoku frekvenciju zapisa
  • Kontenciju lockova
  • Pritisak na vacuum
  • Nepotrebnu buku u realtime feedu

Presence: pratite online korisnike po roomu#

Kad je kanal pretplaćen, trackajte korisnika.

TypeScript
await channel.track({
  user_id: userId,
  name: displayName,
  last_seen: new Date().toISOString(),
});

Za prikaz online korisnika, pročitajte presenceState() i mapirajte vrijednosti. Payload držite malim, jer se presence state broadcasta.

Indikatori tipkanja: broadcast uz throttling#

Tipkanje treba slati broadcastom i throttlati kako biste izbjegli spam.

TypeScript
let typingTimeout: any;
 
function setTyping(channel: any, userId: string, isTyping: boolean) {
  clearTimeout(typingTimeout);
 
  channel.send({
    type: "broadcast",
    event: "typing",
    payload: { users: isTyping ? [userId] : [] },
  });
 
  if (isTyping) {
    typingTimeout = setTimeout(() => {
      channel.send({ type: "broadcast", event: "typing", payload: { users: [] } });
    }, 1200);
  }
}

💡 Savjet: Tipkanje i kursore tretirajte kao best-effort signale. Ako stigne poruka, odmah očistite typing i nemojte čekati timeout.

# Feed aktivnosti i signali suradnje#

Feed aktivnosti je po definiciji trajan. Česta zamka je logiranje previše toga i stvaranje skupe “hot” tablice.

Što logirati#

Dobri activity eventi:

  • “Task prebačen u Done”
  • “Korisnik pozvan”
  • “Komentar dodan”

Loši activity eventi:

  • Svaki pritisak tipke
  • Svako pomicanje kursora
  • “Korisnik čita sekciju 3” svakih 5 sekundi

Dizajn activity payload-a#

Koristite mali, verzionirani payload:

  • type je stabilan identifikator.
  • payload je strukturiran i minimalan.
  • Ako trebate puni kontekst, dohvatite referencirani entitet.

Primjer payload-a:

  • type = "message.created"
  • payload = { "room_id": "...", "message_id": "..." }

To drži evente malima i smanjuje outbound bandwidth.

# Skaliranje: fan-out, stopa i trošak#

Skaliranje realtimea je uglavnom pitanje fan-outa po kanalu i stope eventa.

Praktične smjernice za skaliranje#

ProblemSimptomRješenje
Previše pretplatnika po kanaluSkokovi latencije, izgubljena ažuriranjaPodijelite kanale po roomu, dokumentu ili workspaceu; izbjegavajte “globalne” kanale
Previše DB zapisa za efemerno stanjeVisok CPU, visok IOPrebacite na Presence/Broadcast; debounceajte ažuriranja
Teški payloadiSpori klijenti, visok egressŠaljite id-eve i dohvaćajte detalje; payload držite “lean”
Hot particijeJedan room dominira prometomRazmislite o shardingu roomova ili limitiranju broja sudionika po roomu
Neograničeno UI stanjeMemorija taba rasteDržite prozor poruka, virtualizirajte listu

Reality check za throughput#

Čak i umjerena upotreba može biti “spiky”:

  • 200 istovremenih korisnika u roomu
  • 1 poruka u sekundi
  • To je 200 isporuka poruke u sekundi, plus presence overhead

Ako šaljete typing ažuriranja 5 puta u sekundi po korisniku, lako multiplicirate volumen eventa 10x do 50x. Koristite throttle i efemerne evente tretirajte kao best-effort.

# Troubleshooting: duplikati, rupe pri reconnectu i neusklađene dozvole#

Ovaj dio je razlika između demo-a i produkcijskog sustava.

Duplicirani eventi#

Česti uzroci

  • React Strict Mode pokreće effecte dvaput u developmentu.
  • Komponenta se remounta bez cleanupa kanala.
  • Više tabova se pretplati, a vi svaki event tretirate kao jedinstven.
  • Optimistički ubacite poruku i dodatno dodate realtime insert bez reconciliacije.

Checklist za popravak

  1. 1
    Osigurajte da pozivate removeChannel na unmountu.
  2. 2
    Koristite stabilnu instancu supabase klijenta.
  3. 3
    Deduplicirajte po client_id i/ili id.
  4. 4
    Dodajte unique constraint na (room_id, client_id).

Logika reconnecta i propušteni eventi#

Websocket reconnect može propustiti neke insertove. Ako se oslanjate samo na realtime, UI može “odlutati”.

Nacrt

  • Na subscribe, dohvatite najnovijih N poruka.
  • Pratite zadnji viđeni created_at i id.
  • Nakon reconnecta, ponovno dohvatite poruke novije od vašeg zadnjeg markera.

Supabase kanali izlažu promjene statusa, pa možete okinuti reconciliaciju.

TypeScript
channel.subscribe((status: string) => {
  if (status === "SUBSCRIBED") {
    // Optionally refetch newest items to close gaps
  }
});

Neusklađene dozvole i “tihi” failurei#

Simptomi:

  • Možete fetchati retke, ali realtime nikad ne okida.
  • Neki korisnici dobivaju evente, drugi ne.
  • Insertovi uspiju, ali pretplatnici ne dobiju update.

Root uzroci:

  • Tablica nije dodana u supabase_realtime publication.
  • RLS SELECT policy nedostaje ili je previše restriktivan.
  • Klijent koristi anon key bez auth sessiona.
  • Filter ne odgovara, kriva schema ili naziv tablice.

Checklist za debug:

  • Potvrdite da je korisnik autentificiran u browseru.
  • Testirajte SELECT pod istom user sessionom.
  • Provjerite da publication uključuje tablicu.
  • Privremeno proširite RLS policy da izolirate problem, pa ga opet stegnite.

⚠️ Upozorenje: Policy koji dopušta INSERT, ali zabranjuje SELECT, omogućit će pošiljatelju zapis, ali pretplatnici možda neće vidjeti red preko realtimea jer je SELECT blokiran. Realtime isporuka mora poštovati ono što pretplatnik smije selektirati.

Event “oluje” i UI thrashing#

Ako ažurirate state za svaki event, React renderiranje može postati bottleneck.

Rješenja:

  • Batchajte ažuriranja kad je moguće.
  • Držite liste virtualiziranima za velike roome.
  • Pretplatite se samo na ono što korisnik trenutno gleda.

# Observability: logiranje i nadzor realtimea u produkciji#

Realtime problemi su često intermitentni: specifične mreže, mobile backgrounding ili problemi s refreshom tokena. Dodajte instrumentaciju rano.

Što logirati:

  • Ime kanala i promjene statusa pretplate
  • Broj reconnectova
  • Insert greške i RLS failuree
  • Client-side dedup “hitove”

Za production monitoring stack na Vercelu, pogledajte Next.js logiranje i monitoring sa Sentry, OpenTelemetry i Vercel.

# Ključne poruke#

  • Dizajnirajte trajne tablice za chat i activity, a efemerne signale poput tipkanja i kursora držite na Presence ili Broadcast kako biste izbjegli amplifikaciju zapisa.
  • Koristite client_id plus unique constraint po roomu za idempotenciju, reconciliaciju optimističkog UI-ja i sprječavanje dupliciranih eventa.
  • Realtime tretirajte kao inkrementalni sloj: uvijek napravite inicijalni fetch i pokrenite reconciliation fetch nakon reconnecta kako biste spriječili propuštene evente.
  • Uključite RLS svugdje i pišite eksplicitne membership-based SELECT i INSERT policyje, jer realtime isporuka ovisi o SELECT dozvolama pretplatnika.
  • Skalirajte smanjenjem fan-outa i veličine payload-a: dijelite kanale po roomu ili dokumentu, throttlajte broadcastove i držite UI stanje ograničenim.

# Zaključak#

Next.js App Router plus Supabase Realtime je snažna kombinacija za chat, presence i suradnju, ali production pouzdanost dolazi iz “dosadnih” dijelova: izbora sheme, ispravnog RLS-a, deduplikacije i reconciliacije nakon reconnecta.

Ako želite da Samioda implementira realtime podsustav end-to-end, uključujući RLS audite, optimistički UI i load testing, kontaktirajte nas putem samioda.com i podijelite zahtjeve proizvoda i očekivanu konkurentnost.

FAQ

Share
A
Adrijan OmićevićOsnivač i senior developer

Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.