SnippetAuthNext.js
Magic-link sign-in
The form requests a magic link. The callback page exchanges the code (PKCE) and copies the session into the Next.js cookie so Server Components see the user. Enable magic links in the dashboard and allow your redirect URL first.
localhost:3000/login
Sign in
We will email you a link. No password needed.
Check you@company.com for a sign-in link.
The link opens /auth/callback?code=..., which exchanges the code (PKCE) for a session.
Preview · sample dataRendered from the code, no backend
'use client'import { useState } from 'react'import { createClient, localStorageAdapter } from '@potalab/base'
// Magic links use PKCE: the verifier must survive the redirect, so persist it in localStorage.// Create the client lazily (inside handlers / effects): localStorage only exists in the browser.export const pkceClient = () => createClient({ url: process.env.NEXT_PUBLIC_POTALAB_BASE_URL!, key: process.env.NEXT_PUBLIC_POTALAB_BASE_KEY!, auth: { storage: localStorageAdapter() }, })
export function MagicLinkForm() { const [email, setEmail] = useState('') const [status, setStatus] = useState<'idle' | 'sending' | 'sent'>('idle') const [error, setError] = useState<string | null>(null)
async function onSubmit(e: React.FormEvent) { e.preventDefault() setStatus('sending') const { error } = await pkceClient().auth.signInWithMagicLink({ email, redirectTo: `${location.origin}/auth/callback`, // must be on the project's allowed list }) if (error) { setError(error.message) setStatus('idle') } else { setError(null) setStatus('sent') } }
if (status === 'sent') { return ( <div role="status"> <p>Check {email} for a sign-in link.</p> <button onClick={() => setStatus('idle')}>Use a different email</button> </div> ) }
return ( <form onSubmit={onSubmit}> <label> Email <input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@company.com" /> </label> <button type="submit" disabled={status === 'sending'}> {status === 'sending' ? 'Sending...' : 'Send magic link'} </button> {error && <p role="alert">{error}</p>} </form> )}// app/login/page.tsx (Server Component)import { redirect } from 'next/navigation'import { baseServer } from '@/lib/base-server'import { MagicLinkForm } from '@/components/MagicLinkForm'
export default async function LoginPage() { const base = await baseServer() const { data: { user } } = await base.auth.getUser() if (user) redirect('/dashboard') // already signed in
return ( <main> <h1>Sign in</h1> <p>We will email you a link. No password needed.</p> <MagicLinkForm /> </main> )}// app/auth/callback/page.tsx// The link lands here with ?code=. The PKCE verifier is in this browser's localStorage,// so the exchange runs client-side; then the session is copied to the shared cookie// so Server Components (and proxy.ts) can see it.'use client'import { useEffect, useState } from 'react'import { useRouter } from 'next/navigation'import { createBrowserClient } from '@potalab/base/next'import { pkceClient } from '@/components/MagicLinkForm'
export default function AuthCallback() { const router = useRouter() const [error, setError] = useState<string | null>(null)
useEffect(() => { const code = new URLSearchParams(location.search).get('code') if (!code) return setError('Missing code in the link')
pkceClient().auth.exchangeCodeForSession(code).then(async ({ data, error }) => { if (error || !data.session) return setError(error?.message ?? 'Sign-in failed') const { access_token, refresh_token } = data.session const base = createBrowserClient({ url: process.env.NEXT_PUBLIC_POTALAB_BASE_URL!, key: process.env.NEXT_PUBLIC_POTALAB_BASE_KEY! }) await base.auth.setSession({ access_token, ...(refresh_token ? { refresh_token } : {}) }) // writes the cookie router.replace('/dashboard') }) }, [router])
return error ? <p role="alert">{error}</p> : <p>Signing you in...</p>}// 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)), }, })}-- No schema needed for sign-in: users live in the auth schema.-- Enable "Magic link" under Authentication and add-- http://localhost:3000/auth/callback to the allowed redirect URLs.
-- Optional: a profile row per user, readable only by its owner.create table public.profiles ( user_id uuid primary key default auth.uid(), display_name text);alter table public.profiles enable row level security;
create policy profiles_own on public.profiles for all to authenticated using (user_id = (select auth.uid())) with check (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.