Cómo crear una app para guardar gastos con Next.js y Supabase
Aprende a construir una aplicación completa de seguimiento de gastos usando Next.js 14, Supabase como backend y base de datos, con autenticación integrada y visualización de datos en tiempo real.
Introducción
¿Alguna vez quisiste tener control total de tus gastos pero no encontraste una app que se adaptara a tus necesidades? En este post vas a aprender a construir tu propia aplicación de seguimiento de gastos usando Next.js como framework de React y Supabase como backend completo (base de datos, autenticación y API).
Al final tendrás una app con:
- Autenticación con email/password
- CRUD de gastos por categoría
- Resumen mensual en tiempo real
- Interfaz limpia y responsiva
Arquitectura de la solución
Antes de escribir código, veamos cómo se conectan las piezas del sistema:
Este flujo muestra cómo Next.js actúa como orquestador entre el usuario y Supabase. El token JWT que emite Supabase Auth garantiza que cada usuario solo vea sus propios gastos gracias a las Row Level Security (RLS) policies de PostgreSQL.
1. Configuración inicial
Crear el proyecto Next.js
npx create-next-app@latest expense-tracker --typescript --tailwind --app
cd expense-tracker
npm install @supabase/supabase-js @supabase/ssr
Crear el proyecto en Supabase
- Ve a supabase.com y crea un nuevo proyecto
- Anota la
Project URLy laanon public keydel panel Settings → API - Crea el archivo
.env.localen la raíz:
NEXT_PUBLIC_SUPABASE_URL=https://tu-proyecto.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=tu-anon-key
2. Diseño de la base de datos
Ejecuta este SQL en el SQL Editor de Supabase para crear las tablas:
-- Tabla de categorías
create table categories (
id uuid default gen_random_uuid() primary key,
name text not null,
icon text not null,
color text not null
);
-- Tabla de gastos
create table expenses (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
category_id uuid references categories(id) not null,
amount numeric(10, 2) not null,
description text,
date date not null default current_date,
created_at timestamptz default now()
);
-- Row Level Security
alter table expenses enable row level security;
create policy "Users can only see their own expenses"
on expenses for all
using (auth.uid() = user_id);
-- Insertar categorías base
insert into categories (name, icon, color) values
('Comida', '🍔', '#f59e0b'),
('Transporte', '🚌', '#3b82f6'),
('Entretenimiento', '🎮', '#8b5cf6'),
('Salud', '💊', '#ef4444'),
('Hogar', '🏠', '#10b981'),
('Otros', '📦', '#6b7280');
3. Configurar el cliente Supabase
Crea el archivo src/lib/supabase.ts:
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
Y para el servidor src/lib/supabase-server.ts:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createServerSupabase() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
}
4. Autenticación
Página de Login src/app/login/page.tsx
'use client'
import { createClient } from '@/lib/supabase'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const router = useRouter()
const supabase = createClient()
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
setError(error.message)
} else {
router.push('/dashboard')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-950">
<form onSubmit={handleLogin} className="bg-slate-900 p-8 rounded-xl w-full max-w-sm space-y-4">
<h1 className="text-2xl font-bold text-white">Iniciar sesión</h1>
{error && <p className="text-red-400 text-sm">{error}</p>}
<input
type="email"
placeholder="Email"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full px-4 py-2 bg-slate-800 text-white rounded-lg"
/>
<input
type="password"
placeholder="Contraseña"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full px-4 py-2 bg-slate-800 text-white rounded-lg"
/>
<button
type="submit"
className="w-full py-2 bg-emerald-500 text-white rounded-lg font-semibold hover:bg-emerald-600"
>
Entrar
</button>
</form>
</div>
)
}
5. Dashboard de gastos
Server Component para el Dashboard src/app/dashboard/page.tsx
import { createServerSupabase } from '@/lib/supabase-server'
import { redirect } from 'next/navigation'
import ExpenseList from '@/components/ExpenseList'
import AddExpenseForm from '@/components/AddExpenseForm'
export default async function DashboardPage() {
const supabase = await createServerSupabase()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: expenses } = await supabase
.from('expenses')
.select('*, categories(name, icon, color)')
.order('date', { ascending: false })
.limit(50)
const total = expenses?.reduce((sum, e) => sum + e.amount, 0) ?? 0
return (
<main className="min-h-screen bg-slate-950 text-white p-6">
<div className="max-w-3xl mx-auto space-y-8">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Mis Gastos</h1>
<div className="text-right">
<p className="text-slate-400 text-sm">Total del mes</p>
<p className="text-2xl font-bold text-emerald-400">
${total.toLocaleString('es-CO')}
</p>
</div>
</div>
<AddExpenseForm />
<ExpenseList expenses={expenses ?? []} />
</div>
</main>
)
}
6. Formulario para agregar gastos
'use client'
import { createClient } from '@/lib/supabase'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function AddExpenseForm() {
const [amount, setAmount] = useState('')
const [description, setDescription] = useState('')
const [categoryId, setCategoryId] = useState('')
const router = useRouter()
const supabase = createClient()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const { data: { user } } = await supabase.auth.getUser()
await supabase.from('expenses').insert({
user_id: user!.id,
amount: parseFloat(amount),
description,
category_id: categoryId,
date: new Date().toISOString().split('T')[0],
})
setAmount('')
setDescription('')
router.refresh()
}
return (
<form onSubmit={handleSubmit} className="bg-slate-900 rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold">Nuevo gasto</h2>
<div className="grid grid-cols-2 gap-4">
<input
type="number"
placeholder="Monto"
value={amount}
onChange={e => setAmount(e.target.value)}
className="px-4 py-2 bg-slate-800 rounded-lg text-white"
required
/>
<input
type="text"
placeholder="Descripción"
value={description}
onChange={e => setDescription(e.target.value)}
className="px-4 py-2 bg-slate-800 rounded-lg text-white"
/>
</div>
<button
type="submit"
className="w-full py-2 bg-emerald-500 rounded-lg font-semibold hover:bg-emerald-600"
>
Guardar gasto
</button>
</form>
)
}
7. Despliegue en Vercel
# Instala la CLI de Vercel
npm i -g vercel
# Despliega
vercel
# Agrega las variables de entorno en el dashboard de Vercel:
# NEXT_PUBLIC_SUPABASE_URL
# NEXT_PUBLIC_SUPABASE_ANON_KEY
No olvides agregar tu dominio de Vercel en la configuración de Authentication → URL Configuration de Supabase.
Conclusión
En este tutorial construiste una app de gastos completa con:
- Supabase Auth para autenticación segura sin configurar un backend propio
- Row Level Security para que cada usuario vea únicamente sus datos
- Next.js App Router con Server Components para fetching de datos eficiente
router.refresh()para actualizar la lista sin recargar la página
El siguiente paso natural es agregar gráficas de gastos por categoría usando Recharts o Chart.js, y notificaciones cuando superes tu presupuesto mensual. ¡Eso lo dejamos para el siguiente post!
¿Te fue útil este post?
← Ver más posts