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
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
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
📄 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
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
📄 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
📄 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
📄 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