React
Nothing about React needs a Baselyra adapter — the client is a plain object with promises and callbacks. What is worth writing down is where to create it, how to track the session, and the one effect mistake everybody makes with realtime.
Setup
npm install @baselyra/client
VITE_BASELYRA_URL=https://api.example.com
VITE_BASELYRA_ANON_KEY=eyJhbGciOiJIUzI1NiJ9…
import { createClient } from '@baselyra/client';
export const bl = createClient(
import.meta.env.VITE_BASELYRA_URL,
import.meta.env.VITE_BASELYRA_ANON_KEY,
);
Export one instance and import it everywhere. Two instances means two sessions, two refresh timers racing to rotate the same refresh token, and users signed out at random.
An auth hook
import { useEffect, useState } from 'react';
import type { User } from '@baselyra/client';
import { bl } from './baselyra';
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
bl.auth.getSession().then(({ data }) => {
setUser(data.session?.user ?? null);
setLoading(false);
});
const { data: { subscription } } = bl.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
});
return () => subscription.unsubscribe();
}, []);
return { user, loading };
}
getSession() refreshes first if the stored token has already expired, so it
never hands you a token the server will reject. The listener then covers sign-in,
sign-out, background renewal and profile updates.
A protected route
import { Navigate } from 'react-router-dom';
import { useAuth } from './lib/useAuth';
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return <p>Loading…</p>;
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
Reading data
import { useEffect, useState } from 'react';
import { bl } from './lib/baselyra';
export function Notes() {
const [notes, setNotes] = useState<Note[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
bl.from('notes')
.select('id,title,created_at')
.order('created_at', { ascending: false })
.abortSignal(controller.signal)
.then(({ data, error }) => {
if (error) {
if (error.code === 'aborted') return; // the component went away
return setError(error.message);
}
setNotes(data ?? []);
});
return () => controller.abort();
}, []);
if (error) return <p role="alert">{error}</p>;
return <ul>{notes.map((n) => <li key={n.id}>{n.title}</li>)}</ul>;
}
An empty list here usually is not a bug: it means row level security admits no rows for this caller. Check the policy before you check the query.
A list that stays live
useEffect(() => {
bl.from('notes').select('id,title').order('created_at', { ascending: false })
.then(({ data }) => setNotes(data ?? []));
// Created inside the effect so the cleanup drops it.
const channel = bl.channel('public:notes');
channel.on<Note>('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'notes' },
({ new: row }) => setNotes((prev) => (row ? [row, ...prev] : prev)));
void channel.subscribe();
return () => { void bl.removeChannel(channel); };
}, []);
Writing, and optimistic updates
async function addNote(title: string) {
const { data, error } = await bl.from('notes').insert({ title }).select().single();
if (error) return setError(error.message);
// With realtime on this table the INSERT comes back over the channel too —
// key the list by id so the two do not double up.
setNotes((prev) => (prev.some((n) => n.id === data.id) ? prev : [data, ...prev]));
}
Do not send user_id or author from the client. Give the column a
default auth.uid() and let the policy's with check refuse anything else —
ownership established by the database cannot be forgotten by a component.
Uploading a file
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file || !user) return;
const path = user.id + '/' + file.name;
const { error } = await bl.storage.from('avatars').upload(path, file, { upsert: true });
if (error) return setError(error.message);
const { data } = bl.storage.from('avatars').getPublicUrl(path);
setAvatarUrl(data.publicUrl + '?v=' + Date.now()); // bust the immutable cache
}
A public object is served immutable with a year's max-age, so a re-upload at
the same key needs a cache-busting query parameter or the browser keeps showing the
old image.
Failure modes
| What you see | Why | Fix |
|---|---|---|
| Every realtime message twice | The channel outlived a remount, or Strict Mode double-invoked the effect | Create inside the effect, removeChannel in the cleanup |
[] from a table with rows | RLS admits nothing for this role | Test the policy |
| Users signed out at random | Two client instances rotating the same refresh token | One createClient, exported |
error.code === 'aborted' in the console | A component unmounted mid-request | Ignore that code; it is not a failure |
| A re-uploaded image does not change | The public URL is cached immutable | Add a version query parameter |
| CORS errors from the browser | CORS_ORIGINS does not name your dev origin | Add http://localhost:5173 on the server |