Búsqueda y filtros en Templates Jinja2

Avanzado
FastAPI
FastAPI
Actualizado: 18/09/2025

Formularios de búsqueda

Los formularios de búsqueda permiten a los usuarios filtrar y localizar información específica dentro de aplicaciones web. En FastAPI con Jinja2, implementamos estas funcionalidades combinando formularios HTML, parámetros de consulta y lógica de filtrado en el backend.

Estructura básica de un formulario de búsqueda

Un formulario de búsqueda utiliza el método GET para enviar los términos de búsqueda como parámetros de URL, permitiendo que los usuarios puedan compartir y marcar resultados específicos.

<!-- search_form.html -->
<form method="GET" action="/productos" class="mb-4">
    <div class="input-group">
        <input type="text" 
               name="q" 
               class="form-control" 
               placeholder="Buscar productos..." 
               value="{{ request.query_params.get('q', '') }}">
        <button type="submit" class="btn btn-primary">
            <i class="fas fa-search"></i> Buscar
        </button>
    </div>
</form>

La clave del formulario está en preservar el valor buscado usando {{ request.query_params.get('q', '') }}, lo que mantiene el término visible después de realizar la búsqueda.

Manejo de parámetros de consulta en FastAPI

El endpoint debe capturar los parámetros de consulta opcionales y aplicar los filtros correspondientes:

from fastapi import FastAPI, Request, Query
from fastapi.templating import Jinja2Templates
from typing import Optional

app = FastAPI()
templates = Jinja2Templates(directory="templates")

@app.get("/productos")
async def buscar_productos(
    request: Request,
    q: Optional[str] = Query(None, description="Término de búsqueda")
):
    # Simular datos de productos
    todos_productos = [
        {"id": 1, "nombre": "Laptop HP", "precio": 899.99},
        {"id": 2, "nombre": "Mouse inalámbrico", "precio": 25.50},
        {"id": 3, "nombre": "Teclado mecánico", "precio": 120.00},
        {"id": 4, "nombre": "Monitor LED", "precio": 299.99}
    ]
    
    # Aplicar filtro de búsqueda si existe el término
    if q and q.strip():
        productos_filtrados = [
            p for p in todos_productos 
            if q.lower() in p["nombre"].lower()
        ]
    else:
        productos_filtrados = todos_productos
    
    return templates.TemplateResponse("productos.html", {
        "request": request,
        "productos": productos_filtrados,
        "termino_busqueda": q,
        "total_resultados": len(productos_filtrados)
    })

Template con resultados de búsqueda

El template debe mostrar resultados dinámicamente y manejar casos cuando no se encuentren coincidencias:

<!-- productos.html -->
{% extends "base.html" %}

{% block content %}
<div class="container mt-4">
    <h2>Catálogo de productos</h2>
    
    <!-- Formulario de búsqueda -->
    <form method="GET" action="/productos" class="mb-4">
        <div class="input-group">
            <input type="text" 
                   name="q" 
                   class="form-control" 
                   placeholder="Buscar productos..." 
                   value="{{ request.query_params.get('q', '') }}">
            <button type="submit" class="btn btn-primary">Buscar</button>
            {% if request.query_params.get('q') %}
            <a href="/productos" class="btn btn-outline-secondary">Limpiar</a>
            {% endif %}
        </div>
    </form>
    
    <!-- Información de resultados -->
    {% if termino_busqueda %}
    <div class="alert alert-info">
        <strong>{{ total_resultados }}</strong> resultado(s) encontrado(s) para 
        "<em>{{ termino_busqueda }}</em>"
    </div>
    {% endif %}
    
    <!-- Lista de productos -->
    {% if productos %}
    <div class="row">
        {% for producto in productos %}
        <div class="col-md-6 col-lg-4 mb-3">
            <div class="card">
                <div class="card-body">
                    <h5 class="card-title">{{ producto.nombre }}</h5>
                    <p class="card-text">
                        <strong>Precio:</strong> ${{ "%.2f"|format(producto.precio) }}
                    </p>
                </div>
            </div>
        </div>
        {% endfor %}
    </div>
    {% else %}
    <div class="alert alert-warning text-center">
        <h4>No se encontraron productos</h4>
        <p>Intenta con otros términos de búsqueda.</p>
    </div>
    {% endif %}
