Construye una tienda online moderna con Next.js (App Router) y TypeScript: guía paso a paso
Objetivo: en 2–3 horas tendrás una base funcional de e‑commerce con Next.js 14 (App Router), TypeScript, Prisma + PostgreSQL, Stripe (Checkout), NextAuth, testing (Jest/Playwright) y despliegue (Vercel/Docker).
1) Requisitos e instalación
Resumen: Antes de escribir código, asegúrate de tener el entorno listo: Node 18+, gestor de paquetes, Git, PostgreSQL (local o Docker), Stripe CLI y (opcional) Vercel CLI.
Pasos:
- Instala Node LTS (>=18) y un gestor: pnpm (recomendado), npm o yarn.
- Instala Git, Docker Desktop (opcional) y PostgreSQL local o via Docker.
- Instala Stripe CLI y (opcional) Vercel CLI.
- Verifica versiones:
1 2 3 4 5 6 |
node -v pnpm -v # o npm -v / yarn -v stripe --version docker --version # opcional vercel --version # opcional |
Checklist:
- [ ] Node 18+ y gestor de paquetes
- [ ] Git y repositorio inicializado
- [ ] PostgreSQL accesible (local o Docker)
- [ ] Stripe CLI instalado
- [ ] Comandos de versión OK
2) Inicializar proyecto Next.js + TypeScript (App Router)
Resumen: Crearemos el proyecto con create-next-app, añadiremos ESLint, Prettier y (opcional) Tailwind.
Pasos:
1) Crear proyecto:
1 2 3 |
pnpm create next-app@latest nextjs-store --ts --eslint --app --src-dir false --tailwind cd nextjs-store |
2) Instalar dependencias clave:
1 2 3 4 5 6 |
pnpm add prisma @prisma/client next-auth bcrypt zod pnpm add stripe @stripe/stripe-js @types/node -D pnpm add ts-node -D pnpm add jest @types/jest ts-jest @testing-library/react @testing-library/jest-dom -D pnpm add playwright @playwright/test -D |
3) Estructura recomendada:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
nextjs-store/ ├─ app/ │ ├─ api/ │ │ ├─ auth/[...nextauth]/route.ts │ │ ├─ checkout/route.ts │ │ ├─ webhooks/stripe/route.ts │ │ └─ health/route.ts │ ├─ admin/ │ │ ├─ products/page.tsx │ │ └─ orders/page.tsx │ ├─ (shop)/ │ │ ├─ page.tsx │ │ └─ product/[id]/page.tsx │ ├─ cart/page.tsx │ ├─ layout.tsx │ └─ page.tsx ├─ components/ │ ├─ ProductCard.tsx │ ├─ CartIcon.tsx │ └─ CheckoutButton.tsx ├─ lib/ │ ├─ prisma.ts │ ├─ stripe.ts │ └─ auth.ts ├─ prisma/ │ ├─ schema.prisma │ └─ seed.ts ├─ tests/ │ ├─ e2e/checkout.spec.ts │ └─ unit/product.test.ts ├─ public/ ├─ styles/ ├─ .github/workflows/ci.yml ├─ .env.example ├─ package.json ├─ next.config.mjs ├─ docker-compose.yml └─ Dockerfile |
4) Scripts útiles y configuración inicial.
Código (package.json):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
{ "name": "nextjs-store", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev --name init", "prisma:deploy": "prisma migrate deploy", "seed": "ts-node prisma/seed.ts", "test": "jest", "test:e2e": "playwright test" }, "dependencies": { "@prisma/client": "latest", "bcrypt": "^5.1.1", "next": "14.2.3", "next-auth": "^4.24.7", "react": "18.3.1", "react-dom": "18.3.1", "stripe": "^14.0.0", "zod": "^3.23.8" }, "devDependencies": { "@playwright/test": "^1.46.0", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.3.1", "@types/jest": "^29.5.12", "@types/node": "^20.14.9", "jest": "^29.7.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typescript": "^5.5.4" } } |
Explicación: Define scripts para desarrollo, build, migraciones y tests. Usa Next 14 (App Router).
Código (next.config.mjs):
1 2 3 4 5 6 7 8 9 |
/** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, experimental: { serverActions: { allowedOrigins: ["localhost:3000"] } }, images: { domains: ["images.unsplash.com", "files.stripe.com"] } }; export default nextConfig; |
Explicación: Habilita server actions y dominios para next/image.
Checklist:
- [ ] Proyecto creado con App Router + TS
- [ ] Scripts y next.config configurados
- [ ] Estructura de carpetas lista
3) Autenticación y perfiles con NextAuth (Credentials)
Resumen: Implementamos login con email/contraseña con NextAuth y protegemos rutas.
Pasos:
- Añade modelo User con passwordHash.
- Configura NextAuth con CredentialsProvider.
- Crea endpoint de registro y usa bcrypt.
- Protege vistas de admin con getServerSession.
Código (lib/auth.ts):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
import NextAuth, { NextAuthOptions } from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { PrismaClient } from "@prisma/client"; import bcrypt from "bcrypt"; const prisma = new PrismaClient(); export const authOptions: NextAuthOptions = { session: { strategy: "jwt" }, providers: [ Credentials({ name: "Credentials", credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" } }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) return null; const user = await prisma.user.findUnique({ where: { email: credentials.email } }); if (!user || !user.passwordHash) return null; const valid = await bcrypt.compare(credentials.password, user.passwordHash); if (!valid) return null; return { id: user.id, name: user.name, email: user.email, role: user.role } as any; } }) ], pages: {}, callbacks: { async jwt({ token, user }) { if (user) token.role = (user as any).role || "USER"; return token; }, async session({ session, token }) { if (session.user) (session.user as any).role = token.role; return session; } }, secret: process.env.NEXTAUTH_SECRET }; |
Explicación: Configura NextAuth con Credentials; compara hashes y añade role al token/session.
Código (app/api/auth/[…nextauth]/route.ts):
1 2 3 4 5 6 7 |
import NextAuth from "next-auth"; import { authOptions } from "@/lib/auth"; const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; |
Explicación: Endpoint de NextAuth en App Router.
Código (app/api/register/route.ts):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import { NextResponse } from "next/server"; import { z } from "zod"; import bcrypt from "bcrypt"; import { prisma } from "@/lib/prisma"; const schema = z.object({ name: z.string().min(2), email: z.string().email(), password: z.string().min(8) }); export async function POST(req: Request) { const data = await req.json(); const parsed = schema.safeParse(data); if (!parsed.success) return NextResponse.json({ error: "Invalid" }, { status: 400 }); const { name, email, password } = parsed.data; const exists = await prisma.user.findUnique({ where: { email } }); if (exists) return NextResponse.json({ error: "Email in use" }, { status: 409 }); const passwordHash = await bcrypt.hash(password, 10); const user = await prisma.user.create({ data: { name, email, passwordHash } }); return NextResponse.json({ id: user.id, email: user.email }); } |
Explicación: Crea usuarios nuevos de forma segura.
Protección de ruta (app/admin/orders/page.tsx):
1 2 3 4 5 6 7 8 9 10 |
import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { redirect } from "next/navigation"; export default async function AdminOrdersPage() { const session = await getServerSession(authOptions); if (!session || (session.user as any)?.role !== "ADMIN") redirect("/api/auth/signin"); return <div>Panel de pedidos (solo admin)</div>; } |
Explicación: Protección en Server Component; redirige si no hay permisos.
Checklist:
- [ ] NextAuth configurado con Credentials
- [ ] Endpoint de registro creado
- [ ] Rutas admin protegidas
4) Base de datos y Prisma (PostgreSQL)
Resumen: Definimos el schema, aplicamos migraciones y datos de ejemplo.
Pasos:
- Configura DATABASE_URL.
- Define modelos y relaciones.
- Ejecuta migraciones y seed.
Código (.env.example):
1 2 3 4 5 6 |
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/nextjs_store?schema=public" STRIPE_SECRET_KEY="sk_test_..." STRIPE_WEBHOOK_SECRET="whsec_..." NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="your-strong-secret" |
Explicación: Variables necesarias para DB, Stripe y NextAuth.
Código (prisma/schema.prisma):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? passwordHash String? role Role @default(USER) orders Order[] cart Cart? createdAt DateTime @default(now()) } enum Role { USER ADMIN } model Product { id String @id @default(cuid()) name String description String price Int // en centavos imageUrl String createdAt DateTime @default(now()) } model Cart { id String @id @default(cuid()) userId String @unique user User @relation(fields: [userId], references: [id]) items CartItem[] updatedAt DateTime @updatedAt } model CartItem { id String @id @default(cuid()) cartId String cart Cart @relation(fields: [cartId], references: [id]) productId String product Product @relation(fields: [productId], references: [id]) quantity Int @default(1) } model Order { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id]) status OrderStatus @default(PENDING) total Int paymentIntent String? items OrderItem[] createdAt DateTime @default(now()) } enum OrderStatus { PENDING PAID FAILED CANCELED } model OrderItem { id String @id @default(cuid()) orderId String order Order @relation(fields: [orderId], references: [id]) productId String product Product @relation(fields: [productId], references: [id]) quantity Int unitPrice Int } |
Explicación: Modelos para usuarios, productos, carrito, pedidos e ítems. Precios en centavos para evitar errores de flotantes.
Comandos de migración y seed:
1 2 3 4 |
pnpm prisma:generate pnpm prisma:migrate pnpm seed |
Código (prisma/seed.ts):
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const products = [ { name: "Camiseta Next.js", description: "100% algodón", price: 1999, imageUrl: "https://images.unsplash.com/photo-1512436991641-6745cdb1723f" }, { name: "Gorra TypeScript", description: "Edición limitada", price: 1499, imageUrl: "https://images.unsplash.com/photo-1512436991641-6745cdb1723f" } ]; await prisma.product.createMany({ data: products }); } main().finally(() => prisma.$disconnect()); |
Explicación: Crea productos iniciales para pruebas.
Checklist:
- [ ] DATABASE_URL configurado
- [ ] Migraciones aplicadas con éxito
- [ ] Datos seed insertados
5) Catálogo y UX: listado, filtros, SSR/ISR y caching
Resumen: Renderizamos productos en Server Components, con búsqueda y paginación. Habilitamos ISR.
Pasos:
- Página principal con listado paginado.
- Soporta query parameters ?q=&page=.
- Usa fetch/prisma en server y revalidate.
Código (app/page.tsx):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import { prisma } from "@/lib/prisma"; import ProductCard from "@/components/ProductCard"; export const revalidate = 60; // ISR de 60s async function getProducts(q: string | undefined, page = 1, pageSize = 12) { const where = q ? { name: { contains: q, mode: "insensitive" } } : {}; const [items, count] = await Promise.all([ prisma.product.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: "desc" } }), prisma.product.count({ where }) ]); return { items, count }; } export default async function Home({ searchParams }: { searchParams: { q?: string; page?: string } }) { const q = searchParams?.q; const page = Number(searchParams?.page || 1); const { items, count } = await getProducts(q, page); return ( <main className="container mx-auto p-6"> <h1 className="text-2xl font-bold mb-4">Tienda</h1> <form className="mb-4"> <input name="q" defaultValue={q} placeholder="Buscar" className="border p-2" /> <button className="ml-2 px-3 py-2 bg-black text-white">Buscar</button> </form> <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> {items.map(p => <ProductCard key={p.id} product={p} />)} </div> <div className="mt-4 flex gap-2"> {page > 1 && <a className="underline" href={`/?q=${q || ""}&page=${page - 1}`}>Anterior</a>} {page * 12 < count && <a className="underline" href={`/?q=${q || ""}&page=${page + 1}`}>Siguiente</a>} </div> </main> ); } |
Explicación: Server Component que consulta Prisma directamente; activa ISR con revalidate.
Código (components/ProductCard.tsx):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
'use client'; import Image from "next/image"; import { useState, useTransition } from "react"; export default function ProductCard({ product }: { product: { id: string; name: string; description: string; price: number; imageUrl: string } }) { const [pending, startTransition] = useTransition(); const [qty, setQty] = useState(1); async function addToCart() { startTransition(async () => { await fetch('/api/cart', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ productId: product.id, quantity: qty }) }); }); } return ( <div className="border p-4 rounded"> <Image src={product.imageUrl} alt={product.name} width={600} height={400} className="w-full h-48 object-cover" /> <h3 className="font-semibold mt-2">{product.name}</h3> <p className="text-sm text-gray-600">{product.description}</p> <p className="font-bold mt-2">{(product.price/100).toFixed(2)} €</p> <div className="flex items-center gap-2 mt-2"> <input type="number" min={1} value={qty} onChange={e=>setQty(Number(e.target.value))} className="w-16 border p-1" /> <button onClick={addToCart} disabled={pending} className="px-3 py-2 bg-blue-600 text-white"> {pending ? 'Agregando...' : 'Agregar al carrito'} </button> </div> </div> ); } |
Explicación: Client Component para interacción; llama a un endpoint de carrito (lo crearemos en la sección de carrito).
Checklist:
- [ ] Home SSR con paginación y búsqueda
- [ ] ISR activo para catálogo
- [ ] Tarjetas de producto funcionales
6) Carrito y checkout con Stripe (Checkout Sessions vs Payment Intents)
Resumen: Implementamos un carrito persistente en DB y un flujo de pago con Stripe Checkout.
Diferencias clave:
- Stripe Checkout: hosted page, rápida de integrar, ideal para MVPs y tiendas estándar.
- Payment Intents + Elements: más control UI/UX, ideal para flujos avanzados o internacionales.
Pasos carrito:
- Endpoint /api/cart para agregar/leer.
- Página /cart con botón de Checkout.
Código (app/api/cart/route.ts):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; export async function GET() { const session = await getServerSession(authOptions); if (!session) return NextResponse.json({ items: [] }); const cart = await prisma.cart.findUnique({ where: { userId: (session.user as any).id }, include: { items: { include: { product: true } } } }); return NextResponse.json(cart ?? { items: [] }); } export async function POST(req: Request) { const session = await getServerSession(authOptions); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { productId, quantity } = await req.json(); const userId = (session.user as any).id; let cart = await prisma.cart.upsert({ where: { userId }, update: {}, create: { userId } }); const existing = await prisma.cartItem.findFirst({ where: { cartId: cart.id, productId } }); if (existing) { await prisma.cartItem.update({ where: { id: existing.id }, data: { quantity: existing.quantity + (quantity || 1) } }); } else { await prisma.cartItem.create({ data: { cartId: cart.id, productId, quantity: quantity || 1 } }); } cart = await prisma.cart.findUnique({ where: { id: cart.id }, include: { items: { include: { product: true } } } }) as any; return NextResponse.json(cart); } |
Explicación: CRUD mínimo para leer/agregar al carrito asociado al usuario autenticado.
Página de carrito (app/cart/page.tsx):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
import CheckoutButton from "@/components/CheckoutButton"; import { prisma } from "@/lib/prisma"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import Image from "next/image"; import Link from "next/link"; export default async function CartPage() { const session = await getServerSession(authOptions); if (!session) return <div>Debes <a className="underline" href="/api/auth/signin">iniciar sesión</a>.</div>; const cart = await prisma.cart.findUnique({ where: { userId: (session.user as any).id }, include: { items: { include: { product: true } } } }); const total = cart?.items.reduce((acc, it) => acc + it.product.price * it.quantity, 0) || 0; return ( <div className="container mx-auto p-6"> <h2 className="text-xl font-bold mb-4">Tu carrito</h2> {!cart || cart.items.length === 0 ? ( <p>Carrito vacío. <Link className="underline" href="/">Ver productos</Link></p> ) : ( <> <ul className="space-y-3"> {cart.items.map(it => ( <li key={it.id} className="flex items-center gap-3"> <Image src={it.product.imageUrl} alt={it.product.name} width={80} height={80} /> <div className="flex-1"> <div>{it.product.name} x {it.quantity}</div> <div>{(it.product.price/100).toFixed(2)} €</div> </div> </li> ))} </ul> <div className="mt-4 flex items-center justify-between"> <div className="text-lg font-semibold">Total: {(total/100).toFixed(2)} €</div> <CheckoutButton /> </div> </> )} </div> ); } |
Explicación: Server Component que calcula total y muestra el botón de pago.
Código (components/CheckoutButton.tsx):
1 2 3 4 5 6 7 8 9 10 |
'use client'; export default function CheckoutButton() { async function checkout() { const res = await fetch('/api/checkout', { method: 'POST' }); const data = await res.json(); if (data.url) window.location.href = data.url; } return <button onClick={checkout} className="px-4 py-2 bg-green-600 text-white">Pagar con Stripe</button>; } |
Explicación: Crea una sesión de Checkout y redirige al usuario.
Código (lib/stripe.ts):
1 2 3 |
import Stripe from "stripe"; export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-06-20" }); |
Explicación: Cliente Stripe inicializado con API version fija.
Código (app/api/checkout/route.ts):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { stripe } from "@/lib/stripe"; export async function POST() { const session = await getServerSession(authOptions); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const userId = (session.user as any).id; const cart = await prisma.cart.findUnique({ where: { userId }, include: { items: { include: { product: true } } } }); if (!cart || cart.items.length === 0) return NextResponse.json({ error: "Empty cart" }, { status: 400 }); const line_items = cart.items.map(it => ({ quantity: it.quantity, price_data: { currency: 'eur', unit_amount: it.product.price, product_data: { name: it.product.name, images: [it.product.imageUrl] } } })); const checkout = await stripe.checkout.sessions.create({ mode: 'payment', payment_method_types: ['card'], line_items, success_url: `${process.env.NEXTAUTH_URL}/?success=1`, cancel_url: `${process.env.NEXTAUTH_URL}/cart`, metadata: { userId } }); await prisma.order.create({ data: { userId, status: 'PENDING', total: cart.items.reduce((acc, it) => acc + it.product.price * it.quantity, 0) } }); return NextResponse.json({ url: checkout.url }); } |
Explicación: Crea sesión de Checkout con line items y registra Orden PENDING.
Checklist:
- [ ] Endpoint de carrito listo
- [ ] Página /cart con total y botón de pago
- [ ] Endpoint /api/checkout funcionando
Diagrama de flujo pago:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
[Cliente] | click Checkout v /api/checkout (Next.js) -----> Stripe Checkout Session | \ | v |<----- URL de Checkout ----- [Stripe Hosted Page] | | | v | Pago completado | | v v Webhook Stripe --------------> /api/webhooks/stripe -> Actualiza Orden |
7) Webhooks y confirmación de pago segura
Resumen: Verificamos la firma del webhook, aplicamos idempotencia y actualizamos la orden.
Pasos:
- Crea endpoint /api/webhooks/stripe.
- Usa stripe.webhooks.constructEvent.
- Marca orden como PAID e implementa idempotencia.
Código (app/api/webhooks/stripe/route.ts):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { stripe } from "@/lib/stripe"; import { prisma } from "@/lib/prisma"; export async function POST(req: Request) { const body = await req.text(); const sig = headers().get("stripe-signature"); if (!sig) return NextResponse.json({ error: "No signature" }, { status: 400 }); let event; try { event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); } catch (err: any) { return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 }); } // Idempotencia simple usando metadata o event.id const processed = await prisma.webhookEvent.findUnique({ where: { id: event.id } }).catch(() => null); if (!processed) { await prisma.webhookEvent.create({ data: { id: event.id, type: event.type } }).catch(() => {}); } else { return NextResponse.json({ received: true }); } if (event.type === 'checkout.session.completed') { const session = event.data.object as any; const userId = session.metadata?.userId as string; await prisma.$transaction(async (tx) => { const cart = await tx.cart.findUnique({ where: { userId }, include: { items: true } }); if (!cart) return; const order = await tx.order.findFirst({ where: { userId, status: 'PENDING' }, orderBy: { createdAt: 'desc' } }); if (order) { await tx.order.update({ where: { id: order.id }, data: { status: 'PAID', paymentIntent: session.payment_intent || session.id } }); } await tx.cartItem.deleteMany({ where: { cartId: cart.id } }); }); } return NextResponse.json({ received: true }); } |
Nota: Añade el modelo WebhookEvent a Prisma (ver abajo). Explicación: Verifica firma, guarda event.id para idempotencia, actualiza orden y limpia carrito dentro de una transacción.
Amplía schema para idempotencia (añade a schema.prisma):
1 2 3 4 5 6 |
model WebhookEvent { id String @id type String at DateTime @default(now()) } |
Aplica migración: pnpm prisma:migrate
Probar con Stripe CLI:
1 2 3 4 5 |
stripe login stripe listen --forward-to localhost:3000/api/webhooks/stripe # En otra terminal, simula un evento stripe trigger checkout.session.completed |
Prueba con HTTPie/curl (para health):
1 2 3 |
http :3000/api/health curl -i http://localhost:3000/api/health |
Checklist:
- [ ] Webhook verifica firma correctamente
- [ ] Idempotencia implementada con tabla WebhookEvent
- [ ] Orden pasa de PENDING a PAID y carrito se vacía
8) Gestión de pedidos y panel admin (CRUD básico)
Resumen: Panel protegido para consultar productos y pedidos.
Pasos:
- Crea páginas admin protegidas.
- Lista pedidos y cambia estados.
Código (app/admin/orders/page.tsx):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import { prisma } from "@/lib/prisma"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { redirect } from "next/navigation"; export default async function AdminOrdersPage() { const session = await getServerSession(authOptions); if (!session || (session.user as any).role !== "ADMIN") redirect("/"); const orders = await prisma.order.findMany({ include: { items: { include: { product: true } }, user: true }, orderBy: { createdAt: 'desc' } }); return ( <div className="p-6"> <h2 className="text-xl font-bold mb-4">Pedidos</h2> <ul className="space-y-3"> {orders.map(o => ( <li key={o.id} className="border p-3"> <div>#{o.id.slice(0,6)} • {o.user.email} • {o.status} • {(o.total/100).toFixed(2)} €</div> </li> ))} </ul> </div> ); } |
Explicación: Vista simple de pedidos para admins.
Checklist:
- [ ] Rutas admin protegidas por rol
- [ ] Listado básico de pedidos implementado
9) Seguridad y buenas prácticas
Resumen: Asegura claves, valida entradas, limita peticiones y protege CSRF.
Pasos clave:
- Variables sensibles solo en el servidor (env y Vercel Project Env).
- Validación con zod en endpoints.
- Rate limiting a endpoints públicos (ej. usando headers/caches o middlewares externos).
- CSRF: en credenciales, usar NextAuth pages y POSTs con SameSite Lax; evita exponer endpoints mutadores al mundo sin auth.
- Limita tamaño de payload (Next.js bodySizeLimit en route handler si es necesario).
- CORS: webhooks de Stripe no requieren CORS; acepta solo POST y verifica firma.
Checklist:
- [ ] .env no comiteado
- [ ] Validación de inputs con zod
- [ ] Rutas mutadoras protegidas
- [ ] Verificación de firma en webhooks
10) Testing: unit, integration y E2E
Resumen: Añadimos tests unitarios con Jest y E2E con Playwright.
Pasos:
- Configura Jest con ts-jest.
- Test unitario simple.
- Playwright para flujo de checkout (hasta redirección).
Config Jest (jest.config.js):
1 2 3 4 5 6 |
module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', setupFilesAfterEnv: ['@testing-library/jest-dom'] }; |
Código test unitario (tests/unit/product.test.ts):
1 2 3 4 5 6 7 8 9 10 |
function formatPrice(cents: number) { return (cents / 100).toFixed(2) + ' €'; } describe('formatPrice', () => { it('formatea correctamente', () => { expect(formatPrice(1999)).toBe('19.99 €'); }); }); |
Explicación: Test mínimo de utilidad.
Playwright config (package.json ya incluye test:e2e). Test E2E (tests/e2e/checkout.spec.ts):
1 2 3 4 5 6 7 |
import { test, expect } from '@playwright/test'; test('listado y botón de checkout presente', async ({ page }) => { await page.goto('http://localhost:3000'); await expect(page.getByText('Tienda')).toBeVisible(); }); |
Explicación: Test E2E simple (puedes extender para login y add-to-cart si mockeas auth/stripe).
Ejecutar tests:
1 2 3 |
pnpm test pnpm test:e2e # requiere pnpm dev en otra terminal |
Checklist:
- [ ] Jest configurado y test unitario pasando
- [ ] Playwright instalado y test E2E básico operativo
11) Observabilidad y métricas
Resumen: Añadimos logging, health check y métricas Prometheus.
Pasos:
- Endpoint /api/health.
- Métricas con prom-client.
Código (app/api/health/route.ts):
1 2 3 4 |
export function GET() { return new Response(JSON.stringify({ ok: true, time: new Date().toISOString() }), { headers: { 'Content-Type': 'application/json' } }); } |
Código (app/api/metrics/route.ts):
1 2 3 4 5 6 7 8 |
import client from 'prom-client'; const register = new client.Registry(); client.collectDefaultMetrics({ register }); export async function GET() { return new Response(await register.metrics(), { headers: { 'Content-Type': register.contentType } }); } |
Explicación: Exporta métricas estándar; integrable con Prometheus.
Checklist:
- [ ] Health check responde 200
- [ ] Métricas disponibles en /api/metrics
12) Performance y SEO técnico en Next.js
Resumen: Mejora LCP/TTFB, usa next/image, metadata y accesibilidad.
Pasos:
- Usa Image optimizada y tamaños adecuados.
- Añade metadata en layout.
- Headings semánticos y ARIA.
- Revisa Lighthouse.
Código (app/layout.tsx):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import './globals.css'; import { Metadata } from 'next'; export const metadata: Metadata = { title: 'Next.js Store - Ejemplo completo', description: 'Tienda online con Next.js, Prisma y Stripe', openGraph: { title: 'Next.js Store', description: 'E-commerce moderno con Next.js', type: 'website' } }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="es"> <body className="min-h-screen antialiased">{children}</body> </html> ); } |
Explicación: Metadata para SEO/OG y layout básico.
Checklist:
- [ ] next/image usado en tarjetas
- [ ] Metadata rellenada
- [ ] Lighthouse sin fallos críticos
13) CI/CD y despliegue (Vercel o Docker)
Resumen: Pipeline con GitHub Actions para lint/test/build y deploy a Vercel. Alternativa: Docker multi-stage.
Pasos:
- Configura secrets en GitHub (DATABASEURL, STRIPESECRET_KEY, etc.).
- Pipeline CI con Node + pnpm.
Código (.github/workflows/ci.yml):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
name: CI on: [push, pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm prisma:generate - run: pnpm test - run: pnpm build |
Explicación: CI compila y prueba. Para desplegar en Vercel, conecta el repo desde el dashboard y configura variables de entorno.
Despliegue Vercel CLI (opcional):
1 2 3 |
vercel login vercel |
Checklist:
- [ ] Secrets configurados en el proveedor
- [ ] CI ejecuta lint/test/build
- [ ] Deploy realizado (Vercel recomendado)
14) Contenerización local con Docker (opcional)
Resumen: Dockerfile multi-stage y docker-compose con Postgres.
Código (Dockerfile):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
# Stage 1: deps FROM node:20-alpine AS deps WORKDIR /app COPY package.json pnpm-lock.yaml* ./ RUN corepack enable && pnpm i --frozen-lockfile # Stage 2: build FROM node:20-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN pnpm prisma:generate && pnpm build # Stage 3: runtime FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=build /app/.next ./.next COPY --from=build /app/public ./public COPY --from=build /app/package.json ./package.json COPY --from=build /app/node_modules ./node_modules EXPOSE 3000 CMD ["node", ".next/standalone/server.js"] |
Código (docker-compose.yml):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
version: '3.9' services: db: image: postgres:16 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: nextjs_store ports: ["5432:5432"] volumes: - pgdata:/var/lib/postgresql/data app: build: . environment: DATABASE_URL: postgres://postgres:postgres@db:5432/nextjs_store?schema=public NEXTAUTH_URL: http://localhost:3000 NEXTAUTH_SECRET: dev-secret STRIPE_SECRET_KEY: sk_test_... STRIPE_WEBHOOK_SECRET: whsec_... ports: ["3000:3000"] depends_on: [db] volumes: pgdata: {} |
Explicación: Levanta Postgres y la app. Ajusta STRIPE variables.
Comandos:
1 2 3 4 |
docker compose up -d pnpm prisma:migrate docker compose logs -f app |
Checklist:
- [ ] Docker build OK
- [ ] Postgres en contenedor
- [ ] App accesible en localhost:3000
15) Archivos y ejemplos clave extra
Código (lib/prisma.ts):
1 2 3 4 5 6 |
import { PrismaClient } from "@prisma/client"; const globalForPrisma = global as unknown as { prisma: PrismaClient }; export const prisma = globalForPrisma.prisma || new PrismaClient(); if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; |
Explicación: Singleton de Prisma para evitar conexiones extra en dev.
Código (components/CartIcon.tsx):
1 2 3 4 5 6 |
'use client'; import Link from 'next/link'; export default function CartIcon() { return <Link href="/cart" className="relative">🛒</Link>; } |
Explicación: Enlace rápido al carrito.
FAQ: preguntas frecuentes (respuestas directas)
- ¿Stripe Checkout o Payment Intents? Checkout para MVPs y rapidez; Payment Intents + Elements para UX altamente personalizada.
- ¿Dónde guardo el carrito? En DB (persistente) o en sesión/LocalStorage para guest; aquí usamos DB ligada al usuario.
- ¿Cómo pruebo webhooks? stripe listen –forward-to localhost:3000/api/webhooks/stripe y stripe trigger checkout.session.completed.
- ¿Puedo desplegar sin Vercel? Sí, con Docker en proveedores como Fly.io, Render o tu propio servidor.
- ¿Cómo evito montos incorrectos? Usa enteros en centavos y valida en el backend.
Resolución de problemas comunes
- Migraciones fallando: revisa DATABASE_URL, borra prisma/dev.db si usas SQLite, o run pnpm prisma:migrate reset (cuidado: borra datos) y vuelve a pnpm prisma:migrate.
- Webhooks no llegan: verifica stripe listen apuntando a la ruta correcta y que el puerto 3000 esté activo; usa STRIPEWEBHOOKSECRET correcto.
- Claves env faltantes: copia .env.example a .env.local y rellena todas; reinicia el servidor.
- CORS en webhooks: no uses fetch desde navegador; los webhooks son server-to-server, no requieren CORS.
- Errores de idempotencia: guarda event.id en tabla WebhookEvent y haz early return si ya procesado.
Checklist global final
- [ ] Entorno listo (Node, Stripe CLI, Postgres)
- [ ] Proyecto Next.js + TS creado (App Router)
- [ ] Prisma y migraciones aplicadas + seed
- [ ] NextAuth con Credentials y registro
- [ ] Catálogo SSR/ISR con búsqueda y paginación
- [ ] Carrito persistente y página /cart
- [ ] Checkout con Stripe Checkout Sessions
- [ ] Webhook verificado, idempotente y orden actualizada
- [ ] Panel admin protegido
- [ ] Tests Jest + Playwright básicos
- [ ] Observabilidad (health, metrics)
- [ ] SEO/Performance (metadata, image, Lighthouse)
- [ ] CI configurado y despliegue (Vercel/Docker)
Próximos pasos y conclusión
Hemos construido una base sólida de e‑commerce: catálogo ISR, carrito en DB, Checkout con Stripe, webhooks seguros, admin básico, tests y CI/CD. A partir de aquí, añade variantes (tallas/colores), cupones, email de confirmación, internacionalización y un diseño más pulido.