---
title: "Sitio web multipágina con FastHTML: tutorial completo de estructura"
description: "Crea un sitio web multipágina con FastHTML con componentes reutilizables, layouts compartidos y APIRouter. Incluye manejo de errores 404 en FastHTML 0.14, formularios y estructura del proyecto."
date: 2025-02-26
categories: ["web-development"]
tags: ["fasthtml","python","web-development"]
---

import YouTubeEmbed from "@components/widgets/YouTubeEmbed.astro";
import Notice from "@components/widgets/Notice.astro";
import ListCheck from "@components/widgets/ListCheck.astro";
import Accordion from "@components/widgets/Accordion.astro";
import Tabs from "@components/widgets/Tabs.astro";
import Tab from "@components/widgets/Tab.astro";
import Button from "@components/widgets/Button.astro";

En el primer artículo, [FastHTML: primeros pasos](/es/empezar-fasthtml/), cubrimos lo básico: instalar FastHTML, crear una página sencilla y añadir estilos con Tailwind CSS. Este artículo retoma donde quedó aquel y construye un **sitio web multipágina con FastHTML** completo, con layouts compartidos, componentes reutilizables, manejo correcto de errores 404 y procesamiento de formularios (actualizado para FastHTML 0.14).

Al final tendrás un sitio funcional con páginas de Inicio, Acerca de y Contacto que usa patrones modernos como `fast_app()`, `Titled()` y vinculación de formularios basada en dataclasses. Si quieres escalar el proyecto más adelante, también cubrimos `APIRouter` para el enrutado repartido en varios archivos.

Si prefieres seguirlo visualmente, tienes un video con el recorrido completo más abajo.

## ¿Por qué varias páginas?

Un sitio web multipágina te permite organizar el contenido de forma lógica. Aunque las aplicaciones de una sola página tienen su lugar, la mayoría de los sitios web se benefician de páginas distintas para distintos propósitos:

- **Organización**: las páginas separadas mantienen el contenido estructurado. «Inicio» para una vista general, «Acerca de» para tu historia, «Contacto» para los datos de comunicación.
- **Navegación**: los usuarios esperan hacer clic en enlaces para explorar distintas secciones. Hace que el sitio sea intuitivo.
- **Escalabilidad**: añadir páginas nuevas es cuestión de crear un archivo y una ruta nuevos.
- **Beneficios de SEO**: los motores de búsqueda indexan páginas individuales, lo que mejora la visibilidad.
- **Experiencia de usuario**: los usuarios pueden guardar páginas concretas en marcadores y usar la navegación del navegador (atrás/adelante) de forma natural.

FastHTML hace que los sitios multipágina sean sencillos gracias a su sistema de enrutado y a sus componentes Python reutilizables. Si estás evaluando frameworks web de Python en general, consulta nuestra comparativa de los [mejores frameworks web de Python](/best-python-web-frameworks/).

<YouTubeEmbed
  url="https://www.youtube.com/embed/Zc8APrgknug"
  label="Recorrido por un sitio multipágina con FastHTML"
/>

## Serie de FastHTML

Estos son los artículos de la serie para ayudarte a empezar:

- [FastHTML: primeros pasos](/es/empezar-fasthtml/)
- [FastHTML con varias páginas](/es/fasthtml-multiples-paginas/) (este artículo)
- [Herramientas de IA complejas con FastHTML](/es/fasthtml-herramientas-ia-complejas/)
- [Crear una app web sencilla con IA usando FastHTML y Pydantic AI](/es/fasthtml-herramientas-pydanticai/)
- [Añadir historial con base de datos SQLite a tu generador de títulos con IA en FastHTML](/es/fasthtml-sqlite-db/)
- [Autenticación en FastHTML](/es/fasthtml-autenticacion-usuarios/)

## Requisitos previos

Antes de empezar, asegúrate de tener lo siguiente:

<ListCheck>
<ul>
<li>Python 3.10 o superior instalado</li>
<li>`pip install python-fasthtml` (este tutorial usa la v0.14.x)</li>
<li>Un editor de código (VS Code, Cursor o similar)</li>
<li>Conocimientos básicos de Python (funciones, imports, decoradores)</li>
<li>Acceso a una terminal o línea de comandos</li>
</ul>
</ListCheck>

