Construye Apps Web Full-Stack GRATIS con Next.js y Supabase

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

  1. Ve a supabase.com
  2. Crea una cuenta gratuita
  3. Crea un nuevo proyecto
  4. 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

  1. Desarrollo local: Trabaja con la base de datos local
  2. Crear cambios: Modifica el esquema o crea nuevas tablas
  3. Generar migración: supabase migration new nombre_cambio
  4. Aplicar localmente: supabase db push
  5. Probar: Verifica que todo funcione correctamente
  6. 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.