Logotipo de Bitdoze

Sitio web multipágina con FastHTML: tutorial completo de estructura

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.

Dragos

Actualizado Publicado 55 min de lectura

Sitio web multipágina con FastHTML: tutorial completo de estructura

En el primer artículo, FastHTML: primeros pasos, 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.

Serie de FastHTML

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

Requisitos previos

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

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

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.

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:

text
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:

text
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).

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.

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: "), "[email protected]", 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.

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()

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.

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.

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.

¿Por qué usar Post/Redirect/Get (PRG)?

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, y RedirectResponse es la forma documentada de hacerlo.

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.

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.

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.

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.

Verificar y solucionar problemas

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

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

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": "[email protected]", "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 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 empieza en unos 4 €/mes y ejecuta una app FastHTML sin problemas. Puedes ejecutar cualquier app Python en Docker o montar un pipeline de despliegue completo desplegando un proyecto Python con Dokploy.

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.

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 y crear una app web con IA usando FastHTML y 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.

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

Siguiente: herramientas de IA complejas con FastHTML \u2192

FAQ

¿Cuál es la diferencia entre FastHTML() y fast_app()?

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.

¿Por qué mi página 404 devuelve HTTP 200?

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.

¿Debo usar Tailwind o Pico CSS con FastHTML?

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.

¿Cómo despliego una app FastHTML en producción?

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 a ~4 €/mes lo gestiona sin problemas.

¿Qué es APIRouter y cuándo debo usarlo?

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")).