<Notice type="warning" title="Fija tu versión de FastHTML">
FastHTML es pre-1.0 e introduce cambios incompatibles entre versiones menores. Fija siempre la versión exacta en `requirements.txt` (p. ej., `python-fasthtml==0.14.11`) y vuelve a probar tras actualizar. Lee el CHANGELOG antes de subir de versión.
</Notice>

## Crear un sitio web multipágina con FastHTML

Vamos a construir un sitio de 3 páginas (Inicio, Acerca de, Contacto) paso a paso, usando patrones modernos de FastHTML: `fast_app()` para inicializar la app, tuplas `Title()` para el esqueleto de cada página y vinculación de formularios con dataclasses y Post/Redirect/Get. La estructura es modular: un punto de entrada `main.py`, un `components.py` para la UI compartida y un directorio `pages/` para el contenido de cada página.

### Paso 1: Estructura del proyecto

Una estructura de directorios limpia mantiene el proyecto manejable a medida que crece:

```
mywebsite/
├── main.py              # Application entry point and routing
├── components.py        # Reusable header, footer, and layout
├── requirements.txt     # Pin dependencies
└── pages/               # Per-page content modules
    ├── __init__.py      # Makes pages a Python package
    ├── home.py          # Home page content
    ├── about.py         # About page content
    └── contact.py       # Contact page with form
```

Crea un `requirements.txt` para fijar la versión:

```
python-fasthtml==0.14.11
```

Cada archivo tiene un papel claro:

- **`main.py`** gestiona el enrutado (URL → handler) y arranca el servidor.
- **`components.py`** contiene las piezas de UI reutilizables (cabecera, pie de página, layout) que aparecen en todas las páginas.
- **`pages/`** contiene un módulo por página. Cada uno devuelve componentes FT con el contenido de esa página.
- **`pages/__init__.py`** es un archivo vacío que convierte el directorio en un paquete Python.

Las ventajas: separación de responsabilidades, facilidad para encontrar y actualizar piezas concretas, y sencillez para añadir archivos nuevos a `pages/` a medida que el sitio crece.

### Paso 2: Componentes reutilizables (cabecera, pie y layout)

Crea `components.py` con tres funciones: `header()`, `footer()` y `page_layout()`. El cambio respecto a tutoriales antiguos: usamos tuplas `Title()` en lugar de envolver manualmente con `Html(Head(...))`. FastHTML envuelve automáticamente la tupla en un documento HTML completo que incluye cualquier `hdrs` que hayas pasado a `fast_app()`.

**Archivo: `mywebsite/components.py`**

```python
from fasthtml.common import *
from datetime import datetime

def header(current_page="/"):
    """Navigation bar shared across all pages.

    Args:
        current_page: Current page path, used to highlight the active link.
    """
    nav_items = [
        ("Home", "/"),
        ("About", "/about"),
        ("Contact", "/contact"),
    ]

    nav_links = []
    for title, path in nav_items:
        is_current = current_page == path
        link_class = "text-white hover:text-gray-300 px-3 py-2"
        if is_current:
            link_class += " font-bold underline"
        nav_links.append(Li(A(title, href=path, cls=link_class)))

    return Header(
        Div(
            A("MyWebsite", href="/", cls="text-xl font-bold text-white"),
            Nav(Ul(*nav_links, cls="flex space-x-2"), cls="ml-auto"),
            cls="container mx-auto flex items-center justify-between px-4 py-3",
        ),
        cls="bg-blue-600 shadow-md",
    )


def footer():
    """Footer shared across all pages."""
    current_year = datetime.now().year

    return Footer(
        Div(
            Div(
                P(f"\u00a9 {current_year} MyWebsite. All rights reserved.",
                  cls="text-gray-500"),
                cls="mb-4",
            ),
            Div(
                A("Privacy Policy", href="#",
                  cls="text-blue-500 hover:underline mr-4"),
                A("Terms of Service", href="#",
                  cls="text-blue-500 hover:underline"),
                cls="text-sm",
            ),
            cls="container mx-auto px-4 py-6 text-center",
        ),
        cls="bg-gray-100 mt-8",
    )


def page_layout(title, content, current_page="/"):
    """Wraps page content with header, footer, and meta tags.

    Returns a tuple that FastHTML auto-wraps into a full HTML document.
    The Tailwind CDN script and viewport meta are injected here for
    simplicity in this tutorial.
    """
    return (
        Title(title),
        Meta(name="viewport", content="width=device-width, initial-scale=1.0"),
        Meta(name="description", content=f"{title} - MyWebsite built with FastHTML"),
        Script(src="https://cdn.tailwindcss.com"),
        header(current_page),
        Main(
            Div(content, cls="container mx-auto px-4 py-8"),
            cls="min-h-screen",
        ),
        footer(),
    )
```