</div>
{% endblock %}

Búsqueda con múltiples criterios

Para búsquedas más avanzadas, podemos combinar varios campos de búsqueda:

@app.get("/productos/avanzada")
async def busqueda_avanzada(
    request: Request,
    nombre: Optional[str] = Query(None),
    precio_min: Optional[float] = Query(None, ge=0),
    precio_max: Optional[float] = Query(None, ge=0)
):
    productos = obtener_productos_db()  # Función que obtiene productos
    
    # Aplicar filtros progresivamente
    if nombre and nombre.strip():
        productos = [p for p in productos if nombre.lower() in p["nombre"].lower()]
    
    if precio_min is not None:
        productos = [p for p in productos if p["precio"] >= precio_min]
    
    if precio_max is not None:
        productos = [p for p in productos if p["precio"] <= precio_max]
    
    return templates.TemplateResponse("busqueda_avanzada.html", {
        "request": request,
        "productos": productos,
        "filtros_aplicados": any([nombre, precio_min is not None, precio_max is not None])
    })

El formulario avanzado correspondiente mantiene todos los valores de búsqueda:

<form method="GET" action="/productos/avanzada">
    <div class="row">
        <div class="col-md-4">
            <label class="form-label">Nombre del producto</label>
            <input type="text" name="nombre" class="form-control"
                   value="{{ request.query_params.get('nombre', '') }}">
        </div>
        <div class="col-md-3">
            <label class="form-label">Precio mínimo</label>
            <input type="number" name="precio_min" class="form-control" step="0.01"
                   value="{{ request.query_params.get('precio_min', '') }}">
        </div>
        <div class="col-md-3">
            <label class="form-label">Precio máximo</label>
            <input type="number" name="precio_max" class="form-control" step="0.01"
                   value="{{ request.query_params.get('precio_max', '') }}">
        </div>
        <div class="col-md-2 d-flex align-items-end">
            <button type="submit" class="btn btn-primary w-100">Filtrar</button>
        </div>
    </div>
</form>

Integración con base de datos

Para búsquedas eficientes con SQLAlchemy, utilizamos consultas optimizadas:

from sqlalchemy.orm import Session
from sqlalchemy import or_, and_

@app.get("/productos/db")
async def buscar_productos_db(
    request: Request,
    q: Optional[str] = Query(None),
    db: Session = Depends(get_db)
):
    query = db.query(Producto)
    
    if q and q.strip():
        # Búsqueda en múltiples campos usando OR
        termino = f"%{q.strip()}%"
        query = query.filter(
            or_(
                Producto.nombre.ilike(termino),
                Producto.descripcion.ilike(termino),
                Producto.categoria.ilike(termino)
            )
        )
    
    productos = query.limit(50).all()  # Limitar resultados para rendimiento
    
    return templates.TemplateResponse("productos.html", {
        "request": request,
        "productos": productos,
        "termino_busqueda": q
    })

Los formularios de búsqueda proporcionan una experiencia de usuario intuitiva cuando se implementan correctamente, manteniendo el estado de los filtros y proporcionando retroalimentación clara sobre los resultados obtenidos.

Filtros dinámicos

Los filtros dinámicos mejoran significativamente la experiencia del usuario al permitir filtrado instantáneo sin recargas de página. Utilizamos JavaScript combinado con endpoints FastAPI para crear interfaces interactivas que respondan a las acciones del usuario en tiempo real.

Filtros con JavaScript y fetch()

