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.
Actualizado Publicado 55 min de lectura

FastHTML Tutorial Series
Part 2 of 6
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:
- FastHTML: primeros pasos
- FastHTML con varias páginas (este artículo)
- Herramientas de IA complejas con FastHTML
- Crear una app web sencilla con IA usando FastHTML y Pydantic AI
- Añadir historial con base de datos SQLite a tu generador de títulos con IA en FastHTML
- Autenticación en FastHTML
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:
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 formCrea un requirements.txt para fijar la versión:
python-fasthtml==0.14.11Cada archivo tiene un papel claro:
main.pygestiona el enrutado (URL → handler) y arranca el servidor.components.pycontiene 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__.pyes 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
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:
datetime.now().yearen lugar de fijar2025a mano.page_layoutdevuelve una tupla:(Title(...), Meta(...), header(...), Main(...), footer()). FastHTML detecta esta tupla y la envuelve enHtml(Head(...), Body(...))automáticamente. No hace falta unHtml(Head(Title(...)))manual.- El script del CDN de Tailwind se queda en
page_layoutpor simplicidad. En producción lo moverías afast_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
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
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
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
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:
-
app, rt = fast_app(pico=False):fast_app()es la inicialización moderna. Devuelve(app, rt)dondertes la forma corta deapp.route. Por defecto incluye las cabeceras de Pico CSS, htmx 2.x y surreal.js. Pasamospico=Falseporque usamos Tailwind en su lugar. -
Decorador
@rt("/"): en lugar de@app.get("/"). El nombre de la funcióngetse mapea al método HTTP. Ambos estilos funcionan;@rtes la forma corta idiomática. -
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. -
serve()sin guarda: ejecuta uvicorn en0.0.0.0:5001con 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:
cd mywebsite
pip install -r requirements.txt
python main.pyDeberías ver uvicorn arrancar en http://0.0.0.0:5001. Visita:
http://localhost:5001/— página de iniciohttp://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:
# 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:
# 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",
)# 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: devuelveHTTP/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:
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:
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) # 303Es ú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:
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4Pon 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:
- Generar un archivo CSS estático:
npx @tailwindcss/cli -o public/styles.cssy sírvelo confast_app(hdrs=(Link(rel="stylesheet", href="/styles.css"),)). - 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=Falsey 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.pyy 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, tuplasTitle() - 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:
APIRouterpara 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 \u2192FAQ
¿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")).


