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