Next.js
Baselyra has no cookie-based session helper, and that shapes how a Next.js app is built on it. This page shows the two patterns that work, the three clients worth having, and the one variable name that must never carry the service key.
Environment
NEXT_PUBLIC_BASELYRA_URL=https://api.example.com
NEXT_PUBLIC_BASELYRA_ANON_KEY=eyJhbGciOiJIUzI1NiJ9…
BASELYRA_SERVICE_KEY=eyJhbGciOiJIUzI1NiJ9… # no NEXT_PUBLIC_ prefix, ever
The browser client
'use client';
import { createClient } from '@baselyra/client';
export const bl = createClient(
process.env.NEXT_PUBLIC_BASELYRA_URL!,
process.env.NEXT_PUBLIC_BASELYRA_ANON_KEY!,
);
One instance for the app's lifetime: it owns the session, its refresh timer and the single WebSocket every channel shares.
The server clients
import 'server-only';
import { createClient } from '@baselyra/client';
const URL = process.env.NEXT_PUBLIC_BASELYRA_URL!;
const ANON = process.env.NEXT_PUBLIC_BASELYRA_ANON_KEY!;
/**
* Anonymous reads on the server — public content, sitemaps, OG images.
* Sees exactly what a logged-out visitor sees, because it runs as anon.
*/
export const blPublic = createClient(URL, ANON, {
auth: { persistSession: false, autoRefreshToken: false },
realtime: { enabled: false },
});
/**
* A per-request client acting as one signed-in user. RLS still applies, which
* is the point: server code gets the user's own view, not everyone's.
*/
export function blAsUser(accessToken: string) {
return createClient(URL, ANON, {
auth: { persistSession: false, autoRefreshToken: false },
realtime: { enabled: false },
global: {
fetch: (input, init = {}) => {
const headers = new Headers(init.headers);
headers.set('authorization', 'Bearer ' + accessToken);
return fetch(input, { ...init, headers });
},
},
});
}
/** Bypasses every policy. Webhooks, cron, admin tooling. Nothing user-facing. */
export function blAdmin() {
const key = process.env.BASELYRA_SERVICE_KEY;
if (!key) throw new Error('BASELYRA_SERVICE_KEY is not set');
return createClient(URL, key, {
auth: { persistSession: false, autoRefreshToken: false },
realtime: { enabled: false },
});
}
Both server clients turn realtime and session persistence off. A server has no browser to persist to, and a WebSocket per request would leak sockets.
Sessions and server components
The client keeps the session in localStorage, so a server component
cannot see who is signed in. That is a real constraint, not an oversight: there
is no cookie-based session helper. Two patterns work.
A. Client-side auth, server-side public data
Server components render public content with blPublic; anything user-specific
is a client component using bl. Simplest, and right for most apps.
import { blPublic } from '@/lib/baselyra-server';
export const revalidate = 60;
export default async function Home() {
const { data: posts } = await blPublic
.from('posts')
.select('id,title,slug')
.not('published_at', 'is', null)
.order('published_at', { ascending: false })
.limit(20);
return <ul>{posts?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
B. Mirror the token into a cookie
When you need user-scoped server rendering. Write the token from the client on
every auth change, read it in a server component, and hand it to blAsUser.
'use client';
import { useEffect } from 'react';
import { bl } from '@/lib/baselyra';
export function AuthSync({ children }: { children: React.ReactNode }) {
useEffect(() => {
const { data: { subscription } } = bl.auth.onAuthStateChange((_event, session) => {
// Session-scoped, no Max-Age: it dies with the browser session.
document.cookie = session
? 'bl-token=' + session.access_token + '; Path=/; SameSite=Lax; Secure'
: 'bl-token=; Path=/; Max-Age=0; SameSite=Lax; Secure';
});
return () => subscription.unsubscribe();
}, []);
return <>{children}</>;
}
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { blAsUser } from '@/lib/baselyra-server';
export default async function Dashboard() {
const token = (await cookies()).get('bl-token')?.value;
if (!token) redirect('/login');
const bl = blAsUser(token);
const { data: notes, error } = await bl
.from('notes')
.select('id, title, created_at')
.order('created_at', { ascending: false });
if (error) {
if (error.status === 401) redirect('/login'); // the hour is up
throw new Error(error.message);
}
return <ul>{notes?.map((n) => <li key={n.id}>{n.title}</li>)}</ul>;
}
The access token expires in an hour, so treat a 401 from a server component
as "sign in again" rather than as a bug.
Sign in
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { bl } from '@/lib/baselyra';
export default function LoginForm() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setBusy(true);
setError(null);
const form = new FormData(e.currentTarget);
const { error } = await bl.auth.signInWithPassword({
email: String(form.get('email')),
password: String(form.get('password')),
});
setBusy(false);
if (error) return setError(error.message);
router.refresh();
router.push('/dashboard');
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" autoComplete="email" required />
<input name="password" type="password" autoComplete="current-password" required />
<button disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
A route handler with the service key
import { NextResponse } from 'next/server';
import { blAdmin } from '@/lib/baselyra-server';
export async function POST(request: Request) {
const event = await request.json();
// Verify the provider's signature here before trusting anything.
const bl = blAdmin();
const { error } = await bl
.from('subscriptions')
.upsert({ user_id: event.data.user_id, status: event.data.status }, { onConflict: 'user_id' });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true });
}
Realtime in a client component
'use client';
import { useEffect, useState } from 'react';
import { bl } from '@/lib/baselyra';
export function LiveNotes({ initial }: { initial: Note[] }) {
const [notes, setNotes] = useState(initial);
useEffect(() => {
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); };
}, []);
return <ul>{notes.map((n) => <li key={n.id}>{n.title}</li>)}</ul>;
}
Render the first page on the server and hand it in as initial, then let the
channel keep it fresh. The table needs
select baselyra.enable_realtime('public.notes') and a read policy.
Failure modes
| What you see | Why | Fix |
|---|---|---|
process.env.BASELYRA_SERVICE_KEY is undefined in the browser | Working as designed — it has no NEXT_PUBLIC_ prefix | Use it only in server code |
| A server component renders nothing for a signed-in user | It ran as anon; the session lives in localStorage | Pattern B, or move that part to a client component |
401 from a server component after an hour | The mirrored token expired | Redirect to sign-in; the browser client has already refreshed |
| Realtime never connects in the App Router | The channel was created in a server component | It must be inside a 'use client' component's effect |
| CORS errors in the browser only | CORS_ORIGINS does not name your site | Set it in .env on the server and restart |
| Two clients, two sessions, random sign-outs | A second createClient call somewhere | Export one instance and import it |