Introducción
La primera de las aproximaciones que realicé a una aplicación desarrollada con asistencia de IA fue una idea que llevaba años rondándome por la cabeza. Se trataba de realizar una aplicación de lista de la compra compartida, que permitiera a los usuarios de la lista actualizar la información de la misma en tiempo real, de tal manera que los cambios pudieran ser vistos por la persona que estaba realizando la compra sobre la marcha.
Había tenido experiencia con aplicaciones similares en el marketplace de Android, pero, o bien su diseño e implementación eran pobres y muy descuidados desde el punto de vista de la seguridad de los datos, o bien eran engendros plagados de publicidad. Por ello, me decidí a empezar por este proyecto. Mi primera idea había sido realizar una aplicación nativa para Android pero, en realidad, el desarrollo de una aplicación web encajaba perfectamente con mi objetivo, y permitía, además, la utilización en otro tipo de entornos.
El primer prototipo de la aplicación fue completamente online, realizando tanto el desarrollo como el despliegue en Vercel. Si bien los resultados fueron enormemente positivos, una de mis principales obsesiones era no tener una dependencia con un entorno cloud (y menos aún de uno de acceso de tipo gratuito, que en cualquier momento puede desaparecer, o pasar a ser de pago), sobre todo porque en mi casa cuento con un entorno de virtualización excelente, merced al despliegue del servidor que realicé hace cosa de un año.
Por ello, tras validar que el concepto era viable, realicé una evolución en el proyecto para que el despliegue se realizara en un servidor de backend con Node.js.

