Communitygithub.com

Samueladewole/claude-skills

Domina Next.js 14+ con App Router, Server Components, streaming, rutas paralelas y obtención de datos avanzada. Úsalo al construir aplicaciones Next.js, implementar SSR/SSG u optimizar React Server Components.

¿Qué es claude-skills?

Este skill proporciona una guía completa y patrones prácticos para desarrollar aplicaciones modernas con Next.js 14+ y su App Router. Está diseñado para ayudar a desarrolladores y agentes de IA a implementar eficazmente Server Components, optimizar la obtención de datos, manejar el streaming con Suspense y gestionar rutas complejas como rutas paralelas e interceptadas. Además, incluye ejemplos detallados de Server Actions, Route Handlers y estrategias avanzadas de caché (ISR, revalidación por etiquetas). Es ideal para proyectos que requieran un enfoque full-stack con React, desde la migración desde Pages Router hasta la creación de nuevas aplicaciones con las mejores prácticas del ecosistema Next.js.

Compatible con✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/Samueladewole/claude-skills/tree/HEAD/nextjs-app-router-patterns

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

Patrones del App Router de Next.js

Patrones completos para la arquitectura del App Router de Next.js 14+, Server Components y desarrollo moderno full-stack con React.

Cuándo usar este skill

  • Construir nuevas aplicaciones Next.js con App Router
  • Migrar de Pages Router a App Router
  • Implementar Server Components y streaming
  • Configurar rutas paralelas e interceptadas
  • Optimizar la obtención de datos y el caché
  • Crear funcionalidades full-stack con Server Actions

Conceptos principales

1. Modos de renderizado

ModoDóndeCuándo usarlo
Server ComponentsSolo servidorObtención de datos, cómputo pesado, secretos
Client ComponentsNavegadorInteractividad, hooks, APIs del navegador
EstáticoTiempo de buildContenido que rara vez cambia
DinámicoTiempo de solicitudDatos personalizados o en tiempo real
StreamingProgresivoPáginas grandes, fuentes de datos lentas

2. Convenciones de archivos

app/
├── layout.tsx       # Contenedor compartido de UI
├── page.tsx         # UI de la ruta
├── loading.tsx      # UI de carga (Suspense)
├── error.tsx        # Límite de error
├── not-found.tsx    # UI 404
├── route.ts         # Endpoint de API
├── template.tsx     # Layout que se vuelve a montar
├── default.tsx      # Respaldo de ruta paralela
└── opengraph-image.tsx  # Generación de imagen OG

Inicio rápido

// app/layout.tsx
import { Inter } from 'next/font/google'
import { Providers } from './providers'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: { default: 'Mi App', template: '%s | Mi App' },
  description: 'Construida con Next.js App Router',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

// app/page.tsx - Server Component por defecto
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 }, // ISR: revalidar cada hora
  })
  return res.json()
}

export default async function HomePage() {
  const products = await getProducts()

  return (
    <main>
      <h1>Productos</h1>
      <ProductGrid products={products} />
    </main>
  )
}

Patrones

Patrón 1: Server Components con obtención de datos

// app/products/page.tsx
import { Suspense } from 'react'
import { ProductList, ProductListSkeleton } from '@/components/products'
import { FilterSidebar } from '@/components/filters'

interface SearchParams {
  category?: string
  sort?: 'price' | 'name' | 'date'
  page?: string
}

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Promise<SearchParams>
}) {
  const params = await searchParams

  return (
    <div className="flex gap-8">
      <FilterSidebar />
      <Suspense
        key={JSON.stringify(params)}
        fallback={<ProductListSkeleton />}
      >
        <ProductList
          category={params.category}
          sort={params.sort}
          page={Number(params.page) || 1}
        />
      </Suspense>
    </div>
  )
}