Qué cambió respecto a los tutoriales antiguos:

1. **`datetime.now().year`** en lugar de fijar `2025` a mano.
2. **`page_layout` devuelve una tupla**: `(Title(...), Meta(...), header(...), Main(...), footer())`. FastHTML detecta esta tupla y la envuelve en `Html(Head(...), Body(...))` automáticamente. No hace falta un `Html(Head(Title(...)))` manual.
3. **El script del CDN de Tailwind se queda en `page_layout`** por simplicidad. En producción lo moverías a `fast_app(hdrs=...)` (ver las notas de producción más abajo).

<Notice type="warning" title="El Play CDN de Tailwind es solo para desarrollo">
La propia documentación de Tailwind indica que el Play CDN «está diseñado solo para desarrollo y no está pensado para producción». La consola del navegador también te avisará. Para producción, genera un archivo CSS estático con `npx @tailwindcss/cli -o public/styles.css` y sírvelo con una etiqueta `<link>`, o usa el Pico CSS que FastHTML incluye por defecto para un estilo sin paso de compilación.
</Notice>

### Paso 3: Contenido de cada página

Crea los módulos de página dentro de `pages/`. Empieza con un `pages/__init__.py` vacío (basta un `touch pages/__init__.py`) y luego añade cada página.

**Archivo: `mywebsite/pages/home.py`**

```python
from fasthtml.common import *

def home():
    """Home page content."""
    return Div(
        # Hero section
        Div(
            H1("Welcome to MyWebsite",
               cls="text-4xl font-bold text-gray-800 mb-4"),
            P("Build web applications with FastHTML and Python.",
              cls="text-xl text-gray-600 mb-6"),
            Div(
                A("Get Started", href="/about",
                  cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-4"),
                A("Learn More", href="/contact",
                  cls="bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded"),
                cls="flex",
            ),
            cls="py-12 text-center",
        ),
        # Features section
        Div(
            H2("Key Features", cls="text-3xl font-bold text-center mb-8"),
            Div(
                Div(
                    H3("Easy to Learn", cls="text-xl font-semibold mb-2"),
                    P("Built on Python, making web development accessible to everyone.",
                      cls="text-gray-600"),
                    cls="bg-white p-6 rounded-lg shadow-md",
                ),
                Div(
                    H3("Highly Productive", cls="text-xl font-semibold mb-2"),
                    P("Create web applications faster with fewer lines of code.",
                      cls="text-gray-600"),
                    cls="bg-white p-6 rounded-lg shadow-md",
                ),
                Div(
                    H3("Scalable", cls="text-xl font-semibold mb-2"),
                    P("Easily expand your application as your needs grow.",
                      cls="text-gray-600"),
                    cls="bg-white p-6 rounded-lg shadow-md",
                ),
                cls="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12",
            ),
            cls="py-8",
        ),
    )
```

**Archivo: `mywebsite/pages/about.py`**

```python
from fasthtml.common import *

def about():
    """About page content."""
    return Div(
        H1("About Us", cls="text-3xl font-bold text-gray-800 mb-6 text-center"),
        Div(
            Div(
                H2("Our Story", cls="text-2xl font-semibold mb-4"),
                P("MyWebsite was created to help developers build "
                  "web applications using Python. Our mission is to make "
                  "web development accessible, enjoyable, and productive.",
                  cls="text-gray-600 mb-4"),
                P("We believe that Python developers should be able to create "
                  "stunning web applications without having to learn multiple "
                  "languages and frameworks.",
                  cls="text-gray-600 mb-4"),
                cls="mb-8",
            ),
            Div(
                H2("Our Team", cls="text-2xl font-semibold mb-4"),
                Div(
                    Div(
                        H3("Jane Doe", cls="text-xl font-semibold"),
                        P("Founder & CEO", cls="text-gray-500 italic mb-2"),
                        P("Python enthusiast with 15 years of experience in web development.",
                          cls="text-gray-600"),
                        cls="bg-white p-4 rounded shadow-md",
                    ),
                    Div(
                        H3("John Smith", cls="text-xl font-semibold"),
                        P("CTO", cls="text-gray-500 italic mb-2"),
                        P("Full-stack developer with a passion for clean, maintainable code.",
                          cls="text-gray-600"),
                        cls="bg-white p-4 rounded shadow-md",
                    ),
                    cls="grid grid-cols-1 md:grid-cols-2 gap-6",
                ),
            ),
        ),
    )
```