Como parte del proceso de desarrollo, v0 permite realizar descripciones técnicas e integraciones de la aplicación en repositorios como Github, lo que da una potencia espectacular a la hora de documentar y detallar las características de la aplicación desarrollada, aunque en el proceso no se escriba ni una línea de código por parte del desarrollador. Ojo, esto no implica que el proceso se pueda hacer sin ningún tipo de conocimiento, más bien al contrario: los mejores desarrollos se obtienen cuando conoces las implicaciones técnicas de lo que estás pidiendo, puedes entender los resultados, y guiar a la IA para que el resultado tenga una calidad mínima, tanto en arquitectura, desarrollo, y aspectos de seguridad.
Con estos puntos como partida, la aplicación desarrollada, una Lista de Compras Colaborativa e Interactiva, se desarrolló como una aplicación full-stack que integra React, Next.js, TypeScript, PostgreSQL y técnicas avanzadas de estado compartido en tiempo real.
Antes de continuar, quiero incluir algunos puntos clave en el diseño de la aplicación:
- Dado que la aplicación está pensada para ser utilizada en mi entorno familiar, contempla que sólo existe una lista única de la compra, compartida por los usuarios. No contempla un sistema avanzado de listas múltiples, ya que ello excedería el objetivo de este desarrollo, pero no sería especialmente complicado extenderla en este sentido.
- Dado que la aplicación está publicada en una intranet a la que sólo se puede acceder por VPN (incluyendo en ello a los móviles), no he incluido autenticación de usuarios ni despliegue en HTTPS, en aras de la sencillez. Pero dado que existe un sistema de usuarios y la aplicación es susceptible de ser publicada tras un NGINX, la capa de securización sería bien sencilla de aplicar.
- El despliegue en el Proxmox se ha realizado mediante un contenedor. Simplemente porque tenía ganas de probarlo.
- Quedé gratamente sorprendido de cómo la IA, a partir de unas indicaciones básicas, iba más allá a la hora de añadir aspectos de valor en la implementación propuesta. Sirva como ejemplo la categorización de ítems de la lista de la compra: yo no había pedido nada al respecto, y fue algo que la IA contempló ya desde un primer momento. Luego tuve que refinar la implementación inicial, indicando que estas categorías podían ser editables, añadiendo iconos, etc… Pero lo que fue el proceso inicial de incluir esta característica fue algo completamente llevado a cabo por la IA.
Y tras estas consideraciones, vayamos con la descripción de la aplicación.
Tabla de Contenidos
- Stack Tecnológico
- Arquitectura General del Sistema
- Capa de Presentación (Frontend)
- Capa de Lógica de Negocio (Server Actions)
- Capa de Persistencia (Base de Datos)
- Patrones de Diseño y Mejores Prácticas
- Manejo de Estados y Sincronización en Tiempo Real
- Seguridad y Optimización
- Estrategia de Despliegue
- Lecciones Aprendidas y Conclusiones
1. Stack Tecnológico
Frontend
- React 18: Biblioteca principal para construcción de interfaces de usuario
- Next.js 14: Framework full-stack que proporciona renderizado del lado del servidor (SSR), Server Actions y API routes
- TypeScript: Lenguaje tipado para mayor seguridad en tiempo de compilación
- Tailwind CSS: Framework de CSS utilitario para estilos rápidos y consistentes
- shadcn/ui: Componentes accesibles pre-construidos basados en Radix UI
- Lucide React: Biblioteca de iconos SVG moderna
Backend
- Next.js Server Actions: Funciones del lado del servidor sin necesidad de crear rutas API explícitas
- PostgreSQL: Base de datos relacional robusta para persistencia de datos
- Neon Database: Servicio PostgreSQL serverless con conexiones pooling
DevOps y Despliegue
- PM2: Process Manager para Node.js que mantiene la aplicación en ejecución
- LXC/Linux Containers: Contenedores ligeros para aislamiento de entornos
- Bash Scripts: Automatización completa del despliegue
- PostgreSQL 14+: Base de datos relacional instalada localmente
2. Arquitectura General del Sistema
La aplicación sigue una arquitectura de tres capas clásica optimizada para Next.js 14:
┌─────────────────────────────────────────────────┐
│ CAPA DE PRESENTACIÓN (Frontend) │
│ React Components + Tailwind CSS + shadcn/ui │
│ app/page.tsx + components/ │
└────────────────┬────────────────────────────────┘
│ HTTP/JSON
┌────────────────▼────────────────────────────────┐
│ CAPA DE LÓGICA DE NEGOCIO (Next.js Server) │
│ Server Actions (lib/actions.ts) │
│ Validación, procesamiento de datos │
└────────────────┬────────────────────────────────┘
│ SQL Queries
┌────────────────▼────────────────────────────────┐
│ CAPA DE PERSISTENCIA (PostgreSQL Database) │
│ Tables: grocery_items, categories │
│ Indexes, Triggers, Funciones │
└─────────────────────────────────────────────────┘
Ventajas de esta Arquitectura
- Separación de responsabilidades: Cada capa tiene un propósito claramente definido
- Reusabilidad: Las Server Actions pueden invocarse desde múltiples componentes
- Testabilidad: Facilita testing unitario de cada capa
- Escalabilidad: Permite escalar cada capa independientemente
- Seguridad: Lógica sensible ejecutada en el servidor
3. Capa de Presentación (Frontend)
Estructura de Componentes
La interfaz se construye con una jerarquía clara de componentes:
app/page.tsx (Componente Principal)
├── Header (Logo, estado de conexión, usuarios en línea)
├── AddItemForm (Formulario para agregar productos)
├── ActiveItemsList (Lista de productos pendientes)
│ └── ItemCard (Componente individual)
│ ├── CheckButton
│ ├── ItemInfo
│ └── ActionButtons (Editar, Eliminar)
├── CompletedItemsList (Lista de productos completados)
│ └── CompletedItemCard
└── EmptyState (Cuando no hay productos)
Gestión de Estado Local
El componente principal utiliza hooks de React para gestionar estado:
const [items, setItems] = useState<GroceryItem[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [editingItem, setEditingItem] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const [isConnected, setIsConnected] = useState(true)
const [lastUpdated, setLastUpdated] = useState(new Date())
Sincronización con el Servidor
Se implementa un mecanismo de polling periódico cada 5 segundos:
useEffect(() => {
loadData()
const interval = setInterval(loadData, 5000)
return () => clearInterval(interval)
}, [])
const loadData = async () => {
try {
setIsLoading(true)
const data = await getGroceryItems()
setItems(data.items)
setCategories(data.categories)
setLastUpdated(new Date(data.lastUpdated))
setIsConnected(true)
} catch (error) {
setIsConnected(false)
} finally {
setIsLoading(false)
}
}
Componentes de UI Reutilizables
La aplicación utiliza componentes de shadcn/ui como base:
- Button: Variantes: default, destructive, outline, secondary, ghost
- Card: Contenedor principal para secciones
- Input: Campos de entrada de texto y números
- Badge: Etiquetas para categorías y cantidades
- Icons (Lucide): Plus, Trash2, Edit2, Check, RotateCcw, etc.
4. Capa de Lógica de Negocio (Server Actions)
¿Qué son las Server Actions?
Las Server Actions son funciones TypeScript que se ejecutan en el servidor, permitiendo una comunicación cliente-servidor fluida sin crear rutas API explícitas. Se declaran con la directiva "use server".
Operaciones CRUD Implementadas
1. Obtener Productos (getGroceryItems)
export async function getGroceryItems(): Promise {
try {
if (sql && (await testConnection())) {
const items = await sql`
SELECT id, name, category, completed, quantity,
edited_by, edited_at, created_at, updated_at
FROM grocery_items
ORDER BY created_at DESC
`
return { items, categories, lastUpdated: Date.now() }
}
} catch (error) {
return { items: memoryItems, categories: memoryCategories }
}
}
2. Agregar Producto (addGroceryItem)
export async function addGroceryItem(
item: Omit<GroceryItem, "id" | "created_at" | "updated_at">
): Promise {
const newItem = {
...item,
id: generateId(),
created_at: Date.now(),
updated_at: Date.now()
}
await sql`
INSERT INTO grocery_items (...)
VALUES (${newItem.id}, ${newItem.name}, ...)
`
return await getGroceryItems()
}
3. Actualizar Producto (updateGroceryItem)
export async function updateGroceryItem(
id: string,
updates: Partial,
editedBy?: string
): Promise {
await sql`
UPDATE grocery_items
SET ${updateData}, updated_at = ${Date.now()}
WHERE id = ${id}
`
return await getGroceryItems()
}
4. Eliminar Producto (deleteGroceryItem)
export async function deleteGroceryItem(id: string): Promise {
await sql`DELETE FROM grocery_items WHERE id = ${id}`
return await getGroceryItems()
}
5. Operaciones Masivas
export async function clearCompletedItems(): Promise {
await sql`DELETE FROM grocery_items WHERE completed = true`
return await getGroceryItems()
}
export async function reactivateCompletedItems(): Promise {
await sql`
UPDATE grocery_items
SET completed = false, updated_at = ${Date.now()}
WHERE completed = true
`
return await getGroceryItems()
}
Manejo de Errores y Fallback
La aplicación implementa un patrón de fallback en memoria:
// En memoria como respaldo
let memoryItems: GroceryItem[] = []
const memoryCategories: Category[] = [...]
// Intenta usar DB, fallback a memoria si falla
if (sql && (await testConnection())) {
// Usar PostgreSQL
} else {
// Usar almacenamiento en memoria
return { items: memoryItems, categories: memoryCategories }
}
5. Capa de Persistencia (Base de Datos)
Esquema de la Base de Datos
Tabla: grocery_items
CREATE TABLE grocery_items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'Otros',
completed BOOLEAN NOT NULL DEFAULT FALSE,
quantity TEXT,
edited_by TEXT,
edited_at BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)
CREATE INDEX idx_grocery_items_completed ON grocery_items(completed)
CREATE INDEX idx_grocery_items_category ON grocery_items(category)
CREATE INDEX idx_grocery_items_updated_at ON grocery_items(updated_at)
Tabla: categories
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
icon VARCHAR(10) DEFAULT '📦',
color VARCHAR(7) DEFAULT '#6B7280',
created_at BIGINT,
updated_at BIGINT
)
INSERT INTO categories (name, icon, color) VALUES
('Frutas y Verduras', '🥕', '#10B981'),
('Lácteos', '🥛', '#3B82F6'),
('Carne y Pescado', '🥩', '#EF4444'),
('Panadería', '🍞', '#F59E0B'),
('Despensa', '🥫', '#8B5CF6'),
('Otros', '📦', '#6B7280')
Optimizaciones de Base de Datos
1. Índices para Rendimiento
idx_grocery_items_completed– Acelera consultas de items completados vs. pendientesidx_grocery_items_category– Optimiza filtros por categoríaidx_grocery_items_updated_at– Facilita obtener items más recientes
2. Triggers para Auditoría
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $
BEGIN
NEW.updated_at = EXTRACT(EPOCH FROM NOW()) * 1000;
RETURN NEW;
END;
$ language 'plpgsql';
CREATE TRIGGER update_grocery_items_updated_at
BEFORE UPDATE ON grocery_items
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Este trigger automáticamente actualiza updated_at cada vez que se modifica un registro, sin necesidad de gestión manual.
Seguridad: Row Level Security (RLS)
Para aplicaciones con múltiples usuarios, se pueden implementar políticas RLS:
ALTER TABLE grocery_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can only see their own items" ON grocery_items
FOR SELECT
USING (auth.uid() = user_id OR is_public)
6. Patrones de Diseño y Mejores Prácticas
Patrón: Servidor de Acciones (Server Actions Pattern)
Las Server Actions de Next.js 14 representan la evolución del patrón MVC tradicional:
┌──────────────┐
│ React Form │ handleSubmit()
└──────┬───────┘
│ await serverAction(data)
┌──────▼──────────────────┐
│ Server Action (Node.js)│ Validación, Lógica
└──────┬──────────────────┘
│ SQL Query
┌──────▼───────────────┐
│ PostgreSQL Database │
└──────────────────────┘
Identificación Única de Registros
Se utiliza una combinación de timestamp + random para generar IDs únicos:
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
// Ejemplo: 1234567890123-a1b2c3d4e
Serialización de Timestamps
Los timestamps se almacenan como milisegundos desde la época Unix:
const now = Date.now() // milisegundos
// Almacenado como: EXTRACT(EPOCH FROM NOW()) * 1000
// Al recuperar:
new Date(item.created_at).toLocaleTimeString()
Validación de Entrada
if (!newItemName.trim()) return // Evita items vacíos
if (!editName.trim()) return // Valida antes de actualizar
// Trimming de whitespace
name: newItemName.trim()
7. Manejo de Estados y Sincronización en Tiempo Real
Polling vs. WebSockets
La aplicación utiliza polling (solicitud periódica) cada 5 segundos:
| Aspecto | Polling (Actual) | WebSockets |
|---|---|---|
| Latencia | Hasta 5 segundos | Instantáneo (<100ms) |
| Consumo de Ancho de Banda | Medio (cada 5s) | Alto (conexión persistente) |
| Escalabilidad | Alta (HTTP stateless) | Media (conexiones persistentes) |
| Complejidad | Baja | Alta |
| Mejor para | Aplicaciones CRUD clásicas | Chat, Games, Colaboración en tiempo real |
Indicadores de Conectividad
La UI proporciona retroalimentación visual del estado:
{/* Indicador de conexión */}
{/* Indicador de carga */}
{isLoading &&
}
Estrategia de Reintento
Para operaciones que fallan, se implementan reintentos automáticos:
async function testConnection() {
if (!sql) return false
try {
await sql`SELECT 1 as test`
return true
} catch (error) {
console.error("Connection test failed:", error)
return false
}
}
// Utilizado en cada operación
if (sql && (await testConnection())) {
// Usar DB
} else {
// Fallback
}
8. Seguridad y Optimización
Seguridad en Server Actions
1. Ejecución en el Servidor
- Las credenciales de base de datos NUNCA se exponen al cliente
- La lógica sensible se ejecuta en el servidor
- Las consultas SQL se parametrizan automáticamente
2. Validación en Servidor
// El servidor SIEMPRE valida
if (!newItemName.trim()) {
throw new Error("Item name cannot be empty")
}
// No confiar en validación del cliente
3. Protección contra SQL Injection
La librería @neondatabase/serverless proporciona parametrización automática:
// SEGURO: Parametrizado automáticamente
await sql`SELECT * FROM grocery_items WHERE name = ${userInput}`
// INSEGURO: Nunca hacer esto
await sql(`SELECT * FROM grocery_items WHERE name = '${userInput}'`)
Optimización de Rendimiento
1. Caching de Componentes
// Componente cliente
"use client"
// Componente servidor (cached por defecto)
// No llevar la directiva "use client"
2. Lazy Loading de Componentes
import dynamic from 'next/dynamic'
const AdminPanel = dynamic(() => import('@/components/admin-panel'), {
loading: () =>
Cargando…
,
})
3. Queries Optimizadas
-- ÓPTIMO: Solo columnas necesarias
SELECT id, name, category, completed FROM grocery_items
-- SUB-ÓPTIMO: Todas las columnas
SELECT * FROM grocery_items
4. Paginación para Listas Grandes
Para aplicaciones con miles de items, implementar paginación:
export async function getGroceryItems(page = 1, limit = 50) {
const offset = (page - 1) * limit
const items = await sql`
SELECT * FROM grocery_items
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
`
return items
}
Monitoreo y Logging
La aplicación registra todas las operaciones para debugging:
console.log("📊 Fetching items from database...")
console.log(`✅ Fetched ${items.length} items from database`)
console.log("❌ Error fetching items:", error)
9. Estrategia de Despliegue
Pipeline de Despliegue Automatizado
La aplicación se despliega a través de una serie de scripts bash en LXC containers:
01-create-lxc-container.sh → Crea el contenedor Linux
↓
02-setup-environment.sh → Instala dependencias
↓
03-deploy-application.sh → Despliega la app con PM2
↓
04-verify-installation.sh → Verifica que todo funciona
Componentes del Despliegue
1. LXC Container
# Crear container Ubuntu 20.04
lxc create ubuntu:20.04 grocery-app
lxc start grocery-app
lxc exec grocery-app -- bash
2. Base de Datos PostgreSQL
# Instalar PostgreSQL en el container
apt-get install postgresql postgresql-contrib
# Crear usuario y base de datos
sudo -u postgres psql << 'EOF'
CREATE USER grocery WITH PASSWORD '********'
CREATE DATABASE grocery_list OWNER grocery
GRANT ALL PRIVILEGES ON DATABASE grocery_list TO grocery
EOF
3. Aplicación Next.js
# Instalar dependencias
npm install
# Compilar
npm run build
# Iniciar con PM2
pm2 start "npm start" --name "grocery-app"
pm2 startup
pm2 save
Environment Variables
DATABASE_URL=postgresql://grocery:*******@localhost:5432/grocery_list
NEXT_PUBLIC_APP_URL=http://localhost:3000
NODE_ENV=production
10. Lecciones Aprendidas y Conclusiones
Ventajas de esta Arquitectura
- Desarrollo Rápido: Server Actions eliminan la necesidad de crear API routes
- TypeScript end-to-end: Tipado en cliente y servidor
- Renderizado Eficiente: Next.js maneja SSR automáticamente
- Seguridad Mejorada: Secrets del servidor nunca se exponen
- Experiencia de Usuario Fluida: Interactividad inmediata con polling
- Escalabilidad: Fácil de expandir con nuevas características
Mejoras Futuras
1. WebSockets en Tiempo Real
// Migrar a Socket.IO o ws para colaboración en tiempo real
io.on('connection', (socket) => {
socket.on('item-added', (item) => {
io.emit('items-updated', getAllItems())
})
})
2. Autenticación de Usuarios
// Agregar NextAuth.js o Clerk
import { getServerSession } from "next-auth"
export async function addGroceryItem(item) {
const session = await getServerSession()
if (!session) throw new Error("Unauthorized")
// ...
}
3. Persistencia de Estado Local (Offline-First)**
// Usar IndexedDB para funcionamiento offline
import { openDB } from 'idb'
const db = await openDB('grocery-list')
db.put('items', item)
4. Testing Automatizado
// Jest + React Testing Library
describe('GroceryListApp', () => {
test('should add item when form is submitted', async () => {
const { getByPlaceholderText, getByRole } = render()
// ...
})
})
5. Monitoreo y Observabilidad**
- Integración con Sentry para error tracking
- Prometheus para métricas de rendimiento
- ELK Stack para centralización de logs
Lecciones Clave
- Elige el nivel de complejidad adecuado: Polling es suficiente para muchas aplicaciones; no todas necesitan WebSockets
- Prioriza la seguridad: Nunca expongas credenciales, valida siempre en el servidor
- Documenta tu arquitectura: Los futuros mantenedores lo agradecerán
- Optimiza para el usuario final: UX suave es más importante que arquitectura perfecta
- Automatiza todo: Despliegue, testing, monitoring – es inversión que se paga
- Monitorea en producción: Los problemas reales aparecen con usuarios reales
Conclusión
La arquitectura de la Lista de Compras Colaborativa demuestra cómo Next.js 14, React 18 y PostgreSQL pueden combinarse para crear aplicaciones web modernas, escalables y seguras. Los patrones presentados—Server Actions, gestión de estado con hooks, indexación de base de datos y despliegue automatizado—son transferibles a muchos otros proyectos.
El código está optimizado para el balance entre simplicidad y robustez, permitiendo tanto desarrolladores principiantes como expertos entender y mantener el sistema. Con las mejoras futuras sugeridas, esta aplicación podría escalar a manejar miles de usuarios concurrentes.
Stack recomendado para aplicaciones similares en 2025:
- Next.js 14+ con App Router
- TypeScript para safety
- PostgreSQL o Neon para persistencia
- Tailwind + shadcn/ui para UI
- Vercel, Railway o Render para hosting
- PM2 u otro orchestrator para production
1 comentario en «Aplicación de Lista de Compras Interactiva en Tiempo Real»