SnippetData APIAuthRLSNext.js
Todo app with RLS
One table, four policies. The client never filters by user: Postgres does it. A Server Component guards the page, a client component uses useQuery and useMutation.
localhost:3000/todos
My todos
Signed in as ann@acme.com
2 open
RLS: this query has no user filter. Postgres returns only rows where user_id = auth.uid().
Preview · sample dataRendered from the code, no backend
'use client'import { useEffect, useState } from 'react'import { createBrowserClient } from '@potalab/base/next'import { useMutation, useQuery } from '@potalab/base/react'
const base = () => createBrowserClient({ url: process.env.NEXT_PUBLIC_POTALAB_BASE_URL!, key: process.env.NEXT_PUBLIC_POTALAB_BASE_KEY! })
// createBrowserClient only runs in the browser, so render after mountexport function Todos() { const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) return mounted ? <TodoList /> : <p>Loading...</p>}
function TodoList() { const client = base() const [title, setTitle] = useState('')
// No user filter: RLS only returns this user's rows const todos = useQuery( client.from('todos').select('id,title,done').order('created_at', { ascending: false }), ) // Writes refetch the query above automatically const add = useMutation((t: string) => client.from('todos').insert({ title: t })) const toggle = useMutation((v: { id: number; done: boolean }) => client.from('todos').update({ done: v.done }).eq('id', v.id), ) const remove = useMutation((id: number) => client.from('todos').delete().eq('id', id))
if (todos.isPending) return <p>Loading...</p> if (todos.isError) return <p role="alert">{todos.error.message}</p>
return ( <div> <form onSubmit={(e) => { e.preventDefault() add.mutate(title) setTitle('') }} > <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="What needs doing?" /> <button type="submit" disabled={add.isPending || !title.trim()}>Add</button> </form>
<ul> {todos.data.map((t) => ( <li key={t.id}> <label> <input type="checkbox" checked={t.done} onChange={() => toggle.mutate({ id: t.id, done: !t.done })} /> <span className={t.done ? 'line-through' : ''}>{t.title}</span> </label> <button onClick={() => remove.mutate(t.id)}>Delete</button> </li> ))} </ul> <p>{todos.data.filter((t) => !t.done).length} open</p> </div> )}// app/todos/page.tsx (Server Component)import { redirect } from 'next/navigation'import { baseServer } from '@/lib/base-server'import { Todos } from './Todos'
export default async function TodosPage() { const base = await baseServer() // getUser() validates the token with PotaLab Base (do not trust the cookie alone) const { data: { user } } = await base.auth.getUser() if (!user) redirect('/login')
return ( <main> <h1>My todos</h1> <p>Signed in as {user.email}</p> <Todos /> </main> )}// lib/base-server.ts// One client PER REQUEST: it holds the signed-in user's session.import { cookies } from 'next/headers'import { createServerClient } from '@potalab/base/next'
export async function baseServer() { const store = await cookies() return createServerClient({ url: process.env.NEXT_PUBLIC_POTALAB_BASE_URL!, key: process.env.NEXT_PUBLIC_POTALAB_BASE_KEY!, // lb_pub_... cookies: { getAll: () => store.getAll(), setAll: (list) => list.forEach((c) => store.set(c.name, c.value, c.options)), }, })}create table public.todos ( id bigint generated always as identity primary key, user_id uuid not null default auth.uid(), title text not null, done boolean not null default false, created_at timestamptz not null default now());create index on public.todos (user_id);
alter table public.todos enable row level security;
create policy todos_select on public.todos for select to authenticated using (user_id = (select auth.uid()));create policy todos_insert on public.todos for insert to authenticated with check (user_id = (select auth.uid()));create policy todos_update on public.todos for update to authenticated using (user_id = (select auth.uid())) with check (user_id = (select auth.uid()));create policy todos_delete on public.todos for delete to authenticated using (user_id = (select auth.uid()));Snippet for illustration, written for the Next.js App Router with @potalab/base/next,@potalab/base/react and @potalab/base. Set NEXT_PUBLIC_POTALAB_BASE_URL andNEXT_PUBLIC_POTALAB_BASE_KEY, adapt names and redirect URLs, and review policies before production.
Next: Magic-link sign-in →