Los filtros dinámicos funcionan enviando peticiones asíncronas al servidor y actualizando solo las secciones relevantes del DOM:

<!-- filtros_dinamicos.html -->
<div class="container mt-4">
    <div class="row">
        <div class="col-md-3">
            <!-- Panel de filtros -->
            <div class="card">
                <div class="card-header">
                    <h5>Filtros</h5>
                </div>
                <div class="card-body">
                    <div class="mb-3">
                        <label class="form-label">Categoría</label>
                        <select id="categoria-filter" class="form-select">
                            <option value="">Todas las categorías</option>
                            <option value="electronica">Electrónica</option>
                            <option value="ropa">Ropa</option>
                            <option value="hogar">Hogar</option>
                        </select>
                    </div>
                    
                    <div class="mb-3">
                        <label class="form-label">Precio máximo: <span id="precio-valor">1000</span>€</label>
                        <input type="range" id="precio-range" class="form-range" 
                               min="0" max="1000" value="1000">
                    </div>
                    
                    <div class="mb-3">
                        <div class="form-check">
                            <input type="checkbox" id="disponible-check" class="form-check-input" checked>
                            <label class="form-check-label" for="disponible-check">
                                Solo disponibles
                            </label>
                        </div>
                    </div>
                    
                    <button id="limpiar-filtros" class="btn btn-outline-secondary btn-sm">
                        Limpiar filtros
                    </button>
                </div>
            </div>
        </div>
        
        <div class="col-md-9">
            <!-- Área de resultados -->
            <div id="loading" class="text-center d-none">
                <div class="spinner-border" role="status">
                    <span class="visually-hidden">Cargando...</span>
                </div>
            </div>
            
            <div id="resultados-container">
                <!-- Los resultados se cargarán aquí dinámicamente -->
            </div>
            
            <div id="no-resultados" class="alert alert-info text-center d-none">
                <h5>No se encontraron productos</h5>
                <p>Prueba ajustando los filtros de búsqueda.</p>
            </div>
        </div>
    </div>
</div>

Lógica JavaScript para filtros interactivos

El JavaScript maneja los eventos de los filtros y actualiza los resultados automáticamente:

<script>
class FiltrosDinamicos {
    constructor() {
        this.initEventListeners();
        this.cargarResultados(); // Carga inicial
    }
    
    initEventListeners() {
        // Filtro de categoría
        document.getElementById('categoria-filter').addEventListener('change', () => {
            this.aplicarFiltros();
        });
        
        // Slider de precio con actualización en tiempo real
        const precioRange = document.getElementById('precio-range');
        precioRange.addEventListener('input', (e) => {
            document.getElementById('precio-valor').textContent = e.target.value;
            this.debounce(() => this.aplicarFiltros(), 300);
        });
        
        // Checkbox de disponibilidad
        document.getElementById('disponible-check').addEventListener('change', () => {
            this.aplicarFiltros();
        });
        
        // Botón limpiar filtros
        document.getElementById('limpiar-filtros').addEventListener('click', () => {
            this.limpiarFiltros();
        });
    }
    
    // Función debounce para evitar demasiadas peticiones
    debounce(func, delay) {
        clearTimeout(this.debounceTimer);
        this.debounceTimer = setTimeout(func, delay);
    }
    
