Skip to content

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.xml y /robots.txt.

Privado (sólo administradores)

  • /admin — lista de artículos (publicados y borradores).
  • /admin/nuevo y /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.ts

Paso 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 secret
env
# .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 = prisma
json
// 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 seed

Paso 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 } = handlers
ts
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ón

Despliegue en Vercel (lección 13):

  1. Crea una base de datos en Neon o Supabase y copia la DATABASE_URL.
  2. Ejecuta las migraciones y el seed contra esa base: DATABASE_URL="..." npx prisma migrate deploy && DATABASE_URL="..." npx prisma db seed.
  3. Importa el repositorio en Vercel y agrega DATABASE_URL, AUTH_SECRET y NEXT_PUBLIC_SITE_URL.
  4. 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.
  • [ ] robots bloquea /admin; el sitemap lista sólo artículos publicados.
  • [ ] .env.example documentado y README con instrucciones de instalación.

Retos para llevarlo más lejos ​

  1. Markdown: renderiza el contenido con react-markdown + remark-gfm y agrega vista previa en el formulario.
  2. Imágenes de portada: sube imágenes a Vercel Blob o Cloudinary desde el formulario y muéstralas con next/image.
  3. Categorías y etiquetas con su propia página /blog/categoria/[slug].
  4. Comentarios de lectores autenticados con GitHub, con moderación en el panel.
  5. Confirmación al eliminar: reemplaza el botón directo por un Client Component con confirm y useTransition.
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 panel admin con layouts distintos.
  • Prisma + una capa lib/posts.ts con server-only y cache alimentan Server Components y metadata.
  • Auth.js protege /admin en 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.

Academia Web — aprende, copia y construye.