Tutorial técnico14 mayo 2026 · 18 min lectura

Dashboard ERP en tiempo real con Next.js 14 y Supabase PostgreSQL

Aprende a construir un dashboard empresarial que muestra ventas, inventario y pedidos actualizándose en tiempo real. Usamos Next.js 14 App Router, Supabase Realtime (suscripciones PostgreSQL) y TypeScript — el mismo stack con el que construimos los ERPs a medida de Alcanet.

Stack del proyecto

  • Framework: Next.js 14 (App Router)
  • DB + Realtime: Supabase PostgreSQL
  • Auth: Supabase Auth (JWT)
  • UI: Tailwind CSS
  • Lenguaje: TypeScript
  • Deploy: Vercel o Netlify

Por qué Next.js + Supabase para un ERP colombiano

Las PYMEs colombianas tienen un desafío específico: necesitan software robusto a precio accesible. SAP B1 cuesta más de $15.000 USD en licencias. Odoo requiere un equipo técnico interno. La alternativa es construir el ERP con un stack moderno que permita entregar en semanas, no en años.

Next.js 14 con App Router permite mezclar Server Components (datos seguros, sin exponer claves) con Client Components (tiempo real, interactividad). Supabase agrega Realtime sobre PostgreSQL estándar con Row Level Security — el mismo motor de base de datos que usan empresas Fortune 500.

Estructura del proyecto

app/
  dashboard/
    layout.tsx          ← Server Component (verifica sesión Supabase)
    page.tsx            ← Resumen general (KPIs del día)
    ventas/
      page.tsx          ← Tabla de ventas + gráfico
    inventario/
      page.tsx          ← Stock en tiempo real
    pedidos/
      page.tsx          ← Pipeline de pedidos

components/
  dashboard/
    KPICard.tsx         ← Server Component (dato estático del build)
    SalesTable.tsx      ← Client Component (suscripción Realtime)
    StockAlert.tsx      ← Client Component (notificación en vivo)

lib/
  supabase/
    server.ts           ← Cliente Supabase para Server Components
    client.ts           ← Cliente Supabase para Client Components

Configurar los clientes Supabase (server vs client)

Next.js 14 distingue entre código que corre en el servidor (seguro, puede usar claves secretas) y código que corre en el browser (público). Supabase necesita dos instancias separadas:

// lib/supabase/server.ts — usa SERVICE_ROLE_KEY (solo servidor)
import { createClient } from "@supabase/supabase-js";

export function createServerClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,  // ← NUNCA exponer al browser
  );
}
// lib/supabase/client.ts — usa ANON_KEY (seguro en browser con RLS)
"use client";
import { createBrowserClient } from "@supabase/ssr";

export function createBrowserSupabase() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  );
}

Server Component: KPIs del día (datos en build o ISR)

Los KPIs del dashboard (ventas del día, número de pedidos, stock crítico) se pueden cargar como Server Component con revalidación cada 60 segundos. Esto significa que el HTML ya llega pre-renderizado al usuario — cero tiempo de espera para los datos principales:

// app/dashboard/page.tsx
import { createServerClient } from "@/lib/supabase/server";

export const revalidate = 60; // ISR: revalida cada 60 segundos

export default async function DashboardPage() {
  const supabase = createServerClient();

  const hoy = new Date().toISOString().split("T")[0];

  const [{ data: ventasHoy }, { data: pedidosPendientes }, { data: stockCritico }] =
    await Promise.all([
      supabase.rpc("total_ventas_hoy", { fecha: hoy }),
      supabase.from("pedidos").select("id", { count: "exact" }).eq("estado", "pendiente"),
      supabase.from("productos").select("id", { count: "exact" }).lt("stock", 10),
    ]);

  return (
    <div className="grid grid-cols-3 gap-6 p-6">
      <KPICard titulo="Ventas hoy" valor={ventasHoy ?? 0} prefix="$" />
      <KPICard titulo="Pedidos pendientes" valor={pedidosPendientes?.length ?? 0} />
      <KPICard titulo="Stock crítico" valor={stockCritico?.length ?? 0} alert />
    </div>
  );
}

Client Component: tabla de pedidos en tiempo real

Para los datos que necesitan actualizarse sin recargar la página (nuevos pedidos, cambios de estado), usamos Supabase Realtime con el método channel. Esto abre una conexión WebSocket a PostgreSQL y recibe cambios en milisegundos:

"use client";
// components/dashboard/PedidosRealtime.tsx
import { useEffect, useState } from "react";
import { createBrowserSupabase } from "@/lib/supabase/client";

type Pedido = { id: string; cliente: string; total: number; estado: string };

export default function PedidosRealtime({ initial }: { initial: Pedido[] }) {
  const [pedidos, setPedidos] = useState<Pedido[]>(initial);

  useEffect(() => {
    const supabase = createBrowserSupabase();

    const canal = supabase
      .channel("pedidos-live")
      .on(
        "postgres_changes",
        { event: "*", schema: "public", table: "pedidos" },
        (payload) => {
          if (payload.eventType === "INSERT") {
            setPedidos((prev) => [payload.new as Pedido, ...prev]);
          }
          if (payload.eventType === "UPDATE") {
            setPedidos((prev) =>
              prev.map((p) => (p.id === payload.new.id ? (payload.new as Pedido) : p))
            );
          }
        }
      )
      .subscribe();

    return () => { supabase.removeChannel(canal); };
  }, []);

  return (
    <table>
      <tbody>
        {pedidos.map((p) => (
          <tr key={p.id}>
            <td>{p.cliente}</td>
            <td>${p.total.toLocaleString("es-CO")}</td>
            <td>{p.estado}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

El patrón clave: el Server Component padre hace el fetch inicial (rápido, seguro, pre-renderizado), y pasa los datos como initial al Client Component que solo se "activa" para las actualizaciones posteriores. El usuario ve datos inmediatamente, sin spinner.

Row Level Security: cada empresa solo ve sus datos

Si el ERP sirve a múltiples empresas (multi-tenant), la seguridad es crítica. Supabase RLS permite definir políticas directamente en PostgreSQL — ningún código de aplicación puede bypassearlas:

-- Activar RLS en la tabla pedidos
ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;

-- Solo el usuario autenticado ve los pedidos de su empresa
CREATE POLICY "empresa_propia" ON pedidos
  FOR ALL
  USING (
    empresa_id = (
      SELECT empresa_id FROM usuarios
      WHERE id = auth.uid()
    )
  );

¿Tu empresa necesita un ERP a medida?

Construimos ERPs con este mismo stack en 8–16 semanas. Desde $3.500 USD. Adaptado a la DIAN y tus procesos.

💬 Cotizar ERP a medida
Luis Alcalá — Fundador de Alcanet, agencia digital en Medellín

Escrito por

Luis Alcalá

Fundador & CEO — Alcanet

Desarrollador de software especializado en Next.js, automatización con n8n e IA aplicada a negocios. 6+ años construyendo webs, ERP y sistemas para PYMEs en Colombia y Latinoamérica.