O que vamos fazer
- Tela de login com 1 input de email + botão "Enviar link"
- Email automático com link único de 1 hora de validade
- Clique no link → callback troca por sessão → usuário entra direto no app
- Template do email customizado com sua marca (HTML editável no Dashboard)
- Rate limit configurado pra evitar abuso (3 emails/hora por destinatário)
Pré-requisitos
- Conta SuperDB (Free serve, com limite de 30 emails/dia via SMTP padrão)
- Pacote
@superdb/supabase-compatinstalado - SMTP próprio configurado se quiser passar dos 30 emails/dia (recomendado: Resend, SendGrid ou SES)
Dica: em produção, configure SMTP próprio mesmo no plano Pro. O SMTP compartilhado do SuperDB tem reputação variável — se outro cliente fizer spam, seus emails podem cair na lixeira junto. Resend custa $0 até 3k emails/mês.
Passo 1 — Frontend com input de email
Client component simples com estado de "enviando" → "enviado":
'use client'
import { useState } from 'react'
import { createBrowserClient } from '@superdb/supabase-compat'
export function MagicLinkForm() {
const [email, setEmail] = useState('')
const [enviado, setEnviado] = useState(false)
const [erro, setErro] = useState<string | null>(null)
const db = createBrowserClient(
process.env.NEXT_PUBLIC_SUPERDB_URL!,
process.env.NEXT_PUBLIC_SUPERDB_ANON_KEY!
)
async function enviar(e: React.FormEvent) {
e.preventDefault()
setErro(null)
const { error } = await db.auth.signInWithOtp({
email,
options: { emailRedirectTo: `${location.origin}/auth/callback` },
})
if (error) setErro(error.message)
else setEnviado(true)
}
if (enviado) return <p>✓ Cheque seu email — link enviado pra {email}</p>
return (
<form onSubmit={enviar}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
<button type="submit">Enviar link de acesso</button>
{erro && <p style={{color: 'red'}}>{erro}</p>}
</form>
)
}
Passo 2 — Como funciona o envio
Quando o frontend chama signInWithOtp, o SuperDB:
- Cria um
token_hashaleatório no banco (TTL 1h) - Renderiza o template Magic Link com a URL
{{ .ConfirmationURL }} - Envia via SMTP configurado
- Se for primeiro login → cria user em
auth.userscomemail_confirmed_at= null. Confirma só quando ele clicar.
Passo 3 — Callback handler
O link no email aponta pra https://api.superdb.com.br/auth/v1/verify?token_hash=...&type=email&redirect_to=.... O SuperDB valida o hash e redireciona pro seu emailRedirectTo com ?token_hash=...&type=email&next=... ainda na query.
import { NextResponse } from 'next/server'
import { createServerClient } from '@superdb/supabase-compat/ssr'
import { cookies } from 'next/headers'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type') as 'email' | null
const next = searchParams.get('next') ?? '/'
if (token_hash && type) {
const cookieStore = await cookies()
const db = createServerClient(
process.env.NEXT_PUBLIC_SUPERDB_URL!,
process.env.NEXT_PUBLIC_SUPERDB_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (all) => all.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)),
},
}
)
const { error } = await db.auth.verifyOtp({ token_hash, type })
if (!error) return NextResponse.redirect(`${origin}${next}`)
}
return NextResponse.redirect(`${origin}/login?erro=link_invalido`)
}
Passo 4 — Customizar o template do email
No Dashboard: Auth → Email Templates → Magic Link. Você edita HTML com variáveis. Exemplo brand-friendly:
<!doctype html>
<html lang="pt-BR">
<body style="font-family: -apple-system, sans-serif; padding: 40px; background: #fafafa;">
<div style="max-width: 480px; margin: 0 auto; background: white; padding: 32px; border-radius: 12px;">
<h1 style="margin: 0 0 16px; font-size: 22px;">Entrar no Meu App</h1>
<p>Olá, <strong>{{ .Email }}</strong></p>
<p>Clique no botão abaixo pra entrar. O link expira em 1 hora.</p>
<a href="{{ .ConfirmationURL }}"
style="display: inline-block; background: #3ecf8e; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">
Entrar agora
</a>
<p style="font-size: 12px; color: #71717a; margin-top: 24px;">
Se não foi você, ignore este email.
</p>
</div>
</body>
</html>
Variáveis disponíveis: {{ .ConfirmationURL }}, {{ .Email }}, {{ .Token }} (o OTP de 6 dígitos, caso queira mostrar como alternativa), {{ .SiteURL }}.
Cuidado com o pré-load de links: alguns clients de email (Outlook, Gmail "Smart features") visitam URLs do email pra preview de segurança — isso pode consumir o token antes do usuário clicar. Solução: use OTP de 6 dígitos em vez de link, OU adicione uma página intermediária que confirme com clique manual.
Resultado
Usuário entra no app sem nunca digitar senha. Para apps B2B e produtos com churn baixo, isso elimina 80% dos tickets de "esqueci a senha".
Variações
OTP de 6 dígitos em vez de link
Útil em mobile (paste rápido) ou se você se preocupa com pré-load de links. Mesma chamada, fluxo diferente:
// 1. Pedir o código
await db.auth.signInWithOtp({ email, options: { shouldCreateUser: true } })
// 2. Usuário digita o código no input
await db.auth.verifyOtp({
email,
token: '123456',
type: 'email',
})
Magic link com data payload
Capture referrer, plano de signup, código de promoção — o que quiser. O data entra em user_metadata:
await db.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${location.origin}/auth/callback`,
data: {
referrer: document.referrer,
plano_inicial: 'pro_trial',
utm_source: searchParams.get('utm_source'),
},
},
})
TTL customizado do link
Padrão é 1h. Configure no Dashboard em Auth → Settings → Email OTP Expiration. Mínimo 60s, máximo 24h. Para apps de alto risco use 15min; pra apps casuais 12h é ok.
Erros comuns
SMTP rate limit ("email rate limit exceeded")
Você atingiu o limite do SMTP compartilhado (30/dia no Free, 100/dia no Pro). Solução: configure SMTP próprio em Auth → SMTP Settings. Resend, SendGrid, AWS SES — escolha qualquer um.
Redirect URL não está na allowlist
Por segurança o SuperDB só aceita redirects pra URLs cadastradas. Erro típico: redirect_to not allowed. Solução: adicione a URL em Auth → URL Configuration → Redirect URLs. Suporta wildcards (https://meuapp.com/**).
Link expira e usuário vê erro genérico
TTL padrão é 1h. Se o usuário clica depois disso, o verifyOtp retorna 401. Solução: na página de callback, detecte o erro e mostre um botão "reenviar link" — não jogue o usuário pra uma 404.
Observação: magic link consome o token na primeira validação. Se o usuário clicar duas vezes (ou se o cliente de email pré-carregou), a segunda falha. Isso é por design — é o que impede replay.