Construye Apps Web Full-Stack GRATIS con Next.js y Supabase
En este tutorial aprenderás a crear una aplicación web completa utilizando Next.js como framework frontend y Supabase como backend-as-a-service. Ambas tecnologías ofrecen planes gratuitos generosos que son perfectos para proyectos personales y prototipos.
¿Qué es Next.js?
Next.js es un framework de React construido por Vercel que ofrece:
- Renderizado del lado del servidor (SSR)
- Generación de sitios estáticos (SSG)
- Rutas API integradas
- Optimización automática de imágenes
- Soporte para TypeScript
- División de código automática
¿Qué es Supabase?
Supabase es una alternativa de código abierto a Firebase que proporciona:
- Base de datos PostgreSQL
- Autenticación de usuarios
- API REST y GraphQL automáticas
- Suscripciones en tiempo real
- Almacenamiento de archivos
- Edge functions
Configuración del Proyecto
1. Crear proyecto Next.js
npx create-next-app@latest mi-app-supabase
cd mi-app-supabase
2. Instalar dependencias de Supabase
npm install @supabase/supabase-js
3. Configurar variables de entorno
Crea un archivo .env.local:
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
Configuración de Supabase
1. Instalar CLI de Supabase con Scoop
Si aún no tienes la CLI, instálala siguiendo la guía completa en nuestro artículo Cómo Subir las Migraciones Locales a Supabase.
2. Iniciar proyecto local
# Iniciar un nuevo proyecto local
supabase init
# Iniciar los servicios locales
supabase start
Esto iniciará:
- Base de datos PostgreSQL local
- API REST
- Studio (interfaz web)
- Storage
3. Crear proyecto en Supabase Cloud
- Ve a supabase.com
- Crea una cuenta gratuita
- Crea un nuevo proyecto
- Copia la URL y las claves de API
4. Conectar proyecto local con el remoto
# Conectar tu proyecto local con el proyecto en la nube
supabase link --project-ref your-project-ref
5. Crear tabla de usuarios con migraciones
Crea un archivo de migración en supabase/migrations/20240101000000_create_profiles.sql:
CREATE TABLE profiles (
id UUID REFERENCES auth.users ON DELETE CASCADE,
username TEXT UNIQUE,
full_name TEXT,
avatar_url TEXT,
website TEXT,
updated_at TIMESTAMP WITH TIME ZONE,
PRIMARY KEY (id)
);
-- Crear políticas de seguridad
CREATE POLICY "Users can view their own profile" ON profiles
FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update their own profile" ON profiles
FOR UPDATE USING (auth.uid() = id);
CREATE POLICY "Users can insert their own profile" ON profiles
FOR INSERT WITH CHECK (auth.uid() = id);
6. Aplicar migraciones
# Aplicar migraciones localmente
supabase db push
# Sincronizar cambios con el proyecto remoto
supabase db push --remote
7. Generar tipos TypeScript
# Generar tipos automáticamente desde tu esquema
supabase gen types typescript --local > types/supabase.ts
Luego puedes usar estos tipos en tu aplicación:
import { Database } from '../types/supabase'
type Profile = Database['public']['Tables']['profiles']['Row']
8. Comandos útiles de la CLI
# Ver el estado de los servicios locales
supabase status
# Detener los servicios locales
supabase stop
# Ver logs de la base de datos
supabase logs db
# Crear una nueva migración
supabase migration new nombre_de_la_migracion
# Resetear la base de datos local
supabase db reset
# Ver historial de migraciones
supabase migration list
# Hacer dump de la base de datos
supabase db dump --data-only > backup.sql
9. Flujo de trabajo con migraciones
- Desarrollo local: Trabaja con la base de datos local
- Crear cambios: Modifica el esquema o crea nuevas tablas
- Generar migración:
supabase migration new nombre_cambio - Aplicar localmente:
supabase db push - Probar: Verifica que todo funcione correctamente
- Deploy:
supabase db push --remote
10. Ventajas de usar la CLI
- Control de versiones: Todas las cambios están en archivos SQL
- Desarrollo local: No necesitas conexión a internet para desarrollar
- Tipado automático: Genera tipos TypeScript automáticamente
- Rollbacks: Puedes revertir cambios fácilmente
- Colaboración: Facilita el trabajo en equipo
Implementación de la Aplicación
1. Configurar cliente Supabase
Crea lib/supabase.js:
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
2. Crear contexto de autenticación
Crea contexts/AuthContext.js:
import { createContext, useContext, useEffect, useState } from 'react'
import { supabase } from '../lib/supabase'
const AuthContext = createContext()
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// Obtener sesión actual
const session = supabase.auth.getSession()
setUser(session?.user ?? null)
setLoading(false)
// Escuchar cambios de autenticación
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
setUser(session?.user ?? null)
setLoading(false)
}
)
return () => subscription.unsubscribe()
}, [])
const signUp = async (email, password) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
})
return { data, error }
}
const signIn = async (email, password) => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
return { data, error }
}
const signOut = async () => {
const { error } = await supabase.auth.signOut()
if (error) console.error('Error signing out:', error.message)
}
return (
<AuthContext.Provider value={{ user, loading, signUp, signIn, signOut }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => {
return useContext(AuthContext)
}
3. Crear página de login
Crea pages/login.js:
import { useState } from 'react'
import { useRouter } from 'next/router'
import { useAuth } from '../contexts/AuthContext'
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const { signIn } = useAuth()
const router = useRouter()
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
const { error } = await signIn(email, password)
if (error) {
setError(error.message)
} else {
router.push('/dashboard')
}
setLoading(false)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Iniciar Sesión
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<input
type="email"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Correo electrónico"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<input
type="password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Contraseña"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{loading ? 'Cargando...' : 'Iniciar Sesión'}
</button>
</div>
</form>
</div>
</div>
)
}
4. Crear dashboard protegido
Crea pages/dashboard.js:
import { useAuth } from '../contexts/AuthContext'
import { supabase } from '../lib/supabase'
export default function Dashboard() {
const { user, signOut } = useAuth()
const [profile, setProfile] = useState(null)
useEffect(() => {
if (user) {
fetchProfile()
}
}, [user])
const fetchProfile = async () => {
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single()
if (error) {
console.error('Error fetching profile:', error)
} else {
setProfile(data)
}
}
const updateProfile = async (updates) => {
const { data, error } = await supabase
.from('profiles')
.upsert({
id: user.id,
...updates,
updated_at: new Date(),
})
if (error) {
console.error('Error updating profile:', error)
} else {
setProfile(data[0])
}
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<h1 className="text-xl font-semibold">Dashboard</h1>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700">
{user?.email}
</span>
<button
onClick={signOut}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium"
>
Cerrar Sesión
</button>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="border-4 border-dashed border-gray-200 rounded-lg p-6">
<h2 className="text-2xl font-bold mb-4">Bienvenido, {profile?.full_name || user?.email}!</h2>
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium mb-4">Tu Perfil</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Nombre completo</label>
<input
type="text"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
value={profile?.full_name || ''}
onChange={(e) => updateProfile({ full_name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Nombre de usuario</label>
<input
type="text"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
value={profile?.username || ''}
onChange={(e) => updateProfile({ username: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Sitio web</label>
<input
type="url"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
value={profile?.website || ''}
onChange={(e) => updateProfile({ website: e.target.value })}
/>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
)
}
5. Actualizar _app.js
Modifica pages/_app.js:
import '../styles/globals.css'
import { AuthProvider } from '../contexts/AuthContext'
function MyApp({ Component, pageProps }) {
return (
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
)
}
export default MyApp
Funcionalidades Adicionales
1. Subida de archivos
const uploadFile = async (file) => {
const fileExt = file.name.split('.').pop()
const fileName = `${Math.random()}.${fileExt}`
const filePath = `avatars/${fileName}`
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(filePath, file)
if (uploadError) {
throw uploadError
}
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(filePath)
return publicUrl
}
2. Suscripciones en tiempo real
useEffect(() => {
const subscription = supabase
.channel('profiles')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'profiles' },
(payload) => {
console.log('Change received!', payload)
// Actualizar estado local
}
)
.subscribe()
return () => supabase.removeChannel(subscription)
}, [])
Deploy en Vercel
1. Preparar para producción
Asegúrate de tener las variables de entorno configuradas en Vercel:
vercel env add NEXT_PUBLIC_SUPABASE_URL
vercel env add NEXT_PUBLIC_SUPABASE_ANON_KEY
vercel env add SUPABASE_SERVICE_ROLE_KEY
2. Deploy
vercel --prod
Buenas Prácticas
1. Seguridad
- Siempre valida las entradas del usuario
- Usa Row Level Security (RLS) de Supabase
- Nunca expongas claves de servicio en el cliente
2. Optimización
- Usa memoización para componentes pesados
- Implementa carga diferida (lazy loading)
- Optimiza imágenes con Next.js Image
3. Manejo de errores
const handleAsyncOperation = async () => {
try {
const { data, error } = await supabase
.from('table')
.select('*')
if (error) throw error
return data
} catch (error) {
console.error('Operation failed:', error)
// Manejar error apropiadamente
}
}
Conclusión
Next.js y Supabase forman una combinación poderosa para desarrollar aplicaciones web modernas:
- Next.js proporciona una experiencia de desarrollo excelente con renderizado híbrido
- Supabase ofrece un backend completo sin necesidad de configuración
- Ambos tienen planes gratuitos generosos perfectos para empezar
Esta stack te permite construir desde prototipos rápidos hasta aplicaciones production-ready con una curva de aprendizaje moderada y excelente documentación.
Este tutorial cubre los conceptos básicos. Para profundizar, consulta la documentación oficial de Next.js y Supabase.