// components/products/ProductList.tsx - Server Component
async function getProducts(filters: ProductFilters) {
  const res = await fetch(
    `${process.env.API_URL}/products?${new URLSearchParams(filters)}`,
    { next: { tags: ['products'] } }
  )
  if (!res.ok) throw new Error('Error al obtener productos')
  return res.json()
}

export async function ProductList({ category, sort, page }: ProductFilters) {
  const { products, totalPages } = await getProducts({ category, sort, page })

  return (
    <div>
      <div className="grid grid-cols-3 gap-4">
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
      <Pagination currentPage={page} totalPages={totalPages} />
    </div>
  )
}

Patrón 2: Client Components con 'use client'

// components/products/AddToCartButton.tsx
'use client'

import { useState, useTransition } from 'react'
import { addToCart } from '@/app/actions/cart'

export function AddToCartButton({ productId }: { productId: string }) {
  const [isPending, startTransition] = useTransition()
  const [error, setError] = useState<string | null>(null)

  const handleClick = () => {
    setError(null)
    startTransition(async () => {
      const result = await addToCart(productId)
      if (result.error) {
        setError(result.error)
      }
    })
  }

  return (
    <div>
      <button
        onClick={handleClick}
        disabled={isPending}
        className="btn-primary"
      >
        {isPending ? 'Añadiendo...' : 'Añadir al carrito'}
      </button>
      {error && <p className="text-red-500 text-sm">{error}</p>}
    </div>
  )
}

Patrón 3: Server Actions

// app/actions/cart.ts
"use server"

import { revalidateTag } from "next/cache"
import { cookies } from "next/headers"
import { redirect } from "next/navigation"

export async function addToCart(productId: string) {
  const cookieStore = await cookies()
  const sessionId = cookieStore.get("session")?.value

  if (!sessionId) {
    redirect("/login")
  }

  try {
    await db.cart.upsert({
      where: { sessionId_productId: { sessionId, productId } },
      update: { quantity: { increment: 1 } },
      create: { sessionId, productId, quantity: 1 },
    })

    revalidateTag("cart")
    return { success: true }
  } catch (error) {
    return { error: "Error al añadir el artículo al carrito" }
  }
}

export async function checkout(formData: FormData) {
  const address = formData.get("address") as string
  const payment = formData.get("payment") as string

  // Validar
  if (!address || !payment) {
    return { error: "Faltan campos obligatorios" }
  }

  // Procesar pedido
  const order = await processOrder({ address, payment })

  // Redirigir a la confirmación
  redirect(`/orders/${order.id}/confirmation`)
}

Patrón 4: Rutas paralelas

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div className="dashboard-grid">
      <main>{children}</main>
      <aside className="analytics-panel">{analytics}</aside>
      <aside className="team-panel">{team}</aside>
    </div>
  )
}

// app/dashboard/@analytics/page.tsx
export default async function AnalyticsSlot() {
  const stats = await getAnalytics()
  return <AnalyticsChart data={stats} />
}

// app/dashboard/@analytics/loading.tsx
export default function AnalyticsLoading() {
  return <ChartSkeleton />
}

// app/dashboard/@team/page.tsx
export default async function TeamSlot() {
  const members = await getTeamMembers()
  return <TeamList members={members} />
}

Patrón 5: Rutas interceptadas (Patrón modal)

// Estructura de archivos para el modal de fotos
// app/
// ├── @modal/
// │   ├── (.)photos/[id]/page.tsx  # Interceptar
// │   └── default.tsx
// ├── photos/
// │   └── [id]/page.tsx            # Página completa
// └── layout.tsx

// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/Modal'
import { PhotoDetail } from '@/components/PhotoDetail'

export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const photo = await getPhoto(id)

  return (
    <Modal>
      <PhotoDetail photo={photo} />
    </Modal>
  )
}

// app/photos/[id]/page.tsx - Versión de página completa
export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const photo = await getPhoto(id)

  return (
    <div className="photo-page">
      <PhotoDetail photo={photo} />
      <RelatedPhotos photoId={id} />
    </div>
  )
}

// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <html>
      <body>
        {children}
        {modal}
      </body>
    </html>
  )
}

Patrón 6: Streaming con Suspense

// app/product/[id]/page.tsx
import { Suspense } from 'react'

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params

  // Estos datos se cargan primero (bloqueante)
  const product = await getProduct(id)

  return (
    <div>
      {/* Renderizado inmediato */}
      <ProductHeader product={product} />

      {/* Transmitir reseñas */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={id} />
      </Suspense>

      {/* Transmitir recomendaciones */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations productId={id} />
      </Suspense>
    </div>
  )
}

// Estos componentes obtienen sus propios datos
async function Reviews({ productId }: { productId: string }) {
  const reviews = await getReviews(productId) // API lenta
  return <ReviewList reviews={reviews} />
}

async function Recommendations({ productId }: { productId: string }) {
  const products = await getRecommendations(productId) // Basado en ML, lento
  return <ProductCarousel products={products} />
}

Patrón 7: Route Handlers (API Routes)

// app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server"

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const category = searchParams.get("category")

  const products = await db.product.findMany({
    where: category ? { category } : undefined,
    take: 20,
  })

  return NextResponse.json(products)
}

export async function POST(request: NextRequest) {
  const body = await request.json()

  const product = await db.product.create({
    data: body,
  })

  return NextResponse.json(product, { status: 201 })
}

// app/api/products/[id]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params
  const product = await db.product.findUnique({ where: { id } })

  if (!product) {
    return NextResponse.json({ error: "Producto no encontrado" }, { status: 404 })
  }

  return NextResponse.json(product)
}

Patrón 8: Metadatos y SEO

// app/products/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'

type Props = {
  params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const product = await getProduct(slug)

  if (!product) return {}

  return {
    title: product.name,
    description: product.description,
    openGraph: {
      title: product.name,
      description: product.description,
      images: [{ url: product.image, width: 1200, height: 630 }],
    },
    twitter: {
      card: 'summary_large_image',
      title: product.name,
      description: product.description,
      images: [product.image],
    },
  }
}

export async function generateStaticParams() {
  const products = await db.product.findMany({ select: { slug: true } })
  return products.map((p) => ({ slug: p.slug }))
}

export default async function ProductPage({ params }: Props) {
  const { slug } = await params
  const product = await getProduct(slug)

  if (!product) notFound()

  return <ProductDetail product={product} />
}

Estrategias de caché

Caché de datos

// Sin caché (siempre fresco)
fetch(url, { cache: "no-store" })

// Caché permanente (estático)
fetch(url, { cache: "force-cache" })

// ISR - revalidar después de 60 segundos
fetch(url, { next: { revalidate: 60 } })

// Invalidación por etiqueta
fetch(url, { next: { tags: ["products"] } })

// Invalidar mediante Server Action
"use server"
import { revalidateTag, revalidatePath } from "next/cache"

export async function updateProduct(id: string, data: ProductData) {
  await db.product.update({ where: { id }, data })
  revalidateTag("products")
  revalidatePath("/products")
}

Mejores prácticas

Qué hacer

  • Empieza con Server Components - Añade 'use client' solo cuando sea necesario
  • Coloca la obtención de datos cerca - Obtén datos donde se usen
  • Usa límites de Suspense - Habilita el streaming para datos lentos
  • Aprovecha las rutas paralelas - Estados de carga independientes
  • Usa Server Actions - Para mutaciones con mejora progresiva

Qué evitar

  • No pases datos no serializables - Limitaciones en el límite Servidor → Cliente
  • No uses hooks en Server Components - No useState, useEffect
  • No obtengas datos en Client Components - Usa Server Components o React Query
  • No anides layouts en exceso - Cada layout se añade al árbol de componentes
  • No ignores los estados de carga - Siempre proporciona loading.tsx o Suspense

Recursos

Individual skills in this repo

This repo contains 5 individual skills — each has its own dedicated page.

Skills relacionados