**Archivo: `mywebsite/pages/contact.py`**

```python
from fasthtml.common import *

def contact():
    """Contact page content with a form."""
    return Div(
        H1("Contact Us", cls="text-3xl font-bold text-gray-800 mb-6 text-center"),
        Div(
            # Contact info
            Div(
                H2("Get in Touch", cls="text-2xl font-semibold mb-4"),
                P("We'd love to hear from you! Use the form or the "
                  "information below to reach out.",
                  cls="text-gray-600 mb-4"),
                Div(
                    P(Strong("Email: "), "info@mywebsite.com", cls="mb-2"),
                    P(Strong("Phone: "), "+1 (555) 123-4567", cls="mb-2"),
                    P(Strong("Address: "), "123 Web Street, Internet City, 10101",
                      cls="mb-2"),
                    cls="mb-6",
                ),
                cls="mb-8 md:pr-8",
            ),
            # Contact form
            Div(
                H2("Send a Message", cls="text-2xl font-semibold mb-4"),
                Form(
                    Div(
                        Label("Name", For="name", cls="block text-gray-700 mb-1"),
                        Input(type="text", id="name", name="name",
                              placeholder="Your name",
                              cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"),
                        cls="mb-4",
                    ),
                    Div(
                        Label("Email", For="email", cls="block text-gray-700 mb-1"),
                        Input(type="email", id="email", name="email",
                              placeholder="Your email",
                              cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"),
                        cls="mb-4",
                    ),
                    Div(
                        Label("Message", For="message", cls="block text-gray-700 mb-1"),
                        Textarea(id="message", name="message",
                                 placeholder="Your message",
                                 rows=5,
                                 cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"),
                        cls="mb-6",
                    ),
                    Button("Send Message", type="submit",
                           cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
                    action="/submit-contact",
                    method="post",
                    cls="bg-white p-6 rounded-lg shadow-md",
                ),
            ),
            cls="md:flex",
        ),
    )
```

Si ya has creado formularios HTML antes, esto es Python directo. Cada función devuelve componentes FT que se renderizan como HTML estándar. El `action` del formulario apunta a `/submit-contact`. Gestionaremos ese POST en el siguiente paso.

Para añadir formularios de contacto a otros tipos de sitios, consulta [cómo añadir un formulario de contacto a sitios web estáticos](/add-contact-form-static-websites/).

### Paso 4: Enrutado en la aplicación principal

Aquí es donde importan los patrones modernos de FastHTML. El `main.py` actualizado usa `fast_app()` en lugar de `FastHTML()`, `@rt` en lugar de `@app.get`, un manejador de 404 real y ninguna guarda `if __name__`.

**Archivo: `mywebsite/main.py`**

