Autenticación
Better Auth con email/password, Google OAuth, multi-sesion y roles
Autenticación
EmpiezaTuSaaS usa Better Auth como sistema de autenticación: type-safe, framework-agnostic y sin vendor lock-in. Tus datos viven en tu propia base de datos PostgreSQL.
Incluido en el boilerplate:
- Email y password con verificación de email
- Social login con Google OAuth (ver Google OAuth)
- Recuperación de contraseña
- Sesiónes seguras (30 dias, tokens rotativos)
- Multi-sesion (hasta 5 dispositivos)
- Roles de usuario (admin/user) y ban/unban
- Protección CSRF
Setup
Schema de Prisma
El boilerplate incluye el schema completo con los modelos de Better Auth:
model User {
id String @id @default(cuid())
name String
email String @unique
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
role String? @default("user")
banned Boolean? @default(false)
banReason String?
banExpires DateTime?
sessions Session[]
accounts Account[]
subscription Subscription?
@@map("users")
}
model Session {
id String @id @default(cuid())
expiresAt DateTime
token String @unique
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("sessions")
}
model Account {
id String @id @default(cuid())
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("accounts")
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
@@map("verifications")
}Configurar Better Auth (lib/auth/index.ts)
import { betterAuth } from "better-auth";
import { prismaAdapter } from "@better-auth/prisma-adapter";
import { multiSession, admin } from "better-auth/plugins";
import { db } from "@/lib/db";
import { siteConfig } from "@/config/site";
import { sendVerificationEmail, sendPasswordResetEmail } from "@/lib/email";
export const auth = betterAuth({
database: prismaAdapter(db, { provider: "postgresql" }),
baseURL: process.env.BETTER_AUTH_URL || siteConfig.url,
secret: process.env.BETTER_AUTH_SECRET!,
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
await sendPasswordResetEmail({
to: user.email, name: user.name, resetUrl: url,
});
},
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
await sendVerificationEmail({
to: user.email, name: user.name, verificationUrl: url,
});
},
sendOnSignUp: true,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
plugins: [
multiSession({ maximumSessions: 5 }),
admin({ defaultRole: "user", adminRoles: ["admin"] }),
],
session: {
expiresIn: 60 * 60 * 24 * 30, // 30 dias
updateAge: 60 * 60 * 24, // Actualizar cada 24h
},
user: {
additionalFields: {
role: { type: "string", defaultValue: "user" },
},
},
});
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;API Route
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);Cliente de auth (lib/auth/client.ts)
import { createAuthClient } from "better-auth/react";
import { multiSessionClient, adminClient } from "better-auth/client/plugins";
import { siteConfig } from "@/config/site";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || siteConfig.url,
plugins: [multiSessionClient(), adminClient()],
});
export const { signIn, signOut, signUp, useSession, getSession } = authClient;
// Wrapper personalizado para recuperación de contraseña
export async function forgetPassword({
email,
redirectTo,
}: {
email: string;
redirectTo: string;
}) {
const response = await fetch("/api/auth/forget-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, redirectTo }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
return { error: { message: error.message || "Error al enviar el email" } };
}
return { data: await response.json().catch(() => ({})) };
}forgetPassword es un wrapper personalizado que hace fetch al endpoint /api/auth/forget-password. No es un export nativo de Better Auth.
Variables de entorno
NEXT_PUBLIC_APP_URL=http://localhost:3000
DATABASE_URL="postgresql://user:password@localhost:5432/empiezatusaas"
BETTER_AUTH_SECRET=your-secret-key-min-32-chars-here
BETTER_AUTH_URL=http://localhost:3000
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
RESEND_API_KEY=re_...
RESEND_FROM_EMAIL=noreply@tudominio.comGenera un secreto seguro con: openssl rand -base64 32
Ejecutar migraciones
npx prisma migrate dev --name init
npx prisma generateEmail y Password
Estructura de rutas
Las páginas de auth usan el route group (auth):
app/
(auth)/
login/page.tsx -> /login
register/page.tsx -> /register
forgot-password/page.tsx -> /forgot-passwordFormulario de registro
Incluye boton de Google OAuth, validación de contraseña (min. 8 caracteres), toast de confirmación y enlace a terminos legales.
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { SocialButton } from "./social-button";
import { signUp } from "@/lib/auth/client";
import { toast } from "sonner";
export function RegisterForm() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({ name: "", email: "", password: "" });
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
if (formData.password.length < 8) {
toast.error("La contraseña debe tener al menos 8 caracteres");
setIsLoading(false);
return;
}
try {
const result = await signUp.email({
name: formData.name,
email: formData.email,
password: formData.password,
callbackURL: "/dashboard",
});
if (result.error) {
toast.error(result.error.message || "Error al crear la cuenta");
} else {
toast.success("Cuenta creada. Revisa tu email para verificarla.");
router.push("/login");
}
} catch {
toast.error("Error al registrarse. Intentalo de nuevo.");
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Crea tu cuenta</CardTitle>
<CardDescription>Empieza gratis, sin tarjeta de credito</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<SocialButton provider="google" />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">o registrate con</span>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* campos: name, email, password */}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Creando cuenta...</> : "Crear cuenta"}
</Button>
</form>
</CardContent>
<CardFooter className="justify-center">
<p className="text-sm text-muted-foreground">
Ya tienes cuenta? <Link href="/login" className="font-medium text-foreground hover:underline">Inicia sesión</Link>
</p>
</CardFooter>
</Card>
);
}Formulario de login
Mismo patron: boton de Google, formulario de email/password, enlace a "Olvidaste tu contraseña?", redirección a /dashboard tras login exitoso.
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const result = await signIn.email({
email: formData.email,
password: formData.password,
callbackURL: "/dashboard",
});
if (result.error) {
toast.error(result.error.message || "Email o contraseña incorrectos");
} else {
router.push("/dashboard");
}
} catch {
toast.error("Error al iniciar sesión. Intentalo de nuevo.");
} finally {
setIsLoading(false);
}
};Verificación de email
La verificación se envia automáticamente al registrarse gracias a sendOnSignUp: true. El usuario recibe un enlace único por email.
export async function sendVerificationEmail({ to, name, verificationUrl }: SendVerificationEmailProps) {
const { VerifyEmailTemplate } = await import("@/emails/verify-email");
const { createElement } = await import("react");
return getResend().emails.send({
from: FROM_EMAIL,
to,
subject: `Verifica tu email - ${siteConfig.name}`,
react: createElement(VerifyEmailTemplate, { name, verificationUrl }),
});
}Recuperación de contraseña
El componente ForgotPasswordForm usa el wrapper forgetPassword de lib/auth/client.ts:
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const result = await forgetPassword({ email, redirectTo: "/reset-password" });
if (result.error) {
toast.error(result.error.message || "Error al enviar el email");
} else {
setSent(true); // Muestra estado de confirmación
}
} catch {
toast.error("Error al procesar la solicitud. Intentalo de nuevo.");
} finally {
setIsLoading(false);
}
};Proteger rutas
La protección funciona en tres niveles:
| Nivel | Archivo | Función |
|---|---|---|
| Proxy | proxy.ts | Redirige usuarios no autenticados antes de renderizar |
| Paywall | app/[locale]/(dashboard)/layout.tsx | Requiere suscripción activa para ver el dashboard |
| Server Component | Layout o Page | Obtiene la sesión completa y valida permisos |
Proxy (proxy.ts)
En Next.js 16, proxy.ts reemplaza al antiguo middleware.ts y se ejecuta en Node.js runtime. Usa getSessionCookie para verificación optimista sin fetch HTTP.
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
import { isAuthRoute, isProtectedRoute } from "@/lib/auth/access";
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const protectedPath = isProtectedRoute(pathname);
const authPath = isAuthRoute(pathname);
if (!protectedPath && !authPath) return NextResponse.next();
const sessionCookie = getSessionCookie(request);
const isAuthenticated = !!sessionCookie;
if (!isAuthenticated && protectedPath) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
if (isAuthenticated && authPath) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}Paywall (acceso por suscripción)
Por defecto, el dashboard layout verifica si el usuario tiene una suscripción activa. Si no la tiene, muestra la página de pricing en lugar del dashboard:
if (siteConfig.requirePayment && session?.user) {
// Admins se saltan el paywall
if (!isAdminRole(userRole)) {
const subscription = await db.subscription.findUnique({
where: { userId: session.user.id },
select: { status: true },
});
if (subscription?.status !== "active") {
return <PaywallWithPricing />; // Pricing a pantalla completa
}
}
}Controla este comportamiento con requirePayment en config/site.ts:
true(default) → el usuario debe pagar antes de acceder al dashboardfalse→ modelo freemium, dashboard accesible sin plan
Consulta Paywall para más detalles.
Server Component
Para verificar la sesión completa (con datos del usuario), usa auth.api.getSession:
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) redirect("/login");
return <h1>Bienvenido, {session.user.name}</h1>;
}Multi-Session
El plugin multiSession permite ver y gestiónar sesiónes activas en diferentes dispositivos (max. 5).
El componente SessionsManager (components/auth/sessions-manager.tsx) ofrece:
- Lista de sesiónes activas con icono de dispositivo (mobile, tablet, desktop)
- Badge para la sesión actual
- Boton para cerrar sesiónes individuales
- Boton para cerrar todas las demas sesiónes
// Listar sesiónes
const result = await client.multiSession?.listDeviceSessions?.();
// Cerrar una sesión
await client.multiSession?.revokeDeviceSession?.({ sessionId });
// Cerrar todas las demas
await client.multiSession?.revokeOtherDeviceSessions?.();La página está disponible en /settings/sessions dentro del dashboard.
Cuando un usuario alcanza 5 sesiónes activas, la sesión mas antigua se cierra automáticamente al crear una nueva.
Roles y Admin
El plugin admin agrega roles de usuario y funciones de administracion.
Configuración (ya incluida en lib/auth/index.ts):
plugins: [
admin({
defaultRole: "user",
adminRoles: ["admin"],
}),
],Verificar rol en Server Component:
const session = await auth.api.getSession({ headers: await headers() });
if (session?.user.role !== "admin") {
redirect("/dashboard");
}Funcionalidades del plugin admin:
| Funcion | Descripción |
|---|---|
| Roles | Asignar user o admin a usuarios |
| Ban/Unban | Banear usuarios con razon y fecha de expiracion |
| Listar usuarios | Endpoint para listar todos los usuarios |
| Cambiar roles | Endpoint para cambiar el rol de un usuario |
La verificación de admin se hace en el layout del admin (Server Component), no en el proxy. getSessionCookie solo verifica la existencia de la cookie, no su contenido.
Archivos clave
| Archivo | Descripción |
|---|---|
lib/auth/index.ts | Configuración del servidor de Better Auth |
lib/auth/client.ts | Cliente de auth para componentes React |
lib/auth/access.ts | Reglas de protección de rutas (protectedRoutes, authRoutes) |
lib/db.ts | Cliente de Prisma (singleton) |
config/site.ts | requirePayment controla el paywall del dashboard |
app/api/auth/[...all]/route.ts | API route que maneja todas las rutas de auth |
proxy.ts | Protección de rutas + i18n (reemplaza middleware) |
prisma/schema.prisma | Schema de base de datos |
components/auth/login-form.tsx | Formulario de login |
components/auth/register-form.tsx | Formulario de registro |
components/auth/forgot-password-form.tsx | Formulario de recuperación |
components/auth/social-button.tsx | Botón de Google OAuth |
components/auth/sessions-manager.tsx | Gestor de sesiones activas |
lib/email/index.ts | Funciones de envío de email (verificación, reset) |
Endpoints de la API
| Endpoint | Metodo | Descripción |
|---|---|---|
/api/auth/sign-up/email | POST | Registro con email |
/api/auth/sign-in/email | POST | Login con email |
/api/auth/sign-out | POST | Cerrar sesión |
/api/auth/get-session | GET | Obtener sesión actual |
/api/auth/forget-password | POST | Solicitar reset de contraseña |
/api/auth/sign-in/social | POST | Login con Google OAuth |