📋
Proyecto: NutriTrack Pro
Cliente
NutriTrack Pro SaaS
Migración
Pages → App Router
Stack
Next.js 14 + Prisma + NextAuth
🖥️
Server Components
Fichas de paciente, listas con filtros, historial de mediciones
Client Components
Gráficos Recharts, formularios de cita, selector de fecha
🎬
Server Actions
Crear plan nutricional, añadir medición, reservar cita
🌊
Parallel Routes + Streaming
Dashboard con paneles independientes, recomendaciones IA
🔌
Route Handlers
API wearables Garmin/Fitbit, webhooks de integración
🔄
ISR / Caché
Catálogo de planes nutricionales, revalidación cada hora
🗂️
Estructura App Router
Convenciones de archivos especiales Next.js 14
app/ — NutriTrack Pro
app/
├── layout.tsx <-- RootLayout: NextAuth + Providers
├── loading.tsx <-- Global skeleton
├── not-found.tsx <-- 404
├── dashboard/ <-- Parallel Routes (analytics + pacientes)
│ ├── layout.tsx <-- Recibe slots @analytics y @patients
│ ├── page.tsx [SERVER] Resumen del día
│ ├── @analytics/
│ │ ├── page.tsx [SERVER] KPIs clínica
│ │ └── loading.tsx ChartSkeleton
│ └── @patients/
│ ├── page.tsx [SERVER] Lista reciente
│ └── loading.tsx
├── patients/
│ ├── page.tsx [SERVER] Listado + filtros + Suspense
│ └── [id]/
│ ├── page.tsx [SERVER] Ficha paciente + Streaming
│ └── loading.tsx
├── plans/ <-- ISR: revalidate 3600
│ ├── page.tsx [STATIC/ISR] Catálogo de planes
│ └── [slug]/
│ └── page.tsx [ISR] Detalle plan + generateStaticParams
├── actions/
│ ├── patient.ts [SERVER ACTION] crear/actualizar paciente
│ ├── measurement.ts [SERVER ACTION] añadir medición
│ └── plan.ts [SERVER ACTION] asignar plan
└── api/
├── wearables/
│ ├── route.ts [ROUTE HANDLER] POST Garmin/Fitbit webhook
│ └── [device]/
│ └── route.ts GET datos por dispositivo
└── auth/
└── [...nextauth]/route.ts
🖥️
Patrón 1: Server Components con Data Fetching
SERVER
Caso NutriTrack: La página de listado de pacientes necesita filtros por nombre y objetivos. Se renderizan en servidor con Suspense para habilitar streaming de la lista mientras los filtros se muestran inmediatamente.
📄 app/patients/page.tsx Server Component
import { Suspense } from 'react'
import { PatientList, PatientListSkeleton } from '@/components/patients'
import { FilterBar } from '@/components/filters'

interface SearchParams {
  q?: string
  objetivo?: 'perdida-peso' | 'ganancia-muscular' | 'mantenimiento'
  page?: string
}

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

  return (
    <div className="space-y-6">
      <FilterBar initialValues={params} /> {/* Client Component */}
      <Suspense
        key={JSON.stringify(params)} {/* Re-trigger on filter change */}
        fallback={<PatientListSkeleton />}
      >
        <PatientList
          query={params.q}
          objetivo={params.objetivo}
          page={Number(params.page) || 1}
        />
      </Suspense>
    </div>
  )
}
📄 components/patients/PatientList.tsx Server Component
async function getPatients(filters: PatientFilters) {
  const patients = await prisma.patient.findMany({
    where: {
      clinicId: await getClinicId(), // Server-only: accede a session
      ...(filters.q && {
        OR: [
          { nombre: { contains: filters.q, mode: 'insensitive' } },
          { apellidos: { contains: filters.q, mode: 'insensitive' } },
        ],
      }),
      ...(filters.objetivo && { objetivo: filters.objetivo }),
    },
    include: { ultimaMedicion: true, nutricionista: true },
    orderBy: { updatedAt: 'desc' },
    take: 20,
    skip: (filters.page - 1) * 20,
  })
  return patients
}

export async function PatientList({ query, objetivo, page }: PatientFilters) {
  const patients = await getPatients({ query, objetivo, page })

  if (!patients.length) {
    return <EmptyState message="No hay pacientes con esos filtros" />
  }

  return (
    <div className="grid gap-3">
      {patients.map((patient) => (
        <PatientCard key={patient.id} patient={patient} />
      ))}
    </div>
  )
}
Patrón 2: Client Components
CLIENT
Regla de oro: 'use client' solo cuando necesitas hooks, eventos de usuario o APIs del navegador. Los gráficos de Recharts necesitan el DOM, así que van en Client Components.
📄 components/patients/WeightChart.tsx Client Component
'use client'

