Desarrollo web Intermedio

Ofertas de HOY con Next.js 16 + React 19 — Server Actions sin APIs

Construye “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 …

Publicado: 04/11/2025 Por: Juan Felipe Orozco Cortés Duración: 35 min Nivel: Intermedio
Contenido del tutorial

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).

Público objetivo: devs de Next/React que quieren adoptar Server Actions con un ejemplo pequeño pero completo. En la siguiente parte verás la estructura mínima, archivos clave y cómo correrlo con 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.

Requisito: Node 18.18+ o 20+, y pnpm instalado.
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".

El flujo es 100% compatible con Server Components y Server Actions.
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.

Para demos y tutoriales, pinnear versiones = reproducibilidad.
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.

Señales sanas: estilos utilitarios aplican, navegación de App Router responde, y no hay warnings de versiones incompatibles.
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}.

Si aparece un warning de dependencias, reinstalar con pnpm install suele resolver discrepancias de lockfile.
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.

El paso siguiente del tutorial entra directo a la estructura de carpetas y componentes que usan Server Actions.

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).

Afecta a toda la app porque se importa desde el layout raíz.
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.

Ideal para demos locales; en producción se reemplaza por DB (Postgres/SQLite/Prisma).
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.

Suficiente para el tutorial; en producción, mover a un store robusto (por ej., Redis).

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.

Ofertas de HOY — 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.

Idea: todo el “backend mínimo” vive en este archivo y se orquesta con Server Actions.
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.

Producción: en serverless el FS es efímero; migra a SQLite/Postgres/KV manteniendo la misma interfaz de helpers.
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.

Así “HOY” es consistente para tu público en Colombia, independientemente del servidor.
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.

Útil aunque el banner esté oculto: queda un registro simple y recuperable para pruebas.
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).

Previene “spam” sin montar rutas /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 /.

Sin páginas extra ni estado global: la revalidación mantiene todo coherente tras cada mutación.
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).

El flujo percibido por el usuario es “instantáneo” porque la lista se refresca con 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).

Si migras a DB, cambia los helpers de lectura/escritura y conserva el resto: UI y acciones permanecen.

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.

Ofertas de HOY — 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.

El navegador envía el FormData directo al servidor de Next.
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.

Si cambias reglas de negocio, las tocas en el servidor sin reescribir la UI.

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.

Ofertas de HOY — 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=...>.

No hay 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.

Seguridad: aunque el 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.

La coherencia tras el borrado la mantiene revalidatePath() en la acción del servidor — no hay que sincronizar estados en cliente.
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.

Ofertas de HOY — src/app/layout.tsx · Layout raíz + Metadata
import 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>
);
}

Estás viendo solo el 60% del contenido. Hazte Premium para acceder al tutorial completo.

Comunidad

Comentarios y valoraciones

No hay comentarios aún. ¡Sé el primero en opinar!