Next.js
Caso práctico: SaaS de pedidos e inventario
Aplica Next.js a un SaaS multi-tenant de pedidos e inventario con routing, Actions, PostgreSQL, caché, autorización, realtime y deployment.
- Última actualización
- Actualizada
- Nivel
- Aplicación
Next.js
Aplica Next.js a un SaaS multi-tenant de pedidos e inventario con routing, Actions, PostgreSQL, caché, autorización, realtime y deployment.
Este caso aplica el Notebook a un SaaS multi-tenant de pedidos e inventario inspirado en DomiSys. El objetivo no es proponer una única arquitectura perfecta, sino mostrar cómo routing, Server Components, caché, Actions, autorización, PostgreSQL, observabilidad y deployment se conectan en un producto real.
El sistema permite que varios negocios administren:
Cada registro privado pertenece a un businessId o organizationId. El aislamiento entre negocios es una regla estructural, no un filtro visual.
Platform Admin
Business Owner
Business Admin
Store Manager
Cashier
Delivery Staff
Customer
ViewerLas capacidades no dependen solo del rol global. También dependen de negocio, sucursal, recurso y estado.
Ejemplo:
Store Manager
+ branch A
+ update stock
+ product assigned to branch A
→ allowedBrowser
├─ public storefront
├─ business dashboard
├─ delivery mobile UI
└─ platform admin
↓
Next.js App Router
├─ Server Components
├─ Client Components
├─ Server Actions
├─ Route Handlers
├─ Proxy
├─ DAL/services
└─ Cache Components
↓
PostgreSQL
Redis / shared cache
Object storage
Queue workers
Email/payment/maps providers
ObservabilityNext.js actúa como aplicación web y BFF. Los jobs largos viven fuera del request web.
app/
├─ (marketing)/
│ ├─ page.tsx
│ ├─ pricing/page.tsx
│ └─ services/page.tsx
├─ (auth)/
│ ├─ login/page.tsx
│ └─ recover/page.tsx
├─ (dashboard)/
│ └─ dashboard/
│ ├─ layout.tsx
│ ├─ page.tsx
│ ├─ orders/
│ │ ├─ page.tsx
│ │ └─ [orderId]/page.tsx
│ ├─ inventory/page.tsx
│ ├─ products/
│ ├─ customers/
│ └─ settings/
├─ storefront/[businessSlug]/
├─ delivery/
├─ platform/
└─ api/
├─ webhooks/payment/route.ts
├─ uploads/route.ts
└─ v1/orders/route.tsRoute groups organizan layouts sin cambiar URL. La aplicación puede usar varios root layouts si las experiencias son realmente distintas, sabiendo que navegar entre ellos puede hacer una carga completa.
Responsabilidades mínimas:
<html lang>.No consulta pedidos, membresías o configuración de negocio.
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="es">
<body>
<a href="#main-content" className="skip-link">
Saltar al contenido
</a>
{children}
</body>
</html>
);
}export default async function DashboardLayout({ children }: LayoutProps<"/dashboard">) {
const session = await requireSession();
const membership = await getCurrentMembership(session.userId);
if (!membership) redirect("/onboarding");
return (
<DashboardShell navigation={navigationFor(membership.role)}>
{children}
</DashboardShell>
);
}El layout selecciona navegación y contexto general. No autoriza cada pedido o mutación; la operación vuelve a verificar.
La cookie contiene una referencia de sesión, no el objeto usuario completo.
session cookie
→ verify session
→ userId
→ membership lookup
→ active business/branchEl negocio activo puede derivarse de:
Nunca se acepta businessId del formulario como autoridad. Si se envía, se compara con el contexto autorizado.
import "server-only";
export async function getAccessibleOrder(input: {
orderId: string;
businessId: string;
userId: string;
}): Promise<OrderView | null> {
const order = await db.order.findFirst({
where: {
id: input.orderId,
businessId: input.businessId,
business: {
members: { some: { userId: input.userId } },
},
},
select: {
id: true,
number: true,
status: true,
total: true,
createdAt: true,
customer: { select: { id: true, name: true } },
},
});
return order ? toOrderView(order) : null;
}La query incorpora tenant y actor. El DTO excluye costes internos, tokens y datos no necesarios.
export default async function OrdersPage({
searchParams,
}: PageProps<"/dashboard/orders">) {
const query = ordersQuerySchema.parse(await searchParams);
const session = await requireBusinessSession();
return (
<main id="main-content">
<OrdersHeader />
<OrdersFilters initialQuery={query} />
<Suspense fallback={<OrdersTableSkeleton />}>
<OrdersTableLoader session={session} query={query} />
</Suspense>
</main>
);
}Los filtros compartibles viven en URL:
/dashboard/orders?status=pending&branch=abc&after=cursorEl formulario de filtros puede usar GET. Debounce usa replace para búsquedas continuas y push cuando la navegación debe quedar en historial.
No conviertas la tabla completa en cliente si solo la selección de filas necesita state. Pasa DTOs pequeños.
Clasificación:
marketing/pricing → cached public
product catalog public → cached + tags
business configuration → cached by business with invalidation
live inventory → request-time or short cache
personal session/menu → request-time
order list private → request-time by default
aggregate reports → cached/materialized depending freshnessEjemplo catálogo público:
async function getPublicCatalog(businessSlug: string) {
"use cache";
cacheLife("hours");
cacheTag(`catalog:${businessSlug}`);
return catalogRepository.getPublished(businessSlug);
}Inventario privado no se comparte con una key pública incompleta.
Flujo:
form/client intent
→ Server Action
→ parse schema
→ require session
→ authorize branch/customer/products
→ idempotency check
→ transaction
├─ create order
├─ create items
├─ decrement/reserve stock
├─ write audit log
└─ write outbox event
→ commit
→ update/revalidate tags
→ redirect or resultAction:
"use server";
export async function createOrderAction(
_previous: CreateOrderState,
formData: FormData,
): Promise<CreateOrderState> {
const parsed = createOrderSchema.safeParse(parseOrderForm(formData));
if (!parsed.success) {
return { status: "invalid", errors: parsed.error.flatten().fieldErrors };
}
const session = await requireBusinessSession();
const result = await orderService.create({
actorId: session.userId,
businessId: session.businessId,
branchId: session.branchId,
idempotencyKey: parsed.data.idempotencyKey,
input: parsed.data,
});
updateTag(`business:${session.businessId}:orders`);
revalidateTag(`business:${session.businessId}:dashboard`, "max");
return { status: "success", orderId: result.id };
}La Action adapta el transporte. orderService contiene transacción y reglas.
No hagas:
read stock = 2
if enough
update stock = 1Dos requests pueden leer 2 y vender más unidades de las disponibles.
Usa una operación condicional/transacción:
UPDATE inventory
SET quantity = quantity - $requested
WHERE product_id = $product
AND branch_id = $branch
AND quantity >= $requested;Si filas afectadas es cero, devuelve conflicto de stock.
También considera:
Business
├─ Branch
├─ Membership
├─ Product
│ ├─ Variant
│ └─ Inventory per Branch
├─ Customer
└─ Order
├─ OrderItem
├─ Payment
├─ Delivery
└─ StatusHistoryTodas las entidades privadas incluyen business_id. IDs globalmente únicos no reemplazan tenant scope.
PostgreSQL protege:
La validación de UI no sustituye constraints.
Opcionalmente PostgreSQL RLS añade defensa:
SET LOCAL app.business_id = '...'
→ policy filters rowsDebe integrarse correctamente con pooling/transacciones. No reemplaza permisos por acción ni DTOs.
pending
→ confirmed
→ preparing
→ ready
→ out_for_delivery
→ delivered
pending/confirmed
→ cancelledLas transiciones se validan en servidor. Un rol y estado determinan capacidad:
canTransitionOrder({ actor, order, to: "cancelled" });No permitas que el cliente envíe cualquier status y se persista directamente.
Adecuado:
Con cautela:
No optimista como éxito definitivo:
Pending y reconciliación deben ser visibles.
Invalidar cache no empuja cambios a pestañas abiertas. Para pedidos en vivo:
DB/outbox event
→ realtime service / WebSocket / SSE
→ client receives event
→ router.refresh or local query updateLos eventos incluyen ID/version, no toda la entidad sensible. El cliente vuelve a consultar con autorización.
Diseña reconexión, orden, duplicados y heartbeat.
Pago externo:
export async function POST(request: Request) {
const rawBody = await request.text();
verifySignature(rawBody, request.headers.get("x-signature"));
const event = paymentEventSchema.parse(JSON.parse(rawBody));
await paymentService.processIdempotently(event);
return new Response(null, { status: 204 });
}Protege firma, replay, event ID y orden fuera de secuencia. Responde rápido y usa cola para trabajo pesado.
Imágenes de productos:
request signed upload
→ browser uploads to object storage
→ Action confirms metadata
→ worker scans/transformsImportación de Excel:
upload
→ create import job
→ worker parses and validates
→ preview errors
→ transactional batches
→ progress/statusNo proceses miles de filas dentro de una Server Action esperando la request.
Reportes agregados pueden ser costosos. Opciones:
Un dashboard no debe ejecutar diez agregados globales secuenciales por request.
Marketing y storefront público:
Dashboard:
noindex.Robots no protege información.
Una app de operación se usa bajo presión; accesibilidad mejora eficiencia para todos.
Distingue:
invalid input
unauthenticated
forbidden
not found
stock conflict
provider unavailable
unexpected crashNo muestres “pedido creado” si el commit falló.
Cada request/mutation registra:
Métricas:
Audit logs de negocio se separan de logs técnicos.
Amenazas clave:
Defensas:
PR
→ lint/types/tests/build
→ preview isolated
→ E2E
→ expand migration
→ deploy canary
→ smoke tests
→ rolloutMigraciones destructivas usan expand/contract. Feature flags permiten activar módulos por negocio/plan.
Opción Vercel:
Opción contenedor:
load balancer
→ Next.js replicas
→ PostgreSQL
→ Redis
→ object storage
→ worker queueEn ambos casos, la región debe estar cerca de DB y el cache/realtime debe coordinarse.
No construyas todos los módulos antes de validar operaciones básicas.
Bueno para UI y operaciones web breves. Workers separados para jobs.
Puede mejorar performance, pero aumenta cardinalidad y riesgo. Request-time es más seguro al inicio.
Defensa fuerte, pero añade complejidad con pooling.
Mejora operación, pero requiere orden, reconexión y costes.
Aporta cuando web, platform admin y worker comparten dominio; no desde el primer commit necesariamente.
IDOR.
Overselling.
Difícil reutilizar/testear.
Eventos pueden perderse; DB sigue siendo autoridad.
Timeout y retry inseguro.
Fuga de negocio.
Satura DB.
Confirmaciones falsas.
Modelo mental completo de Next.js resume cómo diagnosticar cualquier ruta, dato, mutación o fallo usando todas las capas del Notebook.