Todos los posts
Next.jsSupabaseTypeScriptPostgreSQLReact

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.

JT
Jeffrey Torres Bello·2026-03-06·12 min de lectura

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:

Rendering diagram…

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

  1. Ve a supabase.com y crea un nuevo proyecto
  2. Anota la Project URL y la anon public key del panel Settings → API
  3. Crea el archivo .env.local en 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