Bitdoze Logo

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.

DragosDragos36 min read
FastHTML + PydanticAI: Build an AI Web App in Python

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 installed pydantic-ai without 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 .env file

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.

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 of uvicorn.run() — FastHTML’s built-in serve() auto-detects the app, defaults to port 5001, and prints a clickable link. No need to import 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.
  • ValueError guard — catches bad int(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 set PORT=8000 in your .env
  • ImportError — check that all files are in the correct directories and __init__.py files 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:free
404 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:

  1. Richer output models — Add fields to TitleGenerationResponse like description: str, keywords: list[str], or seo_score: float. PydanticAI validates all of them automatically.

  2. More platforms — Expand to TikTok, Pinterest, LinkedIn, or Substack. Update the platform dropdown and adjust the system prompt for platform-specific guidance.

  3. Title variations — Add a feature to generate variations of an existing title for A/B testing.

  4. Favorites and history — Save generated titles so users can refer back to them. You can add a SQLite database to persist generation history.

  5. 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
Deploy with Docker \u2192

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:

  • .env is in .gitignore and never committed
  • Error handler logs server-side but doesn’t expose API keys or stack traces to users
  • Copy button uses data-title attribute — 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=False in production .env

Advanced implementation ideas

For those looking to take this project further:

  1. User authentication — Add login functionality to allow users to save preferences and title history. Add user authentication and admin controls to your app.

  2. Multi-page AI tools site — Extend this into a multi-page FastHTML AI tools site with multiple generators.

  3. Batch processing — Generate titles for multiple topics at once.

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