import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
import { useState } from 'react'

type Medicion = { fecha: string; peso: number; imc: number }

export function WeightChart({ mediciones }: { mediciones: Medicion[] }) {
  const [metric, setMetric] = useState<'peso' | 'imc'>('peso')

  return (
    <div className="space-y-3">
      <div className="flex gap-2">
        {['peso', 'imc'].map((m) => (
          <button
            key={m}
            onClick={() => setMetric(m as typeof metric)}
            className={metric === m ? 'btn-active' : 'btn-ghost'}
          >
            {m.toUpperCase()}
          </button>
        ))}
      </div>
      <ResponsiveContainer width="100%" height={240}>
        <LineChart data={mediciones}>
          <XAxis dataKey="fecha" />
          <YAxis />
          <Tooltip />
          <Line dataKey={metric} stroke="#00d4aa" strokeWidth={2} />
        </LineChart>
      </ResponsiveContainer>
    </div>
  )
}
🎬
Patrón 3: Server Actions
ACTION
📄 app/actions/measurement.ts Server Action
"use server"

import { revalidatePath, revalidateTag } from "next/cache"
import { getServerSession } from "next-auth"
import { redirect } from "next/navigation"
import { z } from "zod"

const MedicionSchema = z.object({
  patientId: z.string().cuid(),
  peso:      z.number().min(20).max(300),
  grasa:     z.number().min(0).max(100).optional(),
  musculo:   z.number().min(0).max(100).optional(),
  notas:     z.string().max(500).optional(),
})

export async function addMedicion(formData: FormData) {
  const session = await getServerSession()
  if (!session) redirect('/login')

  const parsed = MedicionSchema.safeParse({
    patientId: formData.get('patientId'),
    peso:      Number(formData.get('peso')),
    grasa:     formData.get('grasa') ? Number(formData.get('grasa')) : undefined,
    notas:     formData.get('notas') as string,
  })

  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors }
  }

  try {
    await prisma.medicion.create({
      data: { ...parsed.data, registradoPor: session.user.id },
    })
    // Revalidar caché del paciente y del dashboard
    revalidateTag(`patient-${parsed.data.patientId}`)
    revalidatePath(`/patients/${parsed.data.patientId}`)
    revalidatePath('/dashboard')
    return { success: true }
  } catch {
    return { error: 'Error al guardar la medición' }
  }
}
🌊
Patrón 4: Parallel Routes — Dashboard
PARALLEL
Caso NutriTrack: El dashboard carga 3 paneles independientes: resumen del día, analytics de la clínica y pacientes recientes. Con Parallel Routes cada slot tiene su propio loading.tsx y no bloquea a los demás.
📄 app/dashboard/layout.tsx Server
export default function DashboardLayout({
  children,
  analytics,
  patients,
}: {
  children:  React.ReactNode
  analytics: React.ReactNode
  patients:  React.ReactNode
}) {
  return (
    <div className="grid grid-cols-3 gap-4">
      <main className="col-span-2">
        {children}
      </main>
      <aside className="space-y-4">
        {analytics}
        {patients}
      </aside>
    </div>
  )
}
📄 @analytics/page.tsx Server
// Carga independiente — no bloquea @patients
export default async function AnalyticsSlot() {
  const kpis = await getClinicKPIs() // puede ser lento

  return (
    <div className="bg-card rounded-xl p-4">
      <h3>Resumen clínica</h3>
      <KPIGrid kpis={kpis} />
    </div>
  )
}

// @analytics/loading.tsx
export default function AnalyticsLoading() {
  return <KPISkeleton />
}
Patrón 5: Streaming con Suspense — Ficha de Paciente
STREAMING
📄 app/patients/[id]/page.tsx Server Component
import { Suspense } from 'react'

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

  // Carga inmediata — datos básicos (rápido)
  const patient = await getPatient(id)
  if (!patient) notFound()

  return (
    <div className="space-y-6">
      <PatientHeader patient={patient} />    {/* Inmediato */}

      {/* Stream: historial de mediciones */}
      <Suspense fallback={<MedicionesSkeleton />}>
        <MedicionesHistory patientId={id} />
      </Suspense>

      {/* Stream: recomendaciones IA (muy lento, modelo ML) */}
      <Suspense fallback={<AIRecommendationsSkeleton />}>
        <AIRecommendations patientId={id} />
      </Suspense>

      {/* Stream: próximas citas */}
      <Suspense fallback={<AppointmentsSkeleton />}>
        <UpcomingAppointments patientId={id} />
      </Suspense>
    </div>
  )
}