    async aplicarFiltros() {
        const filtros = this.obtenerFiltrosActivos();
        
        // Mostrar indicador de carga
        this.mostrarCarga(true);
        
        try {
            const response = await fetch('/api/productos/filtrar', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(filtros)
            });
            
            if (response.ok) {
                const html = await response.text();
                this.actualizarResultados(html);
            } else {
                console.error('Error al aplicar filtros:', response.status);
            }
        } catch (error) {
            console.error('Error de red:', error);
        } finally {
            this.mostrarCarga(false);
        }
    }
    
    obtenerFiltrosActivos() {
        return {
            categoria: document.getElementById('categoria-filter').value,
            precio_max: parseFloat(document.getElementById('precio-range').value),
            solo_disponibles: document.getElementById('disponible-check').checked
        };
    }
    
    actualizarResultados(html) {
        const container = document.getElementById('resultados-container');
        const noResultados = document.getElementById('no-resultados');
        
        if (html.trim() === '') {
            container.innerHTML = '';
            noResultados.classList.remove('d-none');
        } else {
            container.innerHTML = html;
            noResultados.classList.add('d-none');
        }
    }
    
    mostrarCarga(mostrar) {
        const loading = document.getElementById('loading');
        if (mostrar) {
            loading.classList.remove('d-none');
        } else {
            loading.classList.add('d-none');
        }
    }
    
    limpiarFiltros() {
        document.getElementById('categoria-filter').value = '';
        document.getElementById('precio-range').value = 1000;
        document.getElementById('precio-valor').textContent = '1000';
        document.getElementById('disponible-check').checked = true;
        this.aplicarFiltros();
    }
    
    async cargarResultados() {
        await this.aplicarFiltros();
    }
}

// Inicializar cuando se carga la página
document.addEventListener('DOMContentLoaded', () => {
    new FiltrosDinamicos();
});
</script>

Endpoint FastAPI para filtros dinámicos

El endpoint de filtrado procesa los filtros y retorna HTML parcial:

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from typing import Optional

class FiltrosProductos(BaseModel):
    categoria: Optional[str] = None
    precio_max: Optional[float] = None
    solo_disponibles: bool = True

@app.post("/api/productos/filtrar", response_class=HTMLResponse)
async def filtrar_productos(
    request: Request,
    filtros: FiltrosProductos
):
    # Obtener productos de la base de datos o fuente de datos
    productos = obtener_productos_filtrados(filtros)
    
    # Renderizar solo la parte de resultados
    return templates.TemplateResponse("partials/productos_grid.html", {
        "request": request,
        "productos": productos
    })

def obtener_productos_filtrados(filtros: FiltrosProductos):
    # Simular filtrado de productos
    todos_productos = [
        {"id": 1, "nombre": "Smartphone", "categoria": "electronica", "precio": 599.99, "disponible": True},
        {"id": 2, "nombre": "Camiseta", "categoria": "ropa", "precio": 25.99, "disponible": True},
        {"id": 3, "nombre": "Lámpara LED", "categoria": "hogar", "precio": 45.50, "disponible": False},
        {"id": 4, "nombre": "Tablet", "categoria": "electronica", "precio": 299.99, "disponible": True}
    ]
    
    productos_filtrados = todos_productos.copy()
    
    # Aplicar filtro de categoría
    if filtros.categoria:
        productos_filtrados = [p for p in productos_filtrados if p["categoria"] == filtros.categoria]
    
    # Aplicar filtro de precio
    if filtros.precio_max is not None:
        productos_filtrados = [p for p in productos_filtrados if p["precio"] <= filtros.precio_max]
    
    # Aplicar filtro de disponibilidad
    if filtros.solo_disponibles:
        productos_filtrados = [p for p in productos_filtrados if p["disponible"]]
    
    return productos_filtrados

Template parcial para resultados

El template parcial contiene solo la estructura de resultados que se actualizará:

<!-- partials/productos_grid.html -->
{% if productos %}
<div class="row">
    {% for producto in productos %}
    <div class="col-lg-4 col-md-6 mb-4">
        <div class="card h-100 {% if not producto.disponible %}opacity-75{% endif %}">
            <div class="card-body">
                <h5 class="card-title">{{ producto.nombre }}</h5>
                <p class="card-text">
                    <span class="badge bg-secondary">{{ producto.categoria|title }}</span>
                </p>
                <p class="card-text">
                    <strong class="text-primary">${{ "%.2f"|format(producto.precio) }}</strong>
                </p>
                {% if producto.disponible %}
                <span class="badge bg-success">Disponible</span>
                {% else %}
                <span class="badge bg-warning">Agotado</span>
                {% endif %}
            </div>
        </div>
    </div>
    {% endfor %}
