Guia Practica de Next.js App Router

Intermedio | 70 min de lectura | 2024.12.17

Lo que aprenderas en este tutorial

✓ Estructura basica de App Router
✓ Server Components y Client Components
✓ Obtencion de datos
✓ Enrutamiento y layouts
✓ Carga y manejo de errores
✓ Server Actions

Requisitos previos

  • Conocimientos basicos de React
  • Conocimientos basicos de TypeScript
  • Node.js 18 o superior instalado

Configuracion del proyecto

# Crear proyecto
npx create-next-app@latest my-app --typescript --tailwind --eslint --app

cd my-app
npm run dev

Estructura de directorios

my-app/
├── app/
│   ├── layout.tsx      # Layout raiz
│   ├── page.tsx        # Pagina de inicio
│   ├── globals.css     # Estilos globales
│   └── ...
├── components/         # Componentes compartidos
├── lib/               # Funciones de utilidad
└── public/            # Archivos estaticos

Step 1: Fundamentos de App Router

Enrutamiento basado en archivos

app/
├── page.tsx                    # / (inicio)
├── about/
│   └── page.tsx               # /about
├── blog/
│   ├── page.tsx               # /blog
│   └── [slug]/
│       └── page.tsx           # /blog/:slug
├── products/
│   ├── page.tsx               # /products
│   └── [...categories]/
│       └── page.tsx           # /products/* (catch-all)
└── (marketing)/
    ├── pricing/
    │   └── page.tsx           # /pricing
    └── contact/
        └── page.tsx           # /contact

Componente de pagina basico

// app/page.tsx
export default function HomePage() {
  return (
    <main className="container mx-auto p-4">
      <h1 className="text-4xl font-bold">Inicio</h1>
      <p className="mt-4">Bienvenido a Next.js App Router!</p>
    </main>
  );
}

Enrutamiento dinamico

// app/blog/[slug]/page.tsx
interface Props {
  params: Promise<{ slug: string }>;
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;

  return (
    <article>
      <h1>Articulo del blog: {slug}</h1>
    </article>
  );
}

// Generacion de parametros estaticos
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(res => res.json());

  return posts.map((post: { slug: string }) => ({
    slug: post.slug,
  }));
}

Step 2: Layouts y plantillas

Layout raiz

// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

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

export const metadata: Metadata = {
  title: {
    default: 'My App',
    template: '%s | My App',
  },
  description: 'Next.js App Router Demo',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="es">
      <body className={inter.className}>
        <header className="bg-gray-800 text-white p-4">
          <nav className="container mx-auto flex gap-4">
            <a href="/">Inicio</a>
            <a href="/blog">Blog</a>
            <a href="/about">About</a>
          </nav>
        </header>
        {children}
        <footer className="bg-gray-100 p-4 mt-8">
          <p className="text-center">© 2024 My App</p>
        </footer>
      </body>
    </html>
  );
}

Layouts anidados

// app/blog/layout.tsx
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="container mx-auto flex gap-8 p-4">
      <aside className="w-64">
        <h2 className="font-bold mb-4">Categorias</h2>
        <ul className="space-y-2">
          <li><a href="/blog?category=tech">Tecnologia</a></li>
          <li><a href="/blog?category=design">Diseno</a></li>
          <li><a href="/blog?category=business">Negocios</a></li>
        </ul>
      </aside>
      <main className="flex-1">{children}</main>
    </div>
  );
}

Grupos de rutas

// app/(auth)/layout.tsx
// (auth) no afecta la URL
export default function AuthLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-100">
      <div className="bg-white p-8 rounded-lg shadow-md w-96">
        {children}
      </div>
    </div>
  );
}

// app/(auth)/login/page.tsx → /login
// app/(auth)/register/page.tsx → /register

Step 3: Server Components y Client Components

Server Components (por defecto)

// app/products/page.tsx
// Este es un Server Component (por defecto)
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    cache: 'force-cache', // Generacion estatica
  });
  return res.json();
}

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

  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map((product: { id: number; name: string; price: number }) => (
        <div key={product.id} className="border p-4 rounded">
          <h2>{product.name}</h2>
          <p>${product.price.toLocaleString()}</p>
        </div>
      ))}
    </div>
  );
}

Client Components