// AIRecommendations: fetcha modelo ML externo (puede tardar 3-5s)
async function AIRecommendations({ patientId }: { patientId: string }) {
  const recs = await fetch(`${process.env.ML_API}/recommendations/${patientId}`, {
    cache: 'no-store' // Siempre fresco para cada paciente
  }).then((r) => r.json())
  return <RecommendationList items={recs} />
}
🔌
Patrón 6: Route Handlers — API Wearables
API
📄 app/api/wearables/route.ts Route Handler
import { NextRequest, NextResponse } from 'next/server'
import { verifyWebhookSignature } from '@/lib/webhooks'

// POST: Recibe datos de Garmin/Fitbit vía webhook
export async function POST(request: NextRequest) {
  const signature = request.headers.get('x-webhook-signature')
  const body = await request.json()

  // Verificar firma del webhook
  if (!verifyWebhookSignature(body, signature, process.env.WEBHOOK_SECRET!)) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const { userId, device, metrics } = body

    // Procesar y almacenar métricas del wearable
    await prisma.wearableData.create({
      data: {
        patientId: userId,
        device,
        pasos:     metrics.steps,
        calorias:  metrics.calories,
        fcMedia:   metrics.avg_heart_rate,
        timestamp: new Date(metrics.timestamp),
      },
    })

    return NextResponse.json({ received: true }, { status: 200 })
  } catch {
    return NextResponse.json({ error: 'Internal error' }, { status: 500 })
  }
}

// GET: Devuelve datos históricos para un paciente
export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl
  const patientId = searchParams.get('patientId')
  const days = Number(searchParams.get('days')) || 30

  const data = await prisma.wearableData.findMany({
    where: {
      patientId: patientId!,
      timestamp: { gte: new Date(Date.now() - days * 86400000) },
    },
    orderBy: { timestamp: 'asc' },
  })

  return NextResponse.json(data)
}
🔄
Patrón 7: ISR — Catálogo de Planes Nutricionales
ISR
📄 app/plans/[slug]/page.tsx ISR — revalidate 3600s
import { Metadata } from 'next'
import { notFound } from 'next/navigation'

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

// Pre-generar todas las páginas de planes en build time
export async function generateStaticParams() {
  const planes = await prisma.planNutricional.findMany({
    select: { slug: true },
    where:  { publicado: true },
  })
  return planes.map((p) => ({ slug: p.slug }))
}

// SEO dinámico por plan
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const plan = await getPlan(slug)
  if (!plan) return {}
  return {
    title:       plan.nombre,
    description: plan.descripcion,
    openGraph: {
      title:  plan.nombre,
      images: [{ url: plan.imagen, width: 1200, height: 630 }],
    },
  }
}

async function getPlan(slug: string) {
  return fetch(`${process.env.API_URL}/plans/${slug}`, {
    next: {
      revalidate: 3600,               // ISR: actualizar cada hora
      tags: ['plans', `plan-${slug}`], // Invalidación selectiva
    },
  }).then((r) => r.json())
}

export default async function PlanPage({ params }: Props) {
  const { slug } = await params
  const plan = await getPlan(slug)
  if (!plan) notFound()
  return <PlanDetail plan={plan} />
}
📐
Reglas de Oro para NutriTrack
DO — Hacer
  • Empezar como Server Component y añadir 'use client' solo si necesitas hooks o eventos DOM
  • Colocar el fetch donde se usa — cada Server Component pide sus propios datos
  • Usar Suspense boundaries — especialmente para las recomendaciones IA (lentas)
  • Validar con Zod en Server Actions antes de tocar Prisma
  • revalidateTag granular después de mutaciones para no invalidar caché innecesariamente
DON'T — No hacer
  • No uses useState/useEffect en componentes de ficha de paciente o listas — son Server Components
  • No pases objetos no serializables (clases, funciones, Dates de Prisma) del Server al Client sin transformar
  • No anides demasiados layouts — el dashboard ya tiene 2 niveles, no añadir más sin justificación
  • No hagas fetch en componentes cliente — usa Server Components para datos iniciales y React Query solo para actualizaciones en vivo
  • No ignores loading.tsx — cada ruta con datos lentos debe tener su skeleton de carga
NutriTrack Pro SaaS — Arquitectura Next.js 14 App Router · Generado con CULTIVA IA · skill: patrones-app-router-nextjs