EmpiezaTuSaaS
Funcionalidades

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:

prisma/schema.prisma
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)

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

app/api/auth/[...all]/route.ts
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)

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

.env
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.com

Genera un secreto seguro con: openssl rand -base64 32

Ejecutar migraciones

npx prisma migrate dev --name init
npx prisma generate

Email 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-password

Formulario de registro

Incluye boton de Google OAuth, validación de contraseña (min. 8 caracteres), toast de confirmación y enlace a terminos legales.

components/auth/register-form.tsx
"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.

components/auth/login-form.tsx (extracto)
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.

lib/email/index.ts (extracto)
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:

components/auth/forgot-password-form.tsx (extracto)
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:

NivelArchivoFunción
Proxyproxy.tsRedirige usuarios no autenticados antes de renderizar
Paywallapp/[locale]/(dashboard)/layout.tsxRequiere suscripción activa para ver el dashboard
Server ComponentLayout o PageObtiene 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.

proxy.ts
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:

app/[locale]/(dashboard)/layout.tsx (extracto)
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 dashboard
  • false → 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:

app/[locale]/(dashboard)/dashboard/page.tsx
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
components/auth/sessions-manager.tsx (extracto)
// 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:

FuncionDescripción
RolesAsignar user o admin a usuarios
Ban/UnbanBanear usuarios con razon y fecha de expiracion
Listar usuariosEndpoint para listar todos los usuarios
Cambiar rolesEndpoint 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

ArchivoDescripción
lib/auth/index.tsConfiguración del servidor de Better Auth
lib/auth/client.tsCliente de auth para componentes React
lib/auth/access.tsReglas de protección de rutas (protectedRoutes, authRoutes)
lib/db.tsCliente de Prisma (singleton)
config/site.tsrequirePayment controla el paywall del dashboard
app/api/auth/[...all]/route.tsAPI route que maneja todas las rutas de auth
proxy.tsProtección de rutas + i18n (reemplaza middleware)
prisma/schema.prismaSchema de base de datos
components/auth/login-form.tsxFormulario de login
components/auth/register-form.tsxFormulario de registro
components/auth/forgot-password-form.tsxFormulario de recuperación
components/auth/social-button.tsxBotón de Google OAuth
components/auth/sessions-manager.tsxGestor de sesiones activas
lib/email/index.tsFunciones de envío de email (verificación, reset)

Endpoints de la API

EndpointMetodoDescripción
/api/auth/sign-up/emailPOSTRegistro con email
/api/auth/sign-in/emailPOSTLogin con email
/api/auth/sign-outPOSTCerrar sesión
/api/auth/get-sessionGETObtener sesión actual
/api/auth/forget-passwordPOSTSolicitar reset de contraseña
/api/auth/sign-in/socialPOSTLogin con Google OAuth

On this page