// components/Counter.tsx
'use client'; // Especificar como Client Component

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div className="flex items-center gap-4">
      <button
        onClick={() => setCount(count - 1)}
        className="px-4 py-2 bg-gray-200 rounded"
      >
        -
      </button>
      <span className="text-2xl">{count}</span>
      <button
        onClick={() => setCount(count + 1)}
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        +
      </button>
    </div>
  );
}

Patrones de uso

// app/dashboard/page.tsx (Server Component)
import Counter from '@/components/Counter';
import UserProfile from '@/components/UserProfile';

async function getUserData() {
  const res = await fetch('https://api.example.com/user');
  return res.json();
}

export default async function DashboardPage() {
  // Obtener datos del lado del servidor
  const user = await getUserData();

  return (
    <div>
      {/* Pasar datos con Server Component */}
      <UserProfile user={user} />

      {/* Funcionalidad interactiva con Client Component */}
      <Counter />
    </div>
  );
}
// components/UserProfile.tsx (Server Component)
interface User {
  name: string;
  email: string;
  avatar: string;
}

export default function UserProfile({ user }: { user: User }) {
  return (
    <div className="flex items-center gap-4">
      <img src={user.avatar} alt={user.name} className="w-12 h-12 rounded-full" />
      <div>
        <p className="font-bold">{user.name}</p>
        <p className="text-gray-600">{user.email}</p>
      </div>
    </div>
  );
}

Step 4: Obtencion de datos

Obtencion de datos en paralelo

// app/dashboard/page.tsx
async function getUser() {
  const res = await fetch('https://api.example.com/user');
  return res.json();
}

async function getNotifications() {
  const res = await fetch('https://api.example.com/notifications');
  return res.json();
}

async function getStats() {
  const res = await fetch('https://api.example.com/stats');
  return res.json();
}

export default async function DashboardPage() {
  // Obtener datos en paralelo
  const [user, notifications, stats] = await Promise.all([
    getUser(),
    getNotifications(),
    getStats(),
  ]);

  return (
    <div>
      <h1>Bienvenido, {user.name}</h1>
      <p>Notificaciones: {notifications.length}</p>
      <p>Ventas de este mes: ${stats.revenue.toLocaleString()}</p>
    </div>
  );
}

Control de cache

// Datos estaticos (obtenidos en tiempo de build)
const staticData = await fetch('https://api.example.com/data', {
  cache: 'force-cache',
});

// Datos dinamicos (obtenidos cada vez)
const dynamicData = await fetch('https://api.example.com/data', {
  cache: 'no-store',
});

// Revalidacion basada en tiempo
const revalidatedData = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }, // 1 hora
});

// Revalidacion basada en tags
const taggedData = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] },
});

// revalidateTag('products') para revalidar

Renderizado progresivo con Suspense

// app/page.tsx
import { Suspense } from 'react';
import RecommendedProducts from '@/components/RecommendedProducts';
import LatestNews from '@/components/LatestNews';

export default function HomePage() {
  return (
    <div>
      <h1>Inicio</h1>

      <Suspense fallback={<div>Cargando productos recomendados...</div>}>
        <RecommendedProducts />
      </Suspense>

      <Suspense fallback={<div>Cargando noticias...</div>}>
        <LatestNews />
      </Suspense>
    </div>
  );
}

Step 5: Carga y manejo de errores

loading.tsx

// app/blog/loading.tsx
export default function Loading() {
  return (
    <div className="space-y-4">
      {[...Array(3)].map((_, i) => (
        <div key={i} className="animate-pulse">
          <div className="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
          <div className="h-4 bg-gray-200 rounded w-1/2"></div>
        </div>
      ))}
    </div>
  );
}

error.tsx

// app/blog/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="text-center py-8">
      <h2 className="text-2xl font-bold text-red-600 mb-4">
        Ha ocurrido un error
      </h2>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        Reintentar
      </button>
    </div>
  );
}

not-found.tsx

// app/blog/[slug]/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div className="text-center py-8">
      <h2 className="text-2xl font-bold mb-4">Articulo no encontrado</h2>
      <p className="text-gray-600 mb-4">
        El articulo que buscas no existe o ha sido eliminado.
      </p>
      <Link
        href="/blog"
        className="text-blue-500 hover:underline"
      >
        Volver a la lista del blog
      </Link>
    </div>
  );
}
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound(); // Mostrar not-found.tsx
  }

  return <article>{/* ... */}</article>;
}