```python
from fasthtml.common import *
from dataclasses import dataclass

from pages.home import home as home_page
from pages.about import about as about_page
from pages.contact import contact as contact_page
from components import page_layout

# --- 404 handler (defined before fast_app so we can pass it in) ---

def not_found(req, exc):
    error_content = Div(
        H1("404 - Page Not Found", cls="text-3xl font-bold text-gray-800 mb-4"),
        P("Sorry, that page doesn't exist.",
          cls="text-xl text-gray-600 mb-6"),
        A("Return Home", href="/",
          cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
        cls="text-center py-12",
    )
    return page_layout("404 Not Found - MyWebsite", error_content, current_page="/")

# Initialize the app.
# pico=False disables the default Pico CSS since we're using Tailwind.
# Tailwind CDN is injected via page_layout() for this tutorial.
# exception_handlers registers our 404 handler for real HTTP 404 status codes.
app, rt = fast_app(
    pico=False,
    exception_handlers={404: not_found},
)

# --- Routes ---

@rt("/")
def get():
    return page_layout("Home - MyWebsite", home_page(), current_page="/")

@rt("/about")
def get():
    return page_layout("About Us - MyWebsite", about_page(), current_page="/about")

@rt("/contact")
def get():
    return page_layout("Contact Us - MyWebsite", contact_page(), current_page="/contact")

# --- Form handling with dataclass binding + PRG ---

@dataclass
class Contact:
    name: str
    email: str
    message: str

@app.post("/submit-contact")
def submit_contact(contact: Contact):
    # In a real app, save to DB or send email here.
    # contact.name, contact.email, contact.message are typed and validated.
    return RedirectResponse("/thanks", status_code=303)

@rt("/thanks")
def get():
    thanks_content = Div(
        H1("Thank You!", cls="text-3xl font-bold text-gray-800 mb-4"),
        P("We've received your message and will respond soon.",
          cls="text-xl text-gray-600 mb-6"),
        A("Return Home", href="/",
          cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
        cls="text-center py-12",
    )
    return page_layout("Thank You - MyWebsite", thanks_content, current_page="/contact")

serve()
```

<Notice type="error" title="El antiguo 404 catch-all devuelve HTTP 200">
El tutorial anterior usaba `@app.get("/{path:path}")` para el manejo del 404. Eso devuelve **HTTP 200 OK** con una página que *dice* «404». Los crawlers y las herramientas de monitorización ven una respuesta correcta, lo que es malo para el SEO y las alertas. Usa `exception_handlers={404: handler}` pasado a `fast_app()` para devolver un código de estado 404 real.
</Notice>

<Notice type="info" title="serve() no necesita una guarda __main__">
La documentación oficial es explícita: «Never write `if __name__ == '__main__'` since `serve` checks it internally». La guarda es inofensiva pero innecesaria y se considera poco idiomática. Simplemente llama a `serve()` a nivel de módulo.
</Notice>

Cambios clave explicados:

1. **`app, rt = fast_app(pico=False)`**: `fast_app()` es la inicialización moderna. Devuelve `(app, rt)` donde `rt` es la forma corta de `app.route`. Por defecto incluye las cabeceras de Pico CSS, htmx 2.x y surreal.js. Pasamos `pico=False` porque usamos Tailwind en su lugar.

2. **Decorador `@rt("/")`**: en lugar de `@app.get("/")`. El nombre de la función `get` se mapea al método HTTP. Ambos estilos funcionan; `@rt` es la forma corta idiomática.

3. **`exception_handlers={404: not_found}`**: se registra al crear la app. El handler recibe `(req, exc)` y debe devolver una respuesta. FastHTML envuelve el componente FT en un documento HTML completo automáticamente, y la respuesta lleva un código de estado HTTP 404 real.

4. **`serve()` sin guarda**: ejecuta uvicorn en `0.0.0.0:5001` con recarga en vivo por defecto.

### Paso 5: Formulario de contacto con vinculación por dataclass y patrón PRG

El formulario de contacto del paso 4 usa dos patrones que conviene entender:

**Vinculación por dataclass**: en lugar de parámetros sueltos en la función (`name: str, email: str, message: str`), definimos un `@dataclass class Contact`. FastHTML resuelve los campos del formulario al constructor del dataclass automáticamente. Esto te da validación de tipos y código más limpio.

**Post/Redirect/Get (PRG)**: el handler POST devuelve `RedirectResponse("/thanks", status_code=303)` en lugar de renderizar directamente la página de agradecimiento. Si un usuario recarga tras un render directo del POST, el navegador reenvía el formulario. Una redirección 303 a un endpoint GET hace que la recarga sea segura.

<Accordion label="¿Por qué usar Post/Redirect/Get (PRG)?" group="faq" expanded="true">
Tras un POST de formulario exitoso, el navegador guarda la petición POST en su historial. Si el usuario recarga, el navegador pregunta «¿Reenviar los datos del formulario?» o lo reenvía en silencio. Con PRG, el POST devuelve una redirección 303 a un endpoint GET. La barra de direcciones muestra la URL GET y recargar solo vuelve a cargar esa página. Sin envíos duplicados.

