Ofertas de HOY con Next.js 16 + React 19 — Server Actions sin APIs
Inicia sesión para descargarConstruye “Ofertas de HOY”: un CRUD moderno sin rutas /api usando Server Actions, App Router y Server Components. Persistimos en un JSON local, ordenamos por hora y añadimos eliminación con deshacer y …
Contenido del tutorial ⌄
- Proyecto base en Next 16 + React 19 (60 s)
- Setup de “Ofertas de HOY”: qué hace cada paso
- Estructura del proyecto (tu mapa antes de teclear)
- ¿Para qué sirve cada archivo?
- src/app/page.tsx — cerebro de “Ofertas de HOY”
- Lectura guiada de src/app/page.tsx
- src/components/OfferForm.tsx — creación de ofertas
- Lectura guiada de src/components/OfferForm.tsx
- src/components/OfferList.tsx — lista y eliminación
- Lectura guiada de src/components/OfferList.tsx
- src/app/layout.tsx — layout raíz + metadatos
Ofertas de HOY — CRUD moderno sin rutas /api
¿Buscas un ejemplo corto, útil y 100 % replicable para construir CRUDs sin escribir rutas /api?
Aquí creamos “Ofertas de HOY”: un comerciante publica ofertas del día, las ve al instante
y puede eliminarlas con opción de deshacer. Todo con App Router, Server Components y
Server Actions, guardando en un JSON local (perfecto para demos y prototipos).
Qué construirás: un formulario (título + precio) que crea ofertas de hoy, una lista ordenada por hora y eliminación con feedback inmediato y deshacer temporal.
Por qué ahora: las Server Actions reemplazan muchas rutas REST; React 19 aporta useActionState; Next 16 facilita las mutaciones con revalidatePath().
Qué aprenderás: validar en la acción del servidor, manejar estado con useActionState/useFormStatus, refrescar la UI tras la mutación y persistir en JSON (con notas para pasar a DB en producción).
pnpm dev.
Proyecto base en Next 16 + React 19 (60 s)
Requisito: Node 18.18+ o 20+ y pnpm instalado. Este arranque deja App Router, TypeScript, Tailwind y el alias @/* listos.
# Requisito: Node 18.18+ o 20+
node -v
# Crear app con App Router + TS + Tailwind y alias "@/*"
pnpm dlx create-next-app@latest ofertas-hoy `
--ts --app --src-dir --tailwind --eslint `
--import-alias "@/*"
cd ofertas-hoy
# Asegurar versiones (Next 16 + React 19)
pnpm add -E next@16.0.1 react@19.2.0 react-dom@19.2.0
Setup de “Ofertas de HOY”: qué hace cada paso
1) Panorama general
El arranque crea una base con App Router, TypeScript, Tailwind y el alias @/*.
Luego se fijan versiones para garantizar compatibilidad: next@16.0.1, react@19.2.0 y react-dom@19.2.0.
2) ¿Qué construye el comando inicial?
La plantilla genera una app con App Router (carpeta app/), configura TypeScript (archivos .ts/.tsx)
y deja Tailwind listo (config y estilos globales). El alias @/* permite imports limpios como
import { X } from "@/components/X".
3) ¿Por qué fijar versiones?
Fijar next@16.0.1 y react@19.2.0 replica exactamente el entorno probado. Así evitas sorpresas por cambios minor/patch.
Si más adelante actualizas, podrás comparar comportamientos sabiendo desde qué base partiste.
4) Verificación rápida
Comprobación típica tras el arranque: node -v muestra la versión; pnpm dev levanta la app en local. Si Tailwind compila y la página base carga, el setup quedó OK.
5) Problemas comunes y cómo leerlos
Node incompatible: errores de sintaxis/ESM suelen indicar versión antigua. Actualiza a 18.18+ o 20+.
Puerto en uso: si localhost:3000 está ocupado, cambia el puerto con --port o cierra el proceso previo.
Estilos no cargan: Tailwind necesita los content paths correctos; revisa que apunten a app/**/*.{ts,tsx}.
6) Qué queda listo para el CRUD
Con el esqueleto operativo, ya se puede definir el modelo mínimo (ofertas del día), preparar una acción del servidor para crear/eliminar
y renderizar la lista con datos persistidos en un JSON local. Todo sin rutas /api adicionales.
Estructura del proyecto (tu mapa antes de teclear)
Mostramos la estructura para darte un mapa mental claro: qué piezas existen, cómo se relacionan y dónde va cada fragmento que verás enseguida. Así evitas dudas de “¿en qué carpeta pego esto?”, reduces errores por archivos mal ubicados y garantizas que, si alguien clona tu repo, obtenga el mismo resultado. También te ayuda a visualizar rápidamente qué es UI, qué vive en Server Actions y qué es persistencia local, de modo que luego puedas cambiar el JSON por una base de datos sin reescribir toda la app. En resumen, esta vista previa alinea expectativas, acelera la implementación y hace el tutorial 100% replicable.
src/
app/
favicon.ico
globals.css
layout.tsx
page.tsx
components/
OfferForm.tsx
OfferList.tsx
data/
data.json
lastDeleted.json
rl.json
¿Para qué sirve cada archivo?
app/favicon.ico
Ícono del sitio que los navegadores muestran en la pestaña. Aporta identificación visual; en este tutorial es opcional.
app/globals.css
Hoja de estilos global. Aquí viven @tailwind base, @tailwind components y @tailwind utilities, además de reglas base (tipografías, resets, tokens).
app/layout.tsx
Layout raíz del App Router. Define <html> y <body>, importa globals.css y renderiza {'{children}'} para todas las rutas. Punto central para metadatos y temas.
app/page.tsx
Página principal (Server Component) que implementa el “backend mínimo” del tutorial.
Responsabilidades clave: leer/escribir JSON en src/data/; definir Server Actions; filtrar “ofertas de HOY”; forzar revalidaciones de ruta.
Server Actions incluidas:
createOfferAction: valida título/precio, aplica rate-limit, genera id y createdAt, persiste en data.json y llama revalidatePath('/')/.
deleteOfferAction: elimina por id, guarda un buffer en lastDeleted.json (para posible deshacer/auditoría), marca cookie undo_seen y revalida la ruta.
undoDeleteOfferAction (opcional): reinyecta desde el buffer la última oferta eliminada.
Otros detalles: filtro por fecha “hoy” usando zona horaria America/Bogota; rate-limit básico con clave de cliente derivada de cookie sid (o headers), persistida en rl.json.
SHOW_UNDO_BANNER controla si se muestra u oculta el aviso de “Deshacer”. En la guía se mantiene desactivado.components/OfferForm.tsx
Client Component del formulario de creación (título + precio). Usa useActionState para invocar la Server Action y recibir estado, y useFormStatus para feedback de “Publicando…”. Limpia campos cuando la creación fue exitosa.
components/OfferList.tsx
Client Component que renderiza las ofertas del día (título y precio formateado en COP). Cada ítem incluye <form action={'{deleteOfferAction}'}> con el id oculto y useFormStatus para mostrar “Eliminando…” mientras se procesa.
data/data.json
Persistencia mínima de negocio: estructura {'{"offers": [...]}'} con ofertas que incluyen id, title, price y createdAt.
data/lastDeleted.json
Buffer de la última oferta eliminada con expiresAt (TTL). Útil para una opción “Deshacer” temporal o auditoría ligera.
data/rl.json
Registro para rate-limit: mapa clientKey → [timestamps] que limita publicaciones por ventana de tiempo.
src/app/page.tsx — cerebro de “Ofertas de HOY”
Esta página es el corazón de la demo: muestra la vista principal donde el comerciante publica y gestiona las ofertas del día, valida y limpia los datos antes de guardarlos, persiste la información de forma local para que no se pierda al recargar, y actualiza la lista al instante después de cada cambio. También respeta la zona horaria de Bogotá para decidir qué es “HOY”, incluye un mecanismo de eliminación con opción de deshacer por un tiempo breve (si se activa) y aplica un control básico de frecuencia para evitar abusos. En conjunto, concentra la lógica de negocio y orquesta la interfaz para ofrecer una experiencia rápida y confiable.
src/app/page.tsx · UI + Server Actions// src/app/page.tsx
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
import path from "node:path";
import fs from "node:fs/promises";
import { cookies, headers } from "next/headers";
import { revalidatePath } from "next/cache";
import OfferForm from "@/components/OfferForm";
import OfferList from "@/components/OfferList";
// Toggle global: desactiva el banner de “Deshacer” en la UI
const SHOW_UNDO_BANNER = false;
// ===== Tipos =====
export type Offer = {
id: string;
title: string;
price: number;
createdAt: string; // ISO
};
export type CreateState = {
ok?: boolean;
message?: string;
errors?: { title?: string; price?: string };
rateLimitResetSec?: number;
};
// ===== Paths (persistencia simple en archivos) =====
const DB_DIR = path.join(process.cwd(), "src", "data");
const DB_PATH = path.join(DB_DIR, "data.json");
const RL_PATH = path.join(DB_DIR, "rl.json");
const UNDO_PATH = path.join(DB_DIR, "lastDeleted.json");
// ===== Utilidades de archivo =====
async function ensureDB() {
await fs.mkdir(DB_DIR, { recursive: true });
for (const [p, init] of [
[DB_PATH, { offers: [] }],
[RL_PATH, {}],
[UNDO_PATH, {}],
] as const) {
try {
await fs.access(p);
} catch {
await fs.writeFile(p, JSON.stringify(init, null, 2), "utf8");
}
}
}
async function readJSON<T>(p: string, fallback: T): Promise<T> {
await ensureDB();
try {
const raw = await fs.readFile(p, "utf8");
return (raw?.trim() ? JSON.parse(raw) : fallback) as T;
} catch {
return fallback;
}
}
async function writeJSON<T>(p: string, v: T) {
await ensureDB();
await fs.writeFile(p, JSON.stringify(v, null, 2), "utf8");
}
// ===== Ofertas =====
async function readOffers(): Promise<Offer[]> {
const data = await readJSON<{ offers: Offer[] }>(DB_PATH, { offers: [] });
return Array.isArray(data.offers) ? data.offers : [];
}
async function writeOffers(offers: Offer[]) {
await writeJSON(DB_PATH, { offers });
}
function ymdBogota(d: Date) {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: "America/Bogota",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
return fmt.format(d);
}
async function getOffersToday(): Promise<Offer[]> {
const all = await readOffers();
const today = ymdBogota(new Date());
return all
.filter((o) => ymdBogota(new Date(o.createdAt)) === today)
.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
}
// ===== “Deshacer” eliminación (buffer con TTL) =====
type UndoBuffer = { offer?: Offer; expiresAt?: number };
async function setUndo(offer: Offer, ttlSec = 60) {
const expiresAt = Date.now() + ttlSec * 1000;
await writeJSON<UndoBuffer>(UNDO_PATH, { offer, expiresAt });
}
async function getUndo(): Promise<UndoBuffer> {
const buf = await readJSON<UndoBuffer>(UNDO_PATH, {});
if (!buf.offer || !buf.expiresAt) return {};
if (Date.now() > buf.expiresAt) {
await writeJSON<UndoBuffer>(UNDO_PATH, {}); // expiró
return {};
}
return buf;
}
async function clearUndo() {
await writeJSON<UndoBuffer>(UNDO_PATH, {});
}
// ===== Rate limiting (cookie de sesión + fallback a headers) =====
type RLMap = Record<string, number[]>;
async function clientKey(): Promise<string> {
// 1) Cookie de sesión (preferido)
try {
const c = await cookies(); // Next 16: APIs dinámicas son async
let sid = c.get("sid")?.value;
if (!sid) {
sid = crypto.randomUUID();
c.set("sid", sid, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
}
return `sid:${sid}`;
} catch {
// ignore
}
// 2) Fallback: headers async
try {
const h = await headers();
const ip =
h.get("x-forwarded-for") ??
h.get("cf-connecting-ip") ??
h.get("x-real-ip") ??
"unknown";
const ua = h.get("user-agent") ?? "ua";
return `${ip}|${ua}`;
} catch {
// ignore
}
// 3) Último recurso
return "anon";
}
async function rateLimitGuard(key: string, maxPerWindow = 5, windowSec = 60) {
const now = Date.now();
const map = await readJSON<RLMap>(RL_PATH, {});
const arr = (map[key] ?? []).filter((t) => now - t < windowSec * 1000);
if (arr.length >= maxPerWindow) {
const oldest = arr[0];
const resetMs = windowSec * 1000 - (now - oldest);
const resetSec = Math.max(1, Math.ceil(resetMs / 1000));
return { allowed: false, resetSec };
}
arr.push(now);
map[key] = arr;
await writeJSON<RLMap>(RL_PATH, map);
return { allowed: true };
}
// ===== Sanitización =====
function sanitizeTitle(s: string) {
return s.replace(/\s+/g, " ").trim().slice(0, 80);
}
function parseCOP(s: string) {
// acepta “15.000”, “15000”, “15,000.50”, “15000,50”
const norm = s.replace(/\s/g, "").replace(/\./g, "").replace(",", ".");
const n = Number(norm);
return Number.isFinite(n) ? n : NaN;
}
// ===== Server Actions =====
export async function createOfferAction(
_prev: CreateState,
formData: FormData
): Promise<CreateState> {
"use server";
const key = await clientKey();
const gate = await rateLimitGuard(key, 5, 60); // 5 por minuto
if (!gate.allowed) {
return {
message: "Demasiadas publicaciones. Intenta de nuevo en un momento.",
rateLimitResetSec: gate.resetSec,
};
}
let title = (formData.get("title") as string | null) ?? "";
let priceRaw = (formData.get("price") as string | null) ?? "";
title = sanitizeTitle(title);
const priceNum = parseCOP(priceRaw);
const errors: CreateState["errors"] = {};
if (!title) errors.title = "El título es obligatorio.";
if (!priceRaw || Number.isNaN(priceNum) || priceNum <= 0) {
errors.price = "El precio debe ser un número positivo.";
}
if (errors.title || errors.price) {
return { message: "Corrige los campos marcados.", errors };
}
const offer: Offer = {
id: crypto.randomUUID(),
title,
price: Math.round(priceNum), // normaliza a entero COP
createdAt: new Date().toISOString(),
};
const current = await readOffers();
current.push(offer);
await writeOffers(current);
revalidatePath("/");
return { ok: true };
}
// Eliminar: guarda lastDeleted.json PERO marca cookie para no mostrar banner
export async function deleteOfferAction(formData: FormData) {
"use server";
const id = (formData.get("id") as string | null) ?? "";
if (!id) return { ok: false };
const current = await readOffers();
const victim = current.find((o) => o.id === id);
const next = current.filter((o) => o.id !== id);
await writeOffers(next);
if (victim) {
// 1) Guardar buffer de deshacer (auditoría / log) por 60s
await setUndo(victim, 60);
// 2) Marcar en cookie que ESTE cliente ya “vio” este borrado
try {
const c = await cookies();
c.set("undo_seen", victim.id, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60, // dura 60s (igual a TTL del buffer)
});
} catch {
// ignore en dev si cookies no disponibles
}
}
revalidatePath("/");
return { ok: true };
}
// (opcional) Deshacer si algún día reactivas el banner
export async function undoDeleteOfferAction() {
"use server";
const buf = await getUndo();
if (!buf.offer) return { ok: false };
const offers = await readOffers();
if (!offers.some((o) => o.id === buf.offer!.id)) {
offers.push(buf.offer!);
await writeOffers(offers);
}
await clearUndo();
revalidatePath("/");
return { ok: true };
}
// ===== Página =====
export default async function Home() {
const offers = await getOffersToday();
// Cálculo del banner (respetando cookie y flag)
const undo = await getUndo();
const c = await cookies();
const seenId = c.get("undo_seen")?.value;
const undoAvailable =
SHOW_UNDO_BANNER &&
Boolean(undo.offer && undo.expiresAt && Date.now() < (undo.expiresAt as number)) &&
undo.offer!.id !== seenId;
return (
<main className="mx-auto max-w-2xl p-6 space-y-8">
<header className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">Ofertas de HOY</h1>
<p className="text-sm text-neutral-500">
Publica una oferta rápida. Datos persistidos en{" "}
<code>src/data/data.json</code> (solo dev).
</p>
</header>
{/* Banner Deshacer: desactivado por SHOW_UNDO_BANNER */}
{undoAvailable && (
<form
action={undoDeleteOfferAction}
className="flex items-center justify-between rounded-xl border bg-amber-50 px-4 py-3 text-sm"
>
<span>
Oferta eliminada: <strong>{undo.offer!.title}</strong>. ¿Deshacer?
</span>
<button
type="submit"
className="rounded-lg border px-3 py-1 hover:bg-amber-100"
title="Reinsertar la última oferta eliminada (válido ~60s)"
>
Deshacer
</button>
</form>
)}
<section className="rounded-2xl border p-4 shadow-sm">
<h2 className="mb-3 text-lg font-medium">Nueva oferta</h2>
<OfferForm action={createOfferAction} />
</section>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Publicadas hoy</h2>
<span className="text-sm text-neutral-500">{offers.length} en total</span>
</div>
<OfferList offers={offers} deleteAction={deleteOfferAction} />
</section>
</main>
);
}
Lectura guiada de src/app/page.tsx
1) Configuración del render y dependencias
export const runtime = "nodejs" fuerza ejecución en Node (útil para usar fs y trabajar en local). export const dynamic = "force-dynamic" evita HTML estático: tras cada mutación se vuelve a renderizar.
Importaciones clave: path y fs/promises (persistencia en JSON), cookies y headers (APIs dinámicas de Next 16), revalidatePath (invalida / tras crear/eliminar), y los componentes de UI OfferForm y OfferList.
2) Flags y tipos
SHOW_UNDO_BANNER = false controla si aparece el aviso de “Deshacer”. Aunque esté en false, se sigue guardando un buffer en disco para auditoría.
Tipos: Offer (modelo: id, title, price, createdAt) y CreateState (contrato de respuesta de la Server Action: éxito, mensajes, errores y un posible rateLimitResetSec).
3) Persistencia en archivos
Rutas: src/data/data.json (ofertas), rl.json (rate-limit) y lastDeleted.json (buffer “deshacer”). ensureDB() crea carpeta/archivos si no existen. readJSON/writeJSON son envoltorios seguros; readOffers/writeOffers manejan la colección principal.
4) Filtrado por “HOY” (zona horaria Bogotá)
ymdBogota() formatea fechas a YYYY-MM-DD usando America/Bogota. getOffersToday() filtra por la fecha actual (Bogotá) y ordena por createdAt descendente.
5) Buffer de “Deshacer” (auditoría temporal)
Modelo: { offer?: Offer; expiresAt?: number }. setUndo(offer, ttl) guarda la última oferta eliminada con TTL (p. ej., 60 s); getUndo() la devuelve si no expiró; clearUndo() la borra.
6) Rate-limit simple (clave de cliente)
Mapa clientKey → [timestamps] en rl.json. clientKey() intenta identificar por cookie sid (la crea si no existe) y, en su defecto, usa headers() (IP/UA). rateLimitGuard() aplica ventana deslizante (p. ej., 5 publicaciones/min).
/api ni dependencias externas.7) Sanitización/normalización
sanitizeTitle() colapsa espacios y recorta a 80 caracteres. parseCOP() acepta formatos como 15.000, 15,000.50 o 15000,50 y normaliza a número; al guardar se redondea a entero COP.
8) Server Actions: crear, eliminar y deshacer
createOfferAction: aplica rate-limit, sanea/valida title/price, crea la oferta (con crypto.randomUUID()), persiste en data.json y llama revalidatePath("/") para refrescar la UI al instante.
deleteOfferAction: elimina por id, guarda buffer en lastDeleted.json (TTL) y marca cookie undo_seen (no repetir aviso). Revalida /.
undoDeleteOfferAction (opcional): reinserta desde el buffer si sigue vigente; limpia y revalida /.
9) Componente de página Home()
Es un Server Component async que carga las ofertas del día, calcula si el banner de “Deshacer” debería mostrarse (flag + buffer + cookie) y renderiza el encabezado, el formulario (OfferForm) y la lista (OfferList con contador).
revalidatePath sin recargar la página.10) Por qué este diseño funciona bien
Simple y trazable (toda la mutación en Server Actions), local-first (JSON replicable), y listo para crecer (activar banner o migrar a DB sin cambiar la interfaz de uso).
src/components/OfferForm.tsx — creación de ofertas
Este componente publica ofertas del día con un formulario mínimo (título y precio), validación en servidor sin recargar, estados de envío (“Publicando…”) y limpieza automática al éxito. Orquesta la Server Action mediante useActionState/useFormStatus, prioriza accesibilidad y deja la lista actualizada al instante.
src/components/OfferForm.tsx · Formulario + Server Actions"use client";
import { useEffect, useRef } from "react";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import type { CreateState } from "@/app/page";
type Props = {
action: (state: CreateState, formData: FormData) => Promise<CreateState>;
};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="inline-flex items-center rounded-xl bg-black px-4 py-2 text-white disabled:opacity-60"
>
{pending ? "Publicando…" : "Publicar oferta"}
</button>
);
}
export default function OfferForm({ action }: Props) {
const [state, formAction] = useActionState<CreateState, FormData>(action, {});
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state?.ok) formRef.current?.reset();
}, [state?.ok]);
return (
<form ref={formRef} action={formAction} className="space-y-3">
<div>
<label htmlFor="title" className="block text-sm font-medium">
Título de la oferta
</label>
<input
id="title"
name="title"
type="text"
placeholder="Ej: Almuerzo ejecutivo"
className="mt-1 w-full rounded-xl border px-3 py-2 outline-none focus:ring"
aria-invalid={Boolean(state?.errors?.title) || undefined}
aria-errormessage="title-error"
required
maxLength={80}
/>
{state?.errors?.title && (
<p id="title-error" className="mt-1 text-sm text-red-600">
{state.errors.title}
</p>
)}
</div>
<div>
<label htmlFor="price" className="block text-sm font-medium">
Precio (COP)
</label>
<input
id="price"
name="price"
type="text"
inputMode="decimal"
placeholder="Ej: 15.000"
className="mt-1 w-full rounded-xl border px-3 py-2 outline-none focus:ring"
aria-invalid={Boolean(state?.errors?.price) || undefined}
aria-errormessage="price-error"
required
/>
{state?.errors?.price && (
<p id="price-error" className="mt-1 text-sm text-red-600">
{state.errors.price}
</p>
)}
</div>
{state?.message && !state?.ok && (
<p className="text-sm text-red-600">
{state.message}
{typeof state.rateLimitResetSec === "number" &&
` (espera ~${state.rateLimitResetSec}s)`}
</p>
)}
{state?.ok && <p className="text-sm text-green-600">¡Oferta publicada!</p>}
<SubmitButton />
</form>
);
}
Lectura guiada de src/components/OfferForm.tsx
1) Componente de cliente
Se declara con "use client" para habilitar hooks de React en la UI (estado de envío, reseteo del formulario, efectos).
2) Prop action: Server Action directa
Recibe una función de servidor y el <form> la invoca desde su atributo action. No hace falta onSubmit ni rutas REST.
3) useActionState: estado reactivo
Enlaza formulario ↔ Server Action y expone un estado con ok, mensajes y errores de validación. Ese estado gobierna qué muestra la UI en cada momento.
4) Reseteo post-éxito
Se guarda una referencia al <form> y, cuando state.ok cambia a true, se ejecuta form.reset(). Los campos vuelven a vacío de forma automática.
5) useFormStatus en el botón
Un botón hijo lee pending y se deshabilita durante el envío, cambiando su etiqueta a “Publicando…”. Evita dobles clics y comunica progreso.
6) Campos y accesibilidad
Cada campo tiene etiqueta visible, restricciones (required, maxLength, inputMode) y atributos ARIA (aria-invalid, aria-errormessage). Si llega un error, aparece un texto de ayuda justo debajo.
7) Mensajería global
Si la acción devuelve un mensaje general (p. ej., límite por minuto), se muestra un aviso compacto. Al éxito, aparece un confirmatorio breve (“¡Oferta publicada!”).
8) Responsabilidad única
El componente solo recoge datos y refleja estado; la validación definitiva y la persistencia viven en la Server Action. Mantiene la UI simple y confiable.
src/components/OfferList.tsx — lista y eliminación
Renderiza las ofertas del día y entrega una experiencia de borrado simple y segura: cada ítem incluye un formulario que invoca una Server Action para eliminar, muestra estado “Eliminando…” mientras se procesa y mantiene la interfaz consistente sin rutas /api ni recargas. Formatea precios en COP y prioriza accesibilidad y velocidad.
src/components/OfferList.tsx · Lista + Delete Server Action"use client";
import { useMemo } from "react";
import { useFormStatus } from "react-dom";
import type { Offer } from "@/app/page";
type Props = {
offers: Offer[];
deleteAction: (formData: FormData) => Promise<{ ok?: boolean }>;
};
function DeleteButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="rounded-lg border px-3 py-1 text-sm hover:bg-neutral-50 disabled:opacity-60"
>
{pending ? "Eliminando…" : "Eliminar"}
</button>
);
}
export default function OfferList({ offers, deleteAction }: Props) {
const cop = useMemo(
() =>
new Intl.NumberFormat("es-CO", {
style: "currency",
currency: "COP",
maximumFractionDigits: 0,
}),
[]
);
if (offers.length === 0) {
return (
<div className="rounded-2xl border p-4 text-sm text-neutral-500">
Aún no hay ofertas publicadas hoy.
</div>
);
}
return (
<ul className="space-y-3">
{offers.map((o) => (
<li
key={o.id}
className="flex items-center justify-between rounded-2xl border p-4"
>
<div>
<div className="font-medium">{o.title}</div>
<div className="text-sm text-neutral-500">{cop.format(o.price)}</div>
</div>
<form action={deleteAction}>
<input type="hidden" name="id" value={o.id} />
<DeleteButton />
</form>
</li>
))}
</ul>
);
}
Lectura guiada de src/components/OfferList.tsx
1) Componente de cliente y propósito
Se declara con "use client" porque usa hooks de React (useMemo) y de formularios (useFormStatus). Su misión: listar ofertas y delegar el borrado a una Server Action sin gestionar estado global.
2) Props: offers y deleteAction
offers llega ya filtrado (solo “HOY”) desde el Server Component. deleteAction es la función de servidor para eliminar por id, invocada directamente desde el <form action=...>.
onSubmit ni rutas REST: el navegador envía el FormData al servidor de Next.3) Formateo de moneda con useMemo
Se memoiza un Intl.NumberFormat("es-CO", { currency: "COP" }) para evitar recrearlo en cada render. Asegura precios consistentes (sin decimales) y mejora rendimiento.
4) Borrado vía Server Action
Cada item incluye un <form action={deleteAction}> con un <input type="hidden" name="id" />. Al enviar, el servidor verifica y elimina; luego la página se revalida y la lista se actualiza sola.
id viene del cliente, la validación definitiva ocurre en el servidor (existencia del id, permisos, etc.).5) Botón con useFormStatus
DeleteButton lee pending y deshabilita el botón mientras la acción corre, cambiando la etiqueta a “Eliminando…”. Evita dobles clics y comunica progreso.
6) Estado vacío y semántica
Si no hay datos, se muestra un panel neutro (“Aún no hay ofertas publicadas hoy.”). Para listados, se usa <ul>/<li> con key={id}, lo que facilita la reconciliación de React.
7) Accesibilidad y UX
El botón tiene texto claro y estado deshabilitado al enviar. Puedes añadir aria-live="polite" a un contenedor de mensajes si integras confirmaciones o errores de borrado.
8) Rendimiento y límites
Con memo del formateador y claves estables, el render es eficiente. Si el listado creciera, considera paginación o virtualización; para este caso, una docena de ítems es instantánea.
9) Extensiones útiles
Confirmación previa al borrado, toasts de feedback, “deshacer” temporal cuando actives el buffer en el servidor, y filtros/orden por precio u hora.
src/app/layout.tsx — layout raíz + metadatos
Define la “cáscara” de toda la app: fija el lang del documento, carga los estilos globales y envuelve cada página con un <html>/<body> único. Además exporta metadatos tipados (título y descripción) para que el App Router construya el head correctamente y tu tutorial tenga SEO básico desde el inicio.
src/app/layout.tsx · Layout raíz + Metadataimport type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Ofertas de HOY",
description: "Demo Next.js Server Actions con persistencia local en JSON",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body>{children}</body>
</html>
);
}
Comentarios y valoraciones
No hay comentarios aún. ¡Sé el primero en opinar!