FastHTML + PydanticAI: Build an AI Web App in Python
Build an AI title generator with FastHTML and PydanticAI. Learn structured output, OpenRouter integration, and Python web app best practices — step by step.

FastHTML Tutorial Series
Part 3 of 6
Welcome to part 3 of our FastHTML series. In this tutorial, we build an AI-powered web application using FastHTML and PydanticAI, two Python libraries for full-stack web apps with structured AI output. No JavaScript frameworks needed.
If you’re new to FastHTML, start with FastHTML for Beginners where we cover the basics, then our multi-page FastHTML structure tutorial. New to AI integration? Read our beginner’s guide to programming with AI. Curious how FastHTML stacks up against other options? See our best Python web frameworks roundup.
Our project is an AI Title Generator that helps content creators develop titles for blogs, YouTube videos, social posts, and more. We use PydanticAI’s structured output to get type-safe, validated responses from the AI model, and OpenRouter for cheap provider-agnostic model access.
Version targets
This tutorial targets PydanticAI >= v2.0 (stable June 2026) and FastHTML >= 0.14.11. If you’re on an older version, check the PydanticAI upgrade guide.
What you’ll build:
- A working AI title generator form with platform and style options
- PydanticAI structured output with validated, type-safe AI responses (no regex parsing)
- OpenRouter integration via PydanticAI’s first-party provider
- A modular Python project structure you can reuse for other AI tools
Project structure overview
Here’s the exact structure we’ll build:
ai-title-generator/
├── main.py # Main application entry point
├── config.py # Configuration settings
├── ai_service.py # AI integration module
├── components/ # Reusable UI components
│ ├── __init__.py
│ ├── header.py # Page header
│ ├── footer.py # Page footer
│ └── page_layout.py # Layout template
├── pages/ # Individual page content
│ ├── __init__.py
│ ├── home.py # Home page
│ └── title_generator.py # Title generator page
└── tools/ # AI tools
├── __init__.py
└── title_generator.py # Title generation tool
This structure follows the same modular pattern from the multi-page tutorial. Each module handles one responsibility. UI components are reusable across pages. Different aspects of the app are grouped logically, and adding new features or AI tools means adding a new file rather than editing a monolith.
Step-by-step: build your AI title generator
Each step includes verification so you can confirm things work before moving on.
Step 1: setting up the project
Create the project directory, virtual environment, and install dependencies:
mkdir -p ai-title-generator/components ai-title-generator/pages ai-title-generator/tools
cd ai-title-generator
touch components/__init__.py pages/__init__.py tools/__init__.py
python3 -m venv .venv
source .venv/bin/activate
pip install python-fasthtml python-dotenv "pydantic-ai[openrouter]"
Model retirement
The previously recommended default openai/gpt-3.5-turbo has been retired by OpenAI. This tutorial uses openai/gpt-4o-mini (~$0.15/$0.60 per 1M tokens via OpenRouter) as the cheap, dependable default.
Free models
OpenRouter offers free models with rate limits (~50 req/day). You can also use the ~openai/gpt-latest alias to always get the latest GPT model. See openrouter.ai/models for the full catalog.
Both python-fasthtml and pydantic-ai require Python >= 3.10. Check your version with python3 --version.
Create a .gitignore to keep secrets out of version control:
File: .gitignore
.env
.venv/
__pycache__/
Create a requirements.txt to pin your dependencies:
File: requirements.txt
python-fasthtml>=0.14.11
python-dotenv>=1.0
pydantic-ai[openrouter]>=2.0
Now create the configuration file:
File: config.py
import os
import sys
from dotenv import load_dotenv
load_dotenv()
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "openai/gpt-4o-mini")
DEBUG = os.getenv("DEBUG", "True").lower() == "true"
APP_NAME = "AI Title Generator"
if not OPENROUTER_API_KEY:
print("ERROR: OPENROUTER_API_KEY is not set. Create a .env file with your key.")
print("Get a free key at https://openrouter.ai/keys")
sys.exit(1)
Create the .env file with your OpenRouter API key:
File: .env
OPENROUTER_API_KEY=your_openrouter_api_key_here
DEFAULT_MODEL=openai/gpt-4o-mini
DEBUG=True
Verify: Confirm the dependencies are installed and imports work:
pip list | grep pydantic-ai
# Should show pydantic-ai >= 2.0
python -c "from pydantic_ai.models.openrouter import OpenRouterModel; print('OK')"
# Should print: OK
If it fails:
ModuleNotFoundError: No module named 'pydantic_ai.models.openrouter'— you installedpydantic-aiwithout the[openrouter]extra. Re-run:pip install "pydantic-ai[openrouter]"- Python < 3.10 — upgrade Python
OPENROUTER_API_KEY is not set— create or fix your.envfile
Step 2: creating the AI service
This is the main change. The old code manually built an AsyncOpenAI client and used raw chat completions. PydanticAI now has first-party OpenRouter support with OpenRouterModel and OpenRouterProvider, which means less boilerplate and structured output built in.
File: ai_service.py
from pydantic_ai import Agent, ModelSettings
from pydantic_ai.models.openrouter import OpenRouterModel
from pydantic_ai.providers.openrouter import OpenRouterProvider
import config
import YouTubeEmbed from "../../components/widgets/YouTubeEmbed.astro";
class AIService:
"""Service for interacting with AI models via OpenRouter."""
def __init__(self, model_name=None, output_type=None, system_prompt=None):
self.model_name = model_name or config.DEFAULT_MODEL
model = OpenRouterModel(
self.model_name,
provider=OpenRouterProvider(api_key=config.OPENROUTER_API_KEY),
)
self.agent = Agent(
model,
instructions=system_prompt,
output_type=output_type,
)
async def run_structured(self, user_message: str, temperature: float = 0.8):
"""Run the agent and return validated structured output."""
result = await self.agent.run(
user_message,
model_settings=ModelSettings(temperature=temperature),
)
return result.output
Compare this to the old version that had ~90 lines with AsyncOpenAI, OpenAIModel, chat_completion(), structured_completion(), and manual message building.
The code above uses PydanticAI’s first-party OpenRouter provider. Install with:
pip install "pydantic-ai[openrouter]"This is the recommended path. No manual client configuration needed.
If you don’t want the [openrouter] extra, you can use the OpenAI-compatible fallback:
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
model = OpenAIChatModel(
"openai/gpt-4o-mini",
provider=OpenAIProvider(
base_url="https://openrouter.ai/api/v1",
api_key=config.OPENROUTER_API_KEY,
),
)This works with any OpenAI-compatible API, not just OpenRouter.
Why structured output instead of raw chat?
The old tutorial defined Pydantic models (TitleGenerationResponse) but then called chat_completion() and regex-parsed the text response. That’s fragile — LLMs change formatting, add markdown, or include extra text.
PydanticAI’s structured output sends the Pydantic schema to the model and validates the response against it. If the model returns something that doesn’t match, PydanticAI retries automatically. You get type-safe data directly: result.output.titles is a list[str], not a string you have to parse.
This means less code and fewer failure modes.
Verify: Confirm the module imports cleanly:
python -c "from ai_service import AIService; print('AIService imported OK')"
Step 3: creating the title generator tool
Structured output replaces the fragile regex parsing here. The old _extract_titles_from_response() method (40+ lines of regex, fallback logic, and string splitting) is gone entirely.
File: tools/title_generator.py
from pydantic import BaseModel
from pydantic_ai import Agent, ModelSettings
from pydantic_ai.models.openrouter import OpenRouterModel
from pydantic_ai.providers.openrouter import OpenRouterProvider
import config
class TitleGenerationResponse(BaseModel):
"""Schema for title generation response."""
titles: list[str]
SYSTEM_PROMPT = """You are an expert title generator specializing in creating
engaging, click-worthy titles appropriate for different platforms.
Guidelines:
- Create titles that grab attention without being misleading
- Adapt the style and format to the specified platform
- Ensure titles are relevant to the topic
- Keep titles concise and effective
- Return exactly the number of titles requested"""
class TitleGenerator:
"""Tool for generating titles for various platforms."""
def __init__(self):
model = OpenRouterModel(
config.DEFAULT_MODEL,
provider=OpenRouterProvider(api_key=config.OPENROUTER_API_KEY),
)
self.agent = Agent(
model,
instructions=SYSTEM_PROMPT,
output_type=TitleGenerationResponse,
model_settings=ModelSettings(temperature=0.8),
)
async def generate_titles(
self,
topic: str,
platform: str = "Blog",
style: str = "Professional",
number_of_titles: int = 5,
) -> list[str]:
"""Generate titles based on the given parameters."""
user_prompt = (
f"Generate {number_of_titles} engaging {platform} titles about: {topic}\n"
f"Style: {style}"
)
result = await self.agent.run(user_prompt)
return result.output.titles[:number_of_titles]
Structured output win
With PydanticAI’s structured output, we no longer need regex parsing. The AI’s response is validated directly against our Pydantic model, which means fewer lines of code and more reliable results. If the model returns fewer titles than requested, result.output.titles simply has fewer items. If the response doesn’t match the schema, PydanticAI retries automatically.
Step 4: creating UI components
Now create the reusable UI components. The header, footer, and layout are largely unchanged from the original.
File: components/header.py
from fasthtml.common import *
import config
def header(current_page="/"):
"""Creates a consistent header with navigation."""
nav_items = [
("Home", "/"),
("Title Generator", "/title-generator"),
]
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(config.APP_NAME, 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"
)
File: components/footer.py
from fasthtml.common import *
import config
def footer():
"""Creates a consistent footer."""
return Footer(
Div(
P(f"\u00a9 2026 {config.APP_NAME}. Built with FastHTML and PydanticAI.",
cls="text-center text-gray-500"),
cls="container mx-auto px-4 py-6"
),
cls="bg-gray-100 mt-auto"
)
File: components/page_layout.py
from fasthtml.common import *
from .header import header
from .footer import footer
def page_layout(title, content, current_page="/"):
"""Creates a consistent page layout with header and footer."""
return Html(
Head(
Title(title),
Meta(charset="UTF-8"),
Meta(name="viewport", content="width=device-width, initial-scale=1.0"),
Script(src="https://cdn.tailwindcss.com"),
),
Body(
Div(
header(current_page),
Main(
Div(content, cls="container mx-auto px-4 py-8"),
cls="flex-grow"
),
footer(),
cls="flex flex-col min-h-screen"
)
)
)
Label syntax change
FastHTML now uses fr= instead of For= for the label for attribute (since for is a Python reserved word). All labels in this tutorial use the updated syntax. If your labels aren’t clickable or focusing the input, check this first.
Note: Tailwind CSS via CDN is fine for development and prototyping. For production, use a build step or consider MonsterUI as the recommended Tailwind-based component library for FastHTML.
Step 5: creating pages
The home page is unchanged. The title generator page gets two fixes: For= → fr= on all labels, and a safer copy button.
File: pages/home.py
from fasthtml.common import *
import config
def home():
"""Defines the home page content."""
return Div(
# Hero section
Div(
H1(config.APP_NAME,
cls="text-4xl font-bold text-center text-gray-800 mb-4"),
P("Create engaging titles for your content with AI assistance.",
cls="text-xl text-center text-gray-600 mb-6"),
Div(
A("Generate Titles \u2192",
href="/title-generator",
cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
cls="flex justify-center"
),
cls="py-12"
),
# Features section
Div(
H2("Features", cls="text-3xl font-bold text-center mb-8"),
Div(
Div(
H3("Platform-Specific", cls="text-xl font-semibold mb-2"),
P("Generate titles optimized for blogs, YouTube, social media, and more.",
cls="text-gray-600"),
cls="bg-white p-6 rounded-lg shadow-md"
),
Div(
H3("Multiple Styles", cls="text-xl font-semibold mb-2"),
P("Choose from professional, casual, clickbait, or informative styles.",
cls="text-gray-600"),
cls="bg-white p-6 rounded-lg shadow-md"
),
Div(
H3("AI-Powered", cls="text-xl font-semibold mb-2"),
P("Utilizes advanced AI models to craft engaging, relevant titles.",
cls="text-gray-600"),
cls="bg-white p-6 rounded-lg shadow-md"
),
cls="grid grid-cols-1 md:grid-cols-3 gap-6"
),
cls="py-8"
),
# How it works section
Div(
H2("How It Works", cls="text-3xl font-bold text-center mb-8"),
Div(
Div(
Div("1",
cls="flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4"),
H3("Enter Your Topic", cls="text-xl font-semibold mb-2"),
P("Describe what your content is about in detail.",
cls="text-gray-600"),
cls="bg-white p-6 rounded-lg shadow-md"
),
Div(
Div("2",
cls="flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4"),
H3("Choose Settings", cls="text-xl font-semibold mb-2"),
P("Select the platform and style that matches your needs.",
cls="text-gray-600"),
cls="bg-white p-6 rounded-lg shadow-md"
),
Div(
Div("3",
cls="flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4"),
H3("Get Results", cls="text-xl font-semibold mb-2"),
P("Review multiple title options and choose your favorite.",
cls="text-gray-600"),
cls="bg-white p-6 rounded-lg shadow-md"
),
cls="grid grid-cols-1 md:grid-cols-3 gap-6"
),
cls="py-8"
)
)
File: pages/title_generator.py
from fasthtml.common import *
def title_generator_form():
"""Defines the title generator form page."""
return Div(
H1("AI Title Generator", cls="text-3xl font-bold text-gray-800 mb-6"),
Div(
Form(
# Topic field
Div(
Label("What's your content about?", fr="topic",
cls="block text-gray-700 mb-2"),
Textarea(
id="topic",
name="topic",
placeholder="Describe your content topic in detail for better results...",
rows=3,
required=True,
cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"
),
cls="mb-4"
),
# Platform selection
Div(
Label("Platform:", fr="platform", cls="block text-gray-700 mb-2"),
Select(
Option("Blog", value="Blog", selected=True),
Option("YouTube", value="YouTube"),
Option("Social Media", value="Social Media"),
Option("Email Subject", value="Email Subject"),
Option("News Article", value="News Article"),
id="platform",
name="platform",
cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"
),
cls="mb-4"
),
# Style selection
Div(
Label("Style:", fr="style", cls="block text-gray-700 mb-2"),
Select(
Option("Professional", value="Professional", selected=True),
Option("Casual", value="Casual"),
Option("Clickbait", value="Clickbait"),
Option("Informative", value="Informative"),
Option("Funny", value="Funny"),
id="style",
name="style",
cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"
),
cls="mb-4"
),
# Number of titles
Div(
Label("Number of titles:", fr="number_of_titles",
cls="block text-gray-700 mb-2"),
Select(
Option("5", value="5", selected=True),
Option("10", value="10"),
Option("15", value="15"),
id="number_of_titles",
name="number_of_titles",
cls="w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"
),
cls="mb-6"
),
# Submit button
Button(
"Generate Titles",
type="submit",
cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
),
action="/title-generator/generate",
method="post",
cls="bg-white p-6 rounded-lg shadow-md mb-8"
),
# Tips section
Div(
H3("Tips for Better Titles", cls="text-xl font-semibold mb-2"),
Ul(
Li("Be specific about your topic for more relevant titles", cls="mb-1"),
Li("Include your target audience for better context", cls="mb-1"),
Li("Mention key points you want to highlight", cls="mb-1"),
Li("For YouTube, specify if it's a tutorial, review, etc.", cls="mb-1"),
cls="list-disc pl-5 text-gray-600"
),
cls="bg-blue-50 p-4 rounded-lg mt-6"
),
cls="max-w-2xl mx-auto"
)
)
def title_generator_results(topic, platform, style, titles):
"""Defines the title generator results page."""
title_items = []
for i, title in enumerate(titles):
title_items.append(
Li(
Div(
P(title, cls="font-medium"),
Button(
"Copy",
type="button",
data_title=title,
onclick="navigator.clipboard.writeText(this.dataset.title); this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy', 2000);",
cls="ml-auto text-sm bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded"
),
cls="flex justify-between items-center"
),
cls="p-3 border-b last:border-b-0"
)
)
return Div(
H1("Generated Titles", cls="text-3xl font-bold text-gray-800 mb-6"),
Div(
# Query summary
Div(
H2("Your Request", cls="text-xl font-semibold mb-2"),
P(
Strong("Topic: "), Span(topic), Br(),
Strong("Platform: "), Span(platform), Br(),
Strong("Style: "), Span(style),
cls="text-gray-600 mb-4"
),
cls="mb-6"
),
# Titles list
Div(
H2("Title Options", cls="text-xl font-semibold mb-2"),
P("Click 'Copy' to copy any title to your clipboard.", cls="text-gray-600 mb-3"),
Ul(*title_items, cls="border rounded divide-y"),
cls="mb-6"
),
# Action buttons
Div(
A("Generate More",
href="/title-generator",
cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-3"),
A("Back to Home",
href="/",
cls="bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded"),
cls="flex"
),
cls="bg-white p-6 rounded-lg shadow-md mb-8 max-w-2xl mx-auto"
)
)
Copy button security fix
The original copy button used inline JavaScript with string interpolation (title.replace("'", "\\'")), which breaks on titles containing quotes, backslashes, or newlines — and is an injection risk with AI-generated text. The updated version stores the title in a data-title attribute and reads it with this.dataset.title, which is safe regardless of the title’s content.
Verify: Open http://localhost:5001/title-generator in your browser and confirm the form renders with all labels visible and dropdowns working. Click on each label — the corresponding input should receive focus.
Step 6: creating the main application
File: main.py
import logging
from fasthtml.common import *
from pages.home import home as home_page
from pages.title_generator import title_generator_form, title_generator_results
from components.page_layout import page_layout
from tools.title_generator import TitleGenerator
import config
# Set up logging
logging.basicConfig(level=logging.DEBUG if config.DEBUG else logging.INFO)
logger = logging.getLogger(__name__)
app = FastHTML()
title_generator = TitleGenerator()
@app.get("/")
def home():
"""Handler for the home page route."""
return page_layout(
title=f"Home - {config.APP_NAME}",
content=home_page(),
current_page="/"
)
@app.get("/title-generator")
def title_generator_page():
"""Handler for the title generator page route."""
return page_layout(
title=f"Title Generator - {config.APP_NAME}",
content=title_generator_form(),
current_page="/title-generator"
)
@app.post("/title-generator/generate")
async def generate_titles(topic: str, platform: str, style: str, number_of_titles: str):
"""Handler for processing title generation requests."""
try:
if not topic:
error_message = Div(
H1("Error", cls="text-3xl font-bold text-red-600 mb-4"),
P("Please provide a topic for your titles.", cls="mb-4"),
A("Try Again", href="/title-generator",
cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md"
)
return page_layout(
title=f"Error - {config.APP_NAME}",
content=error_message,
current_page="/title-generator"
)
num_titles = int(number_of_titles)
titles = await title_generator.generate_titles(
topic=topic,
platform=platform,
style=style,
number_of_titles=num_titles,
)
return page_layout(
title=f"Generated Titles - {config.APP_NAME}",
content=title_generator_results(
topic=topic,
platform=platform,
style=style,
titles=titles,
),
current_page="/title-generator"
)
except ValueError:
error_message = Div(
H1("Error", cls="text-3xl font-bold text-red-600 mb-4"),
P("Invalid number of titles. Please select a valid option.", cls="mb-4"),
A("Try Again", href="/title-generator",
cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md"
)
return page_layout(
title=f"Error - {config.APP_NAME}",
content=error_message,
current_page="/title-generator"
)
except Exception as e:
logger.exception("Title generation failed")
error_message = Div(
H1("Error", cls="text-3xl font-bold text-red-600 mb-4"),
P("Something went wrong while generating titles. Please try again.", cls="mb-4"),
A("Try Again", href="/title-generator",
cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md"
)
return page_layout(
title=f"Error - {config.APP_NAME}",
content=error_message,
current_page="/title-generator"
)
@app.get("/{path:path}")
def not_found(path: str):
"""Handler for 404 Not Found errors."""
error_content = Div(
H1("404 - Page Not Found", cls="text-3xl font-bold text-gray-800 mb-4"),
P(f"Sorry, the page '/{path}' does not exist.", cls="mb-4"),
A("Return Home", href="/",
cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"),
cls="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md text-center"
)
return page_layout(
title=f"404 Not Found - {config.APP_NAME}",
content=error_content,
current_page="/"
)
if __name__ == "__main__":
serve()
Key changes from the original main.py:
serve()instead ofuvicorn.run()— FastHTML’s built-inserve()auto-detects the app, defaults to port 5001, and prints a clickable link. No need toimport uvicorn.- Error handler doesn’t leak exceptions — the old code showed
str(e)to users, which could expose API keys or stack fragments. Now it logs the full traceback server-side and shows a generic message. ValueErrorguard — catches badint(number_of_titles)conversions separately from other errors.
Verify: Start the app and confirm it starts without errors:
python main.py
# Should print something like: Link: http://0.0.0.0:5001
If it fails:
Port 5001 already in use— kill the other process (lsof -i :5001) or setPORT=8000in your.envImportError— check that all files are in the correct directories and__init__.pyfiles exist
Step 7: running and testing your application
Test the full flow:
# 1. Start the app
python main.py
# 2. Test home page (in another terminal)
curl -s http://localhost:5001/ | head -20
# Should return HTML with "AI Title Generator"
# 3. Test form page
curl -s http://localhost:5001/title-generator | grep "Generate Titles"
# Should show the form button text
# 4. Test generation (form POST)
curl -s -X POST http://localhost:5001/title-generator/generate \
-d "topic=Python+web+frameworks&platform=Blog&style=Professional&number_of_titles=5" \
| grep "Title Options"
# Should return results with "Title Options" heading
In the browser, visit http://localhost:5001, navigate to the Title Generator, fill in a topic, and click “Generate Titles”. You should see a list of titles with Copy buttons.
401 Unauthorized — Invalid API Key
Check your .env file. Make sure OPENROUTER_API_KEY is set with no trailing spaces or quotes. Verify the key works at openrouter.ai/keys.
402 Insufficient Credits
Your OpenRouter account is out of credits. Add credit at openrouter.ai/credits, or switch to a free model in your .env:
DEFAULT_MODEL=meta-llama/llama-3.1-8b-instruct:free404 Model Not Found
The model slug doesn’t match what’s available on OpenRouter. Make sure it uses the provider/model format (e.g., openai/gpt-4o-mini). Browse available models at openrouter.ai/models.
Request Timeout
The model took too long to respond. This can happen with free models during peak hours. PydanticAI has built-in retries — if it persists, try a different model or increase the timeout with ModelSettings(timeout=60).
ValidationError from PydanticAI
The model returned a response that doesn’t match TitleGenerationResponse. PydanticAI retries automatically (up to 3 times by default). If it keeps failing, the model may not support structured output well — try a different model like openai/gpt-4o-mini.
Enhancing the title generator
Here are the top ways to extend this project:
-
Richer output models — Add fields to
TitleGenerationResponselikedescription: str,keywords: list[str], orseo_score: float. PydanticAI validates all of them automatically. -
More platforms — Expand to TikTok, Pinterest, LinkedIn, or Substack. Update the platform dropdown and adjust the system prompt for platform-specific guidance.
-
Title variations — Add a feature to generate variations of an existing title for A/B testing.
-
Favorites and history — Save generated titles so users can refer back to them. You can add a SQLite database to persist generation history.
-
Export to CSV — Let users download their generated titles as a file for batch use.
Production notes — cost, security, and deployment
Before exposing this app beyond localhost, here’s what to consider.
Cost breakdown
Cost per request
A typical 5-title generation uses 500–1,000 tokens total. At gpt-4o-mini pricing ($0.15/$0.60 per 1M tokens via OpenRouter), that’s less than $0.001 per generation. Free models on OpenRouter are available but have strict rate limits (~50 req/day).
You can track token usage in your code via result.usage (a property on the agent run result). Note that the public POST endpoint has no authentication — anyone who can reach it can spend your credits. Add rate limiting or auth before exposing publicly.
Dev vs. production
serve() with the defaults runs with reload=True — that’s dev-only. For production:
- Use
serve(reload=False)or a process manager - Put a reverse proxy (Caddy or nginx) in front for TLS and static files
- Tailwind via CDN is fine for development; use a build step for production
- Consider Docker for consistent deployments
For affordable VPS hosting to deploy your app, Hetzner Cloud starts at ~EUR4/month with solid performance. Hostinger VPS is another budget-friendly option with NVMe storage.
Security checklist
Before going live:
.envis in.gitignoreand never committed- Error handler logs server-side but doesn’t expose API keys or stack traces to users
- Copy button uses
data-titleattribute — safe against AI-generated content injection - Add rate limiting or authentication before exposing the POST endpoint publicly
- Use
serve(reload=False)in production - Set
DEBUG=Falsein production.env
Advanced implementation ideas
For those looking to take this project further:
-
User authentication — Add login functionality to allow users to save preferences and title history. Add user authentication and admin controls to your app.
-
Multi-page AI tools site — Extend this into a multi-page FastHTML AI tools site with multiple generators.
-
Batch processing — Generate titles for multiple topics at once.
-
A/B testing integration — Connect with analytics platforms to test title effectiveness.
If you’re curious about alternative approaches to AI agents, see building an AI agent with Mastra or build a Discord AI bot with Agno.
For larger FastHTML apps, look into fast_app() with function-name-based routing (rt) and MonsterUI as the recommended component library.
Conclusion
You’ve built a complete AI-powered title generator using FastHTML and PydanticAI. The key takeaways:
- Python-only web development — no JavaScript frameworks needed for a functional, responsive UI
- Structured AI output — PydanticAI validates responses against your Pydantic model, eliminating fragile regex parsing
- OpenRouter integration — provider-agnostic model access with first-party PydanticAI support
- Modular architecture — clean separation of concerns that scales to more complex tools
- Type-safe, validated responses — the AI’s output is guaranteed to match your schema
The pattern here — FastHTML for the UI, PydanticAI for structured AI calls, OpenRouter for model access — works for many other AI-powered tools: content summarizers, product description generators, email draft writers, SEO optimizers. The title generator is a starting point you can adapt.
In the next article, we’ll extend this into a multi-page AI tools site with multiple generators. You can also add a SQLite database to save generation history, or add user authentication and admin controls.
FastHTML series
Below are all the articles in our FastHTML series:
- FastHTML for Beginners: Build a UI for Your Python App
- FastHTML Multi-Page Website: Complete Structure Tutorial
- FastHTML + PydanticAI: Build an AI Web App in Python (this article)
- FastHTML AI Tools: Build a Multi-Page Website Guide
- How to Add SQLite Database to Your FastHTML App
- Adding User Authentication and Admin Controls to Your FastHTML AI Title Generator