Es un patrón web estándar. La comunidad de FastHTML lo discutió en el [issue #389 de GitHub](https://github.com/AnswerDotAI/fasthtml/issues/389), y `RedirectResponse` es la forma documentada de hacerlo.
</Accordion>

### Paso 6 — Ejecutar y probar tu sitio web

Ahora vamos a ejecutarlo:

```bash
cd mywebsite
pip install -r requirements.txt
python main.py
```

Deberías ver uvicorn arrancar en `http://0.0.0.0:5001`. Visita:

- `http://localhost:5001/` — página de inicio
- `http://localhost:5001/about` — página «Acerca de»
- `http://localhost:5001/contact` — página de contacto con formulario

Haz clic en los enlaces de navegación para moverte entre páginas. La página actual debería aparecer resaltada en la cabecera. Envía el formulario de contacto: deberías ser redirigido a `/thanks`. Recarga esa página: no hay envío duplicado del formulario.

## Organizar rutas con APIRouter

El patrón anterior (importar funciones de página y definir rutas en `main.py`) funciona bien para sitios pequeños. Cuando tu proyecto supere las 3-4 páginas, FastHTML ofrece `APIRouter` —introducido en la v0.8.0— para que cada módulo sea dueño de sus rutas.

<Tabs>
<Tab name="Patrón de importación simple">
Las rutas se definen en `main.py` importando las funciones de página:

```python
# main.py
from pages.home import home as home_page
from components import page_layout

app, rt = fast_app(pico=False)

@rt("/")
def get():
    return page_layout("Home", home_page(), current_page="/")
```

Este es el patrón de los pasos 1-6. Funciona bien para proyectos pequeños.
</Tab>
<Tab name="APIRouter (patrón moderno)">
Cada módulo de página crea su propio `APIRouter` y se registra en la app:

```python
# pages/home.py
from fasthtml.common import *
from components import page_layout

ar = APIRouter()

@ar("/")
def get():
    return page_layout("Home", home_content(), current_page="/")

def home_content():
    return Div(
        H1("Welcome to MyWebsite", cls="text-4xl font-bold text-gray-800 mb-4"),
        P("Build web applications with FastHTML and Python.",
          cls="text-xl text-gray-600 mb-6"),
        cls="py-12 text-center",
    )
```

```python
# main.py
from fasthtml.common import *
from components import not_found
from pages.home import ar as home_routes

app, rt = fast_app(
    pico=False,
    exception_handlers={404: not_found},
)

home_routes.to_app(app)

serve()
```

Cada módulo de página es autocontenido: las rutas y el contenido conviven. El `main.py` solo los conecta con la app.
</Tab>
</Tabs>

**Soporte de prefijos**: `APIRouter("/blog")` añade un prefijo de URL a todas las rutas de ese módulo. Útil para namespaces, p. ej., todas las rutas del blog viven bajo `/blog/*` sin repetir el prefijo en cada decorador.

Para un ejemplo real de `APIRouter` en un proyecto multipágina, consulta [cómo crear un sitio web de herramientas de IA multipágina con FastHTML](/es/fasthtml-herramientas-ia-complejas/).

## Verificar y solucionar problemas

Después de construir el sitio, verifica que funciona:

<ListCheck>
<ul>
<li>Visita `/`, `/about`, `/contact`: el contenido se renderiza correctamente</li>
<li>`curl -i http://127.0.0.1:5001/nope`: devuelve `HTTP/1.1 404 Not Found` (no 200)</li>
<li>Envía el formulario de contacto: el navegador redirige a `/thanks` (mira la barra de direcciones)</li>
<li>Recarga la página de agradecimiento: no hay envío duplicado del formulario</li>
<li>Revisa la consola del navegador: no hay errores de JS del CDN de Tailwind</li>
</ul>
</ListCheck>

### Errores comunes y soluciones

| Síntoma | Causa probable | Solución |
|---|---|---|
| `ModuleNotFoundError: No module named 'pages'` | Falta `pages/__init__.py` o el directorio de trabajo es incorrecto | Crea el `__init__.py` vacío; ejecuta desde la raíz del proyecto |
| La página 404 devuelve HTTP 200 | El antiguo catch-all `@app.get("/{path:path}")` sigue en el código | Sustitúyelo por `exception_handlers={404: handler}` |
| El puerto 5001 ya está en uso | Otro proceso en el puerto | Mátalo (`lsof -i :5001`) o define la variable de entorno `PORT=8000` |
| `AttributeError: 'NoneType'` al enviar el formulario | Falta el atributo `name` en el `<input>` | Asegúrate de que los inputs tengan `name="name"`, `name="email"`, etc. |
| Los estilos de Tailwind no cargan | El Play CDN está bloqueado o sin conexión | Revisa la pestaña de red del navegador; usa un CSS compilado local para mayor fiabilidad |

### Modo debug

Pasa `debug=True` a `fast_app()` para obtener páginas de error detalladas en el navegador:

```python
app, rt = fast_app(pico=False, debug=True)
```

Nunca lo uses en producción: expone las trazas de pila a los usuarios.

### Pruebas con TestClient

Puedes probar las rutas sin navegador usando el `TestClient` de Starlette:

```python
from starlette.testclient import TestClient

client = TestClient(app)
print(client.get("/").status_code)       # 200
print(client.get("/nope").status_code)   # 404
print(client.post("/submit-contact", data={
    "name": "Test", "email": "a@b.c", "message": "hi"
}).status_code)                          # 303
```

Es útil para CI o pruebas de humo rápidas tras los cambios.

## Notas de producción y despliegue

El tutorial anterior está optimizado para desarrollo local. Esto es lo que cambia cuando despliegas más allá de localhost.

### Servir en producción

`serve()` es un envoltorio pensado para desarrollo. Hace bind en `0.0.0.0:5001` con la recarga en vivo activada por defecto. Para producción, usa uvicorn directamente detrás de un proxy inverso para la terminación TLS y varios workers:

```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
```

Pon Caddy, Nginx o Traefik delante para HTTPS automático. [Desplegar un proyecto Python con Dokploy](/es/dokploy-python-railpack-uv/) es una forma de automatizarlo en un VPS.

### Estrategia de CSS

El Play CDN de Tailwind compila el CSS en el navegador en cada carga de página: bien para desarrollo, lento y poco fiable en producción. Opciones:

1. **Generar un archivo CSS estático**: `npx @tailwindcss/cli -o public/styles.css` y sírvelo con `fast_app(hdrs=(Link(rel="stylesheet", href="/styles.css"),))`.
2. **Usar Pico CSS**: el que FastHTML trae por defecto. Sin paso de compilación, sin clases, con un aspecto decente de fábrica. Quita `pico=False` y elimina el script de Tailwind.

### Archivos estáticos

`fast_app(static_path='public')` sirve automáticamente los archivos de un directorio `public/`. Útil para imágenes, CSS y assets JS.

### Fijar versiones

FastHTML es 0.x con cambios incompatibles frecuentes. La v0.14.1 eliminó varios imports de `fasthtml.common`. Fija siempre `python-fasthtml` en `requirements.txt` y vuelve a probar antes de actualizar.

### Sesiones y secretos

`fast_app(secret_key="your-secret")` habilita cookies de sesión firmadas mediante el middleware de sesiones de Starlette. Nunca pongas secretos en el código: usa variables de entorno.

### Desplegar en un VPS

Para hosting económico, un [VPS de Hetzner Cloud](https://go.bitdoze.com/hetzner) empieza en unos 4 €/mes y ejecuta una app FastHTML sin problemas. Puedes [ejecutar cualquier app Python en Docker](/docker-run-python/) o montar un pipeline de despliegue completo [desplegando un proyecto Python con Dokploy](/es/dokploy-python-railpack-uv/).

_Los precios de los VPS subieron en 2026: si estás reconsiderando una máquina alquilada, mira [qué cambió y cuándo gana un mini PC](/es/subidas-precios-vps/)._

## Ampliar tu sitio web multipágina

La base ya está lista. Así puedes hacerlo crecer:

### Añadir más páginas

Crea un archivo nuevo en `pages/`, añade una ruta (o un `APIRouter`) y añade un enlace de navegación en `components.py`. Eso es todo.

### Añadir contenido dinámico

Obtén datos de APIs, lee de una base de datos o genera contenido según la entrada del usuario. Consulta [añadir una base de datos SQLite a tu app FastHTML](/es/fasthtml-sqlite-db/) y [crear una app web con IA usando FastHTML y PydanticAI](/es/fasthtml-herramientas-pydanticai/) para ejemplos concretos.

### Implementar autenticación de usuarios

Para páginas protegidas: rutas de login/registro, gestión de sesiones y redirecciones para usuarios no autenticados. Sigue [añadir autenticación de usuarios a tu app FastHTML](/es/fasthtml-autenticacion-usuarios/).

### Mejorar la UI

Añade interacciones con HTMX para actualizaciones parciales de página, animaciones con Tailwind y validación de formularios en el cliente. [Crear un sitio web de herramientas de IA multipágina con FastHTML](/es/fasthtml-herramientas-ia-complejas/) muestra HTMX en acción con un proyecto real.

## Conclusión

Has construido un sitio web multipágina con FastHTML con:

- **Estructura de proyecto modular**: `main.py`, `components.py` y módulos por página
- **Layout compartido reutilizable**: cabecera, pie y esqueleto de página en un solo lugar
- **Patrones modernos de FastHTML**: `fast_app()`, `@rt`, tuplas `Title()`
- **Manejo de 404 correcto**: `exception_handlers={404: handler}` devolviendo un HTTP 404 real
- **Procesamiento de formularios**: vinculación por dataclass con el patrón Post/Redirect/Get
- **Un camino para escalar**: `APIRouter` para enrutado en varios archivos cuando el proyecto crezca

El código de este tutorial está pensado para FastHTML 0.14.x. Fija tu versión, lee el CHANGELOG antes de actualizar y revisa las notas de producción antes de desplegar.

<Button text="Siguiente: herramientas de IA complejas con FastHTML \u2192" link="/es/fasthtml-herramientas-ia-complejas/" variant="solid" color="blue" size="md" icon="arrow-right" />

## FAQ

<Accordion label="¿Cuál es la diferencia entre FastHTML() y fast_app()?" group="faq">
`fast_app()` es la forma corta moderna. Devuelve `(app, rt)` donde `rt` es `app.route`. Además inyecta las cabeceras por defecto (Pico CSS, htmx 2.x, surreal.js) y el middleware de sesiones. `FastHTML()` sigue funcionando pero requiere configurar manualmente cabeceras y middleware. Usa `fast_app()` salvo que necesites control total sobre cada valor por defecto.
</Accordion>

<Accordion label="¿Por qué mi página 404 devuelve HTTP 200?" group="faq">
Si usas `@app.get("/{path:path}")` como catch-all, el handler de la ruta devuelve HTTP 200 por defecto: la página *dice* «404» pero el código de estado es incorrecto. Sustitúyelo por `exception_handlers={404: handler}` pasado a `fast_app()`. El handler recibe `(req, exc)` y debe devolver una respuesta; FastHTML establece el estado 404 automáticamente.
</Accordion>

<Accordion label="¿Debo usar Tailwind o Pico CSS con FastHTML?" group="faq">
FastHTML usa Pico CSS por defecto: un framework CSS sin clases que estila el HTML semántico sin configuración. Vale para herramientas internas y sitios sencillos. Tailwind da más control pero requiere el Play CDN (solo desarrollo) o un paso de compilación para producción. Usa `pico=False` más `hdrs=(Script(...),)` para cambiar a Tailwind. Para Tailwind en producción, genera un archivo CSS estático con la CLI de Tailwind.
</Accordion>

<Accordion label="¿Cómo despliego una app FastHTML en producción?" group="faq">
Ejecuta `uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4` detrás de un proxy inverso (Caddy, Nginx o Traefik) para la terminación TLS. Fija `python-fasthtml` en tu archivo de requirements. Genera CSS estático si usas Tailwind. Usa `fast_app(secret_key=...)` con una variable de entorno para la firma de sesiones. Un [VPS de Hetzner Cloud](https://go.bitdoze.com/hetzner) a ~4 €/mes lo gestiona sin problemas.
</Accordion>

<Accordion label="¿Qué es APIRouter y cuándo debo usarlo?" group="faq">
`APIRouter` (disponible desde FastHTML v0.8.0) permite que cada módulo de página sea dueño de sus rutas. En lugar de importar las funciones de página en `main.py` y definir ahí todas las rutas, cada módulo crea un `APIRouter`, decora sus propios handlers y llama a `ar.to_app(app)` para registrarse en la app principal. Úsalo cuando tu proyecto supere las 3-4 páginas. Admite `prefix=` para namespaces de URL (p. ej., `APIRouter("/blog")`).
</Accordion>