Baselyra Docs

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>;
}

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 seeWhyFix
process.env.BASELYRA_SERVICE_KEY is undefined in the browserWorking as designed — it has no NEXT_PUBLIC_ prefixUse it only in server code
A server component renders nothing for a signed-in userIt ran as anon; the session lives in localStoragePattern B, or move that part to a client component
401 from a server component after an hourThe mirrored token expiredRedirect to sign-in; the browser client has already refreshed
Realtime never connects in the App RouterThe channel was created in a server componentIt must be inside a 'use client' component's effect
CORS errors in the browser onlyCORS_ORIGINS does not name your siteSet it in .env on the server and restart
Two clients, two sessions, random sign-outsA second createClient call somewhereExport one instance and import it

Edit this page Report a problem

Esc
navigate open Esc close