Tema
Proyecto final
Es momento de unir todo lo aprendido en un proyecto real: DevBlog, un blog con panel de administración. Tendrá páginas públicas optimizadas para SEO, autenticación con roles, base de datos con Prisma, Server Actions validadas, revalidación de caché, sitemap y despliegue. Es exactamente lo que te pedirá un cliente que quiere "un sitio que yo mismo pueda actualizar".
Qué vas a construir
Público
/— portada con los últimos artículos./blog— listado con búsqueda y paginación./blog/[slug]— artículo con metadata dinámica, imagen OG y datos estructurados./sitemap.xmly/robots.txt.
Privado (sólo administradores)
/admin— lista de artículos (publicados y borradores)./admin/nuevoy/admin/[id]/editar— formulario con validación.- Publicar/despublicar y eliminar.
Tecnologías: Next.js (App Router), TypeScript, Tailwind, Prisma + PostgreSQL, Auth.js, zod.
Estructura
txt
devblog/
├── app/
│ ├── (publico)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── blog/
│ │ ├── page.tsx
│ │ └── [slug]/
│ │ ├── page.tsx
│ │ └── opengraph-image.tsx
│ ├── admin/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── acciones.ts
│ │ ├── FormPost.tsx
│ │ ├── nuevo/page.tsx
│ │ └── [id]/editar/page.tsx
│ ├── login/page.tsx
│ ├── api/auth/[...nextauth]/route.ts
│ ├── layout.tsx
│ ├── not-found.tsx
│ ├── sitemap.ts
│ ├── robots.ts
│ └── globals.css
├── lib/
│ ├── prisma.ts
│ ├── posts.ts
│ └── autorizacion.ts
├── prisma/
│ ├── schema.prisma
│ └── seed.ts
├── types/next-auth.d.ts
├── auth.ts
├── middleware.ts (proxy.ts en Next.js 16)
└── next.config.tsPaso 1: crear el proyecto
bash
npx create-next-app@latest devblog --ts --tailwind --eslint --app --use-npm --import-alias "@/*"
cd devblog
npm i next-auth@beta @prisma/client@6 zod bcryptjs server-only
npm i -D prisma@6 @types/bcryptjs
npx prisma init --datasource-provider postgresql
npx auth secretenv
# .env
DATABASE_URL="postgresql://postgres:secreto@localhost:5432/devblog"
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
# .env.local (lo generó npx auth secret)
# AUTH_SECRET="..."Paso 2: base de datos
txt
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Rol {
LECTOR
ADMIN
}
model Usuario {
id String @id @default(cuid())
nombre String
email String @unique
passwordHash String
rol Rol @default(LECTOR)
posts Post[]
creadoEn DateTime @default(now())
}
model Post {
id Int @id @default(autoincrement())
titulo String @db.VarChar(160)
slug String @unique
resumen String @db.VarChar(300)
contenido String
publicado Boolean @default(false)
publicadoEn DateTime?
autorId String
autor Usuario @relation(fields: [autorId], references: [id])
creadoEn DateTime @default(now())
actualizado DateTime @updatedAt
@@index([publicado, publicadoEn])
}ts
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
const admin = await prisma.usuario.upsert({
where: { email: 'admin@devblog.com' },
update: {},
create: {
nombre: 'Admin',
email: 'admin@devblog.com',
passwordHash: await bcrypt.hash('Admin1234', 12),
rol: 'ADMIN'
}
})
const posts = [
{ titulo: 'Bienvenido a DevBlog', slug: 'bienvenido', resumen: 'Primer artículo del blog.' },
{ titulo: 'Server Components en 5 minutos', slug: 'server-components', resumen: 'Qué son y por qué importan.' },
{ titulo: 'Cómo cotizar un sitio web', slug: 'cotizar-sitio-web', resumen: 'Guía práctica para freelancers.' }
]
for (const p of posts) {
await prisma.post.upsert({
where: { slug: p.slug },
update: {},
create: {
...p,
contenido: `${p.resumen}\n\nEste es el contenido completo del artículo. Escribe aquí párrafos separados por líneas en blanco.`,
publicado: true,
publicadoEn: new Date(),
autorId: admin.id
}
})
}
}
main().then(() => console.log('✅ Seed listo')).finally(() => prisma.$disconnect())ts
import 'server-only'
import { PrismaClient } from '@prisma/client'
const g = globalThis as unknown as { prisma?: PrismaClient }
export const prisma = g.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') g.prisma = prismajson
// package.json (agrega)
{
"prisma": { "seed": "node --experimental-strip-types prisma/seed.ts" },
"scripts": { "build": "prisma generate && next build" }
}bash
docker run -d --name devblog-db -e POSTGRES_PASSWORD=secreto -e POSTGRES_DB=devblog -p 5432:5432 postgres:17
npx prisma migrate dev --name init
npx prisma db seedPaso 3: autenticación
ts
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
export const { handlers, auth, signIn, signOut } = NextAuth({
session: { strategy: 'jwt' },
pages: { signIn: '/login' },
providers: [
Credentials({
credentials: { email: {}, password: {} },
async authorize(credenciales) {
const r = z.object({ email: z.email(), password: z.string().min(1) }).safeParse(credenciales)
if (!r.success) return null
const u = await prisma.usuario.findUnique({ where: { email: r.data.email.toLowerCase() } })
if (!u || !(await bcrypt.compare(r.data.password, u.passwordHash))) return null
return { id: u.id, name: u.nombre, email: u.email, rol: u.rol }
}
})
],
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id
token.rol = (user as { rol: string }).rol
}
return token
},
session({ session, token }) {
session.user.id = token.id as string
session.user.rol = token.rol as string
return session
},
authorized({ auth, request }) {
if (request.nextUrl.pathname.startsWith('/admin')) return auth?.user?.rol === 'ADMIN'
return true
}
}
})ts
// En Next.js 16 renombra el archivo a proxy.ts y usa: export { auth as proxy } from '@/auth'
export { auth as middleware } from '@/auth'
export const config = { matcher: ['/admin/:path*'] }ts
import { handlers } from '@/auth'
export const { GET, POST } = handlersts
import type { DefaultSession } from 'next-auth'
declare module 'next-auth' {
interface Session {
user: { id: string; rol: string } & DefaultSession['user']
}
}ts
import 'server-only'
import { redirect } from 'next/navigation'
import { auth } from '@/auth'
// Para páginas: redirige si no es admin
export async function exigirAdminPagina() {
const sesion = await auth()
if (sesion?.user?.rol !== 'ADMIN') redirect('/login?callbackUrl=/admin')
return sesion.user
}
// Para Server Actions: lanza error
export async function exigirAdmin() {
const sesion = await auth()
if (sesion?.user?.rol !== 'ADMIN') throw new Error('No autorizado')
return sesion.user
}tsx
import { AuthError } from 'next-auth'
import { redirect } from 'next/navigation'
import { signIn } from '@/auth'
export const metadata = { title: 'Iniciar sesión', robots: { index: false } }
type Props = { searchParams: Promise<{ error?: string; callbackUrl?: string }> }
export default async function Login({ searchParams }: Props) {
const { error, callbackUrl } = await searchParams
async function entrar(formData: FormData) {
'use server'
try {
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirectTo: String(formData.get('callbackUrl') || '/admin')
})
} catch (e) {
if (e instanceof AuthError) redirect('/login?error=1')
throw e
}
}
return (
<main className="mx-auto mt-20 max-w-sm rounded-2xl border p-8">
<h1 className="mb-6 text-2xl font-bold">Acceso administradores</h1>
<form action={entrar} className="space-y-3">
<input type="hidden" name="callbackUrl" value={callbackUrl ?? ''} />
<input name="email" type="email" placeholder="Email" required className="w-full rounded border px-3 py-2" />
<input name="password" type="password" placeholder="Contraseña" required className="w-full rounded border px-3 py-2" />
{error && <p role="alert" className="text-sm text-red-600">Email o contraseña incorrectos</p>}
<button className="w-full rounded bg-violet-600 py-2 text-white">Entrar</button>
</form>
</main>
)
}Paso 4: capa de datos de posts
ts
// lib/posts.ts
import 'server-only'
import { cache } from 'react'
import { prisma } from '@/lib/prisma'
export const POR_PAGINA = 6
export async function listarPublicados({ pagina = 1, q }: { pagina?: number; q?: string }) {
const where = {
publicado: true,
...(q && {
OR: [
{ titulo: { contains: q, mode: 'insensitive' as const } },
{ resumen: { contains: q, mode: 'insensitive' as const } }
]
})
}
const [posts, total] = await prisma.$transaction([
prisma.post.findMany({
where,
orderBy: { publicadoEn: 'desc' },
skip: (pagina - 1) * POR_PAGINA,
take: POR_PAGINA,
select: { id: true, titulo: true, slug: true, resumen: true, publicadoEn: true, autor: { select: { nombre: true } } }
}),
prisma.post.count({ where })
])
return { posts, total, paginas: Math.max(1, Math.ceil(total / POR_PAGINA)) }
}
export const obtenerPublicado = cache(async (slug: string) => {
return prisma.post.findFirst({
where: { slug, publicado: true },
include: { autor: { select: { nombre: true } } }
})
})
export async function slugsPublicados() {
return prisma.post.findMany({ where: { publicado: true }, select: { slug: true, actualizado: true } })
}
export const formatoFecha = (d: Date | null) =>
d ? new Intl.DateTimeFormat('es-MX', { dateStyle: 'long' }).format(d) : ''Paso 5: sitio público
tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'),
title: { default: 'DevBlog — Desarrollo web en español', template: '%s | DevBlog' },
description: 'Artículos prácticos sobre desarrollo web, Next.js y trabajo freelance.',
openGraph: { type: 'website', locale: 'es_MX', siteName: 'DevBlog' }
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es-MX" className={inter.variable}>
<body className="min-h-screen bg-white font-sans text-gray-900 antialiased">{children}</body>
</html>
)
}tsx
import Link from 'next/link'
export default function PublicoLayout({ children }: { children: React.ReactNode }) {
return (
<>
<header className="border-b">
<nav className="mx-auto flex max-w-3xl items-center gap-6 px-4 py-4">
<Link href="/" className="text-xl font-extrabold text-violet-700">DevBlog</Link>
<Link href="/blog" className="text-gray-600 hover:text-gray-900">Artículos</Link>
</nav>
</header>
<main className="mx-auto max-w-3xl px-4 py-10">{children}</main>
<footer className="border-t py-6 text-center text-sm text-gray-500">© {new Date().getFullYear()} DevBlog</footer>
</>
)
}tsx
import Link from 'next/link'
import { listarPublicados, formatoFecha } from '@/lib/posts'
export const revalidate = 3600 // ISR: además revalidamos al publicar
export default async function Inicio() {
const { posts } = await listarPublicados({ pagina: 1 })
return (
<>
<section className="mb-12">
<h1 className="text-4xl font-extrabold">Aprende a construir la web 🚀</h1>
<p className="mt-3 text-lg text-gray-600">Guías prácticas de Next.js, React y trabajo con clientes.</p>
</section>
<h2 className="mb-4 text-2xl font-bold">Lo más reciente</h2>
<div className="space-y-8">
{posts.map((p) => (
<article key={p.id}>
<time className="text-sm text-gray-500">{formatoFecha(p.publicadoEn)}</time>
<h3 className="text-xl font-semibold">
<Link href={`/blog/${p.slug}`} className="hover:text-violet-700">{p.titulo}</Link>
</h3>
<p className="text-gray-600">{p.resumen}</p>
</article>
))}
</div>
<Link href="/blog" className="mt-10 inline-block text-violet-700 underline">Ver todos los artículos →</Link>
</>
)
}tsx
import Link from 'next/link'
import { listarPublicados, formatoFecha } from '@/lib/posts'
export const metadata = { title: 'Artículos', alternates: { canonical: '/blog' } }
type Props = { searchParams: Promise<{ q?: string; pagina?: string }> }
export default async function Blog({ searchParams }: Props) {
const { q = '', pagina = '1' } = await searchParams
const actual = Math.max(1, Number(pagina) || 1)
const { posts, total, paginas } = await listarPublicados({ pagina: actual, q: q.trim() || undefined })
const url = (p: number) => `/blog?${new URLSearchParams({ ...(q && { q }), pagina: String(p) })}`
return (
<>
<h1 className="mb-6 text-3xl font-bold">Artículos</h1>
<form className="mb-8">
<input name="q" defaultValue={q} placeholder="Buscar artículos…" className="w-full rounded-lg border px-4 py-2" />
</form>
<p className="mb-6 text-sm text-gray-500">{total} artículo{total === 1 ? '' : 's'}</p>
<div className="space-y-8">
{posts.map((p) => (
<article key={p.id}>
<h2 className="text-xl font-semibold">
<Link href={`/blog/${p.slug}`} className="hover:text-violet-700">{p.titulo}</Link>
</h2>
<p className="text-sm text-gray-500">{formatoFecha(p.publicadoEn)} · {p.autor.nombre}</p>
<p className="mt-1 text-gray-600">{p.resumen}</p>
</article>
))}
{posts.length === 0 && <p>No encontramos artículos{q && ` para "${q}"`}.</p>}
</div>
<nav className="mt-10 flex justify-between" aria-label="Paginación">
{actual > 1 ? <Link href={url(actual - 1)}>← Más recientes</Link> : <span />}
{actual < paginas && <Link href={url(actual + 1)}>Anteriores →</Link>}
</nav>
</>
)
}tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { obtenerPublicado, slugsPublicados, formatoFecha } from '@/lib/posts'
type Props = { params: Promise<{ slug: string }> }
export async function generateStaticParams() {
const posts = await slugsPublicados()
return posts.map((p) => ({ slug: p.slug }))
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await obtenerPublicado((await params).slug)
if (!post) return { title: 'Artículo no encontrado' }
return {
title: post.titulo,
description: post.resumen,
alternates: { canonical: `/blog/${post.slug}` },
openGraph: { type: 'article', title: post.titulo, description: post.resumen, publishedTime: post.publicadoEn?.toISOString() }
}
}
export default async function Articulo({ params }: Props) {
const post = await obtenerPublicado((await params).slug)
if (!post) notFound()
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.titulo,
description: post.resumen,
datePublished: post.publicadoEn?.toISOString(),
dateModified: post.actualizado.toISOString(),
author: { '@type': 'Person', name: post.autor.nombre }
}
return (
<article>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }} />
<Link href="/blog" className="text-sm text-violet-700">← Artículos</Link>
<h1 className="mt-3 text-4xl font-extrabold leading-tight">{post.titulo}</h1>
<p className="mt-2 text-gray-500">{formatoFecha(post.publicadoEn)} · {post.autor.nombre}</p>
<div className="mt-8 space-y-4 text-lg leading-relaxed">
{/* Párrafos separados por líneas en blanco. Para Markdown real usa react-markdown. */}
{post.contenido.split(/\n{2,}/).map((parrafo, i) => (
<p key={i}>{parrafo}</p>
))}
</div>
</article>
)
}tsx
import { ImageResponse } from 'next/og'
import { obtenerPublicado } from '@/lib/posts'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Imagen({ params }: { params: Promise<{ slug: string }> }) {
const post = await obtenerPublicado((await params).slug)
return new ImageResponse(
(
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: 72, background: '#4c1d95', color: 'white' }}>
<div style={{ fontSize: 34, opacity: 0.8 }}>DevBlog</div>
<div style={{ fontSize: 66, fontWeight: 800, lineHeight: 1.1 }}>{post?.titulo ?? 'DevBlog'}</div>
<div style={{ fontSize: 28, opacity: 0.8 }}>{post?.resumen}</div>
</div>
),
size
)
}tsx
import Link from 'next/link'
export default function NoEncontrado() {
return (
<main className="mx-auto max-w-md p-20 text-center">
<h1 className="text-3xl font-bold">404</h1>
<p className="mt-2 text-gray-600">Esta página no existe.</p>
<Link href="/" className="mt-6 inline-block underline">Volver al inicio</Link>
</main>
)
}Paso 6: panel de administración
ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { exigirAdmin } from '@/lib/autorizacion'
import { signOut } from '@/auth'
const PostSchema = z.object({
titulo: z.string().trim().min(5, 'Mínimo 5 caracteres').max(160),
slug: z.string().trim().toLowerCase().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Sólo minúsculas, números y guiones'),
resumen: z.string().trim().min(20, 'Mínimo 20 caracteres').max(300),
contenido: z.string().trim().min(50, 'Mínimo 50 caracteres'),
publicado: z.preprocess((v) => v === 'on', z.boolean())
})
export type EstadoPost = { errores?: Record<string, string[] | undefined>; mensaje?: string }
function revalidarBlog(slug?: string) {
revalidatePath('/')
revalidatePath('/blog')
if (slug) revalidatePath(`/blog/${slug}`)
revalidatePath('/sitemap.xml')
revalidatePath('/admin')
}
export async function guardarPost(id: number | null, _: EstadoPost, formData: FormData): Promise<EstadoPost> {
const admin = await exigirAdmin()
const r = PostSchema.safeParse(Object.fromEntries(formData))
if (!r.success) return { errores: z.flattenError(r.error).fieldErrors, mensaje: 'Revisa los campos marcados' }
const datos = r.data
let slugAnterior: string | undefined
try {
if (id) {
const actual = await prisma.post.findUnique({ where: { id }, select: { slug: true, publicadoEn: true } })
if (!actual) return { mensaje: 'El artículo no existe' }
slugAnterior = actual.slug
await prisma.post.update({
where: { id },
data: { ...datos, publicadoEn: datos.publicado ? actual.publicadoEn ?? new Date() : null }
})
} else {
await prisma.post.create({
data: { ...datos, autorId: admin.id, publicadoEn: datos.publicado ? new Date() : null }
})
}
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return { errores: { slug: ['Ya existe un artículo con ese slug'] } }
}
return { mensaje: 'No se pudo guardar. Intenta de nuevo.' }
}
revalidarBlog(datos.slug)
if (slugAnterior && slugAnterior !== datos.slug) revalidatePath(`/blog/${slugAnterior}`)
redirect('/admin')
}
export async function alternarPublicado(id: number) {
await exigirAdmin()
const post = await prisma.post.findUnique({ where: { id } })
if (!post) return
await prisma.post.update({
where: { id },
data: { publicado: !post.publicado, publicadoEn: !post.publicado ? post.publicadoEn ?? new Date() : post.publicadoEn }
})
revalidarBlog(post.slug)
}
export async function eliminarPost(id: number) {
await exigirAdmin()
const post = await prisma.post.delete({ where: { id } })
revalidarBlog(post.slug)
}
export async function salir() {
await signOut({ redirectTo: '/' })
}tsx
import Link from 'next/link'
import { exigirAdminPagina } from '@/lib/autorizacion'
import { salir } from './acciones'
export const metadata = { title: 'Panel', robots: { index: false, follow: false } }
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const admin = await exigirAdminPagina() // segunda barrera tras el middleware
return (
<div className="min-h-screen bg-gray-50">
<header className="border-b bg-white">
<div className="mx-auto flex max-w-5xl items-center gap-6 px-4 py-3">
<Link href="/admin" className="font-bold text-violet-700">DevBlog · Panel</Link>
<Link href="/" className="text-sm text-gray-600">Ver sitio ↗</Link>
<span className="ml-auto text-sm text-gray-600">{admin.name}</span>
<form action={salir}><button className="text-sm underline">Salir</button></form>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-8">{children}</main>
</div>
)
}tsx
import Link from 'next/link'
import { prisma } from '@/lib/prisma'
import { alternarPublicado, eliminarPost } from './acciones'
export default async function Admin() {
const posts = await prisma.post.findMany({
orderBy: { actualizado: 'desc' },
select: { id: true, titulo: true, slug: true, publicado: true, actualizado: true }
})
return (
<>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Artículos ({posts.length})</h1>
<Link href="/admin/nuevo" className="rounded-lg bg-violet-600 px-4 py-2 text-white">+ Nuevo artículo</Link>
</div>
<ul className="divide-y rounded-xl border bg-white">
{posts.map((p) => (
<li key={p.id} className="flex flex-wrap items-center gap-3 p-4">
<div className="min-w-0 flex-1">
<p className="font-medium">{p.titulo}</p>
<p className="text-sm text-gray-500">/{p.slug} · editado {p.actualizado.toLocaleDateString('es-MX')}</p>
</div>
<span className={`rounded-full px-2 py-0.5 text-xs ${p.publicado ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>
{p.publicado ? 'Publicado' : 'Borrador'}
</span>
<form action={alternarPublicado.bind(null, p.id)}>
<button className="text-sm underline">{p.publicado ? 'Despublicar' : 'Publicar'}</button>
</form>
<Link href={`/admin/${p.id}/editar`} className="text-sm text-violet-700">Editar</Link>
<form action={eliminarPost.bind(null, p.id)}>
<button className="text-sm text-red-600">Eliminar</button>
</form>
</li>
))}
</ul>
</>
)
}tsx
'use client'
import { useActionState, useState } from 'react'
import type { EstadoPost } from './acciones'
type Post = { titulo: string; slug: string; resumen: string; contenido: string; publicado: boolean }
const slugify = (t: string) =>
t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
export default function FormPost({
accion,
post
}: {
accion: (estado: EstadoPost, formData: FormData) => Promise<EstadoPost>
post?: Post
}) {
const [estado, formAction, pendiente] = useActionState(accion, {})
const [titulo, setTitulo] = useState(post?.titulo ?? '')
const [slug, setSlug] = useState(post?.slug ?? '')
const [slugManual, setSlugManual] = useState(Boolean(post))
const err = (c: string) => estado.errores?.[c]?.[0]
return (
<form action={formAction} className="space-y-5 rounded-xl border bg-white p-6">
{estado.mensaje && <p role="alert" className="rounded bg-red-50 p-3 text-red-700">{estado.mensaje}</p>}
<label className="block">
<span className="font-medium">Título</span>
<input
name="titulo"
value={titulo}
onChange={(e) => {
setTitulo(e.target.value)
if (!slugManual) setSlug(slugify(e.target.value)) // slug automático
}}
className="mt-1 w-full rounded border px-3 py-2"
/>
{err('titulo') && <span className="text-sm text-red-600">{err('titulo')}</span>}
</label>
<label className="block">
<span className="font-medium">Slug</span>
<input
name="slug"
value={slug}
onChange={(e) => { setSlug(e.target.value); setSlugManual(true) }}
className="mt-1 w-full rounded border px-3 py-2 font-mono text-sm"
/>
{err('slug') && <span className="text-sm text-red-600">{err('slug')}</span>}
</label>
<label className="block">
<span className="font-medium">Resumen</span>
<textarea name="resumen" defaultValue={post?.resumen} rows={2} className="mt-1 w-full rounded border px-3 py-2" />
{err('resumen') && <span className="text-sm text-red-600">{err('resumen')}</span>}
</label>
<label className="block">
<span className="font-medium">Contenido</span>
<textarea name="contenido" defaultValue={post?.contenido} rows={14} className="mt-1 w-full rounded border px-3 py-2" />
{err('contenido') && <span className="text-sm text-red-600">{err('contenido')}</span>}
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="publicado" defaultChecked={post?.publicado ?? false} />
Publicar
</label>
<button disabled={pendiente} className="rounded-lg bg-violet-600 px-6 py-2 text-white disabled:opacity-60">
{pendiente ? 'Guardando…' : 'Guardar'}
</button>
</form>
)
}tsx
import FormPost from '../FormPost'
import { guardarPost } from '../acciones'
export default function NuevoPost() {
return (
<>
<h1 className="mb-6 text-2xl font-bold">Nuevo artículo</h1>
<FormPost accion={guardarPost.bind(null, null)} />
</>
)
}tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import FormPost from '../../FormPost'
import { guardarPost } from '../../acciones'
export default async function EditarPost({ params }: { params: Promise<{ id: string }> }) {
const id = Number((await params).id)
if (!Number.isInteger(id)) notFound()
const post = await prisma.post.findUnique({ where: { id } })
if (!post) notFound()
return (
<>
<h1 className="mb-6 text-2xl font-bold">Editar artículo</h1>
<FormPost accion={guardarPost.bind(null, id)} post={post} />
</>
)
}Paso 7: SEO técnico
ts
import type { MetadataRoute } from 'next'
import { slugsPublicados } from '@/lib/posts'
export const revalidate = 3600
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const sitio = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
const posts = await slugsPublicados()
return [
{ url: sitio, changeFrequency: 'weekly', priority: 1 },
{ url: `${sitio}/blog`, changeFrequency: 'daily', priority: 0.9 },
...posts.map((p) => ({ url: `${sitio}/blog/${p.slug}`, lastModified: p.actualizado, priority: 0.7 }))
]
}ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
const sitio = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/admin', '/login', '/api'] }],
sitemap: `${sitio}/sitemap.xml`
}
}Paso 8: probar y desplegar
bash
npm run dev
# Entra a /login con admin@devblog.com / Admin1234, crea un artículo y publícalo.
# Comprueba que aparece en / y en /blog sin recargar el servidor (revalidación).
npm run build && npm start # verifica el build de producciónDespliegue en Vercel (lección 13):
- Crea una base de datos en Neon o Supabase y copia la
DATABASE_URL. - Ejecuta las migraciones y el seed contra esa base:
DATABASE_URL="..." npx prisma migrate deploy && DATABASE_URL="..." npx prisma db seed. - Importa el repositorio en Vercel y agrega
DATABASE_URL,AUTH_SECRETyNEXT_PUBLIC_SITE_URL. - Despliega, cambia la contraseña del administrador y da de alta el sitemap en Google Search Console.
Checklist profesional
- [ ] El panel exige sesión en el middleware y en el layout y cada Server Action.
- [ ] Cada mutación revalida las rutas públicas afectadas.
- [ ] Títulos, descripciones, canonical, OG y JSON-LD en cada artículo.
- [ ]
robotsbloquea/admin; el sitemap lista sólo artículos publicados. - [ ]
.env.exampledocumentado y README con instrucciones de instalación.
Retos para llevarlo más lejos
- Markdown: renderiza el contenido con
react-markdown+remark-gfmy agrega vista previa en el formulario. - Imágenes de portada: sube imágenes a Vercel Blob o Cloudinary desde el formulario y muéstralas con
next/image. - Categorías y etiquetas con su propia página
/blog/categoria/[slug]. - Comentarios de lectores autenticados con GitHub, con moderación en el panel.
- Confirmación al eliminar: reemplaza el botón directo por un Client Component con
confirmyuseTransition.
Ver solución del reto 5
tsx
// app/admin/BotonEliminar.tsx
'use client'
import { useTransition } from 'react'
import { eliminarPost } from './acciones'
export default function BotonEliminar({ id, titulo }: { id: number; titulo: string }) {
const [pendiente, start] = useTransition()
return (
<button
disabled={pendiente}
className="text-sm text-red-600 disabled:opacity-50"
onClick={() => {
if (!confirm(`¿Eliminar "${titulo}"? Esta acción no se puede deshacer.`)) return
start(() => eliminarPost(id))
}}
>
{pendiente ? 'Eliminando…' : 'Eliminar'}
</button>
)
}
// En app/admin/page.tsx reemplaza el formulario de eliminar por:
// <BotonEliminar id={p.id} titulo={p.titulo} />Resumen
- Grupos de rutas separan el sitio público
(publico)del paneladmincon layouts distintos. - Prisma + una capa
lib/posts.tsconserver-onlyycachealimentan Server Components y metadata. - Auth.js protege
/adminen tres niveles: middleware, layout y cada Server Action. - Server Actions validadas con zod guardan datos y revalidan las páginas públicas (ISR bajo demanda).
- SEO completo: metadata dinámica, imágenes OG generadas, JSON-LD, sitemap y robots.
- 🎉 ¡Terminaste el curso de Next.js! Ya puedes entregar sitios administrables completos a tus clientes.