</div>
<div class="mt-3 text-muted">
    <small>{{ productos|length }} producto(s) encontrado(s)</small>
</div>
{% endif %}

Filtros con autocompletado dinámico

Para búsquedas más sofisticadas, implementamos autocompletado que se actualiza mientras el usuario escribe:

<div class="mb-3">
    <label class="form-label">Buscar producto</label>
    <div class="position-relative">
        <input type="text" id="busqueda-input" class="form-control" 
               placeholder="Escribe para buscar...">
        <div id="sugerencias" class="position-absolute w-100 bg-white border rounded-bottom shadow-sm d-none" 
             style="top: 100%; z-index: 1000; max-height: 200px; overflow-y: auto;">
        </div>
    </div>
</div>

<script>
// Agregar a la clase FiltrosDinamicos
initBusquedaAutocompletado() {
    const input = document.getElementById('busqueda-input');
    const sugerencias = document.getElementById('sugerencias');
    
    input.addEventListener('input', (e) => {
        const termino = e.target.value.trim();
        
        if (termino.length >= 2) {
            this.debounce(() => this.buscarSugerencias(termino), 200);
        } else {
            sugerencias.classList.add('d-none');
        }
    });
    
    // Ocultar sugerencias al hacer clic fuera
    document.addEventListener('click', (e) => {
        if (!input.contains(e.target) && !sugerencias.contains(e.target)) {
            sugerencias.classList.add('d-none');
        }
    });
}

async buscarSugerencias(termino) {
    try {
        const response = await fetch(`/api/productos/sugerencias?q=${encodeURIComponent(termino)}`);
        const sugerenciasData = await response.json();
        
        const sugerenciasEl = document.getElementById('sugerencias');
        
        if (sugerenciasData.length > 0) {
            sugerenciasEl.innerHTML = sugerenciasData.map(item => 
                `<div class="p-2 border-bottom cursor-pointer hover-bg-light" onclick="this.seleccionarSugerencia('${item.nombre}')">
                    <strong>${item.nombre}</strong>
                    <small class="text-muted d-block">${item.categoria} - $${item.precio}</small>
                </div>`
            ).join('');
            sugerenciasEl.classList.remove('d-none');
        } else {
            sugerenciasEl.classList.add('d-none');
        }
    } catch (error) {
        console.error('Error al buscar sugerencias:', error);
    }
}
</script>

Endpoint para sugerencias

El endpoint de sugerencias proporciona resultados rápidos para el autocompletado:

@app.get("/api/productos/sugerencias")
async def obtener_sugerencias(q: str = Query(..., min_length=2)):
    # Búsqueda rápida en productos
    productos = buscar_productos_por_nombre(q)
    
    # Limitar a 5 sugerencias para rendimiento
    sugerencias = [
        {
            "nombre": p["nombre"],
            "categoria": p["categoria"],
            "precio": p["precio"]
        }
        for p in productos[:5]
    ]
    
    return sugerencias

Los filtros dinámicos transforman la experiencia de búsqueda de estática a interactiva, proporcionando retroalimentación inmediata y reduciendo la fricción en la navegación de contenido.

Fuentes y referencias

Documentación oficial y recursos externos para profundizar en FastAPI

Documentación oficial de FastAPI
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, FastAPI es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de FastAPI

Explora más contenido relacionado con FastAPI y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

  • Comprender cómo crear formularios de búsqueda con método GET en Jinja2.
  • Manejar parámetros de consulta en FastAPI para filtrar datos.
  • Diseñar templates que muestren resultados de búsqueda y gestionen casos sin coincidencias.
  • Implementar filtros avanzados y dinámicos con JavaScript y endpoints FastAPI.
  • Integrar autocompletado y filtros interactivos para mejorar la usabilidad.