Step 6: Server Actions

Server Action basico

// app/contact/page.tsx
async function submitContact(formData: FormData) {
  'use server';

  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;

  // Guardar en base de datos
  await db.contact.create({
    data: { name, email, message },
  });

  // Enviar correo, etc.
}

export default function ContactPage() {
  return (
    <form action={submitContact} className="space-y-4">
      <div>
        <label htmlFor="name">Nombre</label>
        <input
          id="name"
          name="name"
          required
          className="w-full border p-2 rounded"
        />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="w-full border p-2 rounded"
        />
      </div>
      <div>
        <label htmlFor="message">Mensaje</label>
        <textarea
          id="message"
          name="message"
          required
          className="w-full border p-2 rounded"
        />
      </div>
      <button
        type="submit"
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        Enviar
      </button>
    </form>
  );
}

useFormState y useFormStatus

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  if (!title || title.length < 3) {
    return { error: 'El titulo debe tener al menos 3 caracteres' };
  }

  await db.post.create({
    data: { title, content },
  });

  revalidatePath('/posts');
  return { success: true };
}
// app/posts/new/page.tsx
'use client';

import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
    >
      {pending ? 'Enviando...' : 'Publicar'}
    </button>
  );
}

export default function NewPostPage() {
  const [state, formAction] = useActionState(createPost, null);

  return (
    <form action={formAction} className="space-y-4">
      {state?.error && (
        <p className="text-red-500">{state.error}</p>
      )}

      <div>
        <label htmlFor="title">Titulo</label>
        <input
          id="title"
          name="title"
          className="w-full border p-2 rounded"
        />
      </div>

      <div>
        <label htmlFor="content">Contenido</label>
        <textarea
          id="content"
          name="content"
          className="w-full border p-2 rounded"
        />
      </div>

      <SubmitButton />
    </form>
  );
}

Ejercicio practico: Aplicacion de blog

// lib/db.ts
export interface Post {
  id: string;
  title: string;
  content: string;
  createdAt: Date;
}

// Base de datos simple en memoria
let posts: Post[] = [];

export const db = {
  posts: {
    findMany: () => posts,
    findUnique: (id: string) => posts.find(p => p.id === id),
    create: (data: Omit<Post, 'id' | 'createdAt'>) => {
      const post = {
        ...data,
        id: crypto.randomUUID(),
        createdAt: new Date(),
      };
      posts.push(post);
      return post;
    },
    delete: (id: string) => {
      posts = posts.filter(p => p.id !== id);
    },
  },
};
// app/posts/page.tsx
import Link from 'next/link';
import { db } from '@/lib/db';

export default function PostsPage() {
  const posts = db.posts.findMany();

  return (
    <div className="container mx-auto p-4">
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-3xl font-bold">Blog</h1>
        <Link
          href="/posts/new"
          className="px-4 py-2 bg-blue-500 text-white rounded"
        >
          Crear nuevo
        </Link>
      </div>

      <div className="space-y-4">
        {posts.map(post => (
          <article key={post.id} className="border p-4 rounded">
            <h2 className="text-xl font-bold">
              <Link href={`/posts/${post.id}`} className="hover:underline">
                {post.title}
              </Link>
            </h2>
            <p className="text-gray-600 mt-2">
              {post.content.substring(0, 100)}...
            </p>
          </article>
        ))}
      </div>
    </div>
  );
}

Mejores practicas

1. Uso de componentes
   - Obtencion de datos → Server Component
   - Funcionalidad interactiva → Client Component
   - 'use client' solo donde sea necesario

2. Obtencion de datos
   - Obtener en paralelo siempre que sea posible
   - Seleccionar estrategia de cache apropiada
   - Renderizado progresivo con Suspense

3. Rendimiento
   - Division con importacion dinamica
   - Usar next/image para imagenes
   - Usar next/font para fuentes

4. Manejo de errores
   - Manejar apropiadamente con error.tsx
   - Pagina 404 con not-found.tsx
   - Estado de carga con loading.tsx

Resumen

Next.js App Router es una nueva arquitectura que combina Server Components y Client Components. Usandolos apropiadamente, puedes mejorar tanto el rendimiento como la experiencia de desarrollo.

← Volver a la lista