Next.js 15 + FastAPI: una arquitectura pragmatica para productos AI-first

Construir una plataforma de inteligencia de cumplimiento normativo en traze.ai — y mas tarde una herramienta de BI regulatorio en zertia.ai — me llevo hacia una separacion clara: TypeScript en el cliente y el edge del servidor, Python en el backend analitico. Tras desplegar esta combinacion en mas de 50 empresas, puedo decir que los puntos de friccion estan bien identificados. Este articulo es el documento de arquitectura que me habria gustado tener cuando empece.

Cubrire por que esta separacion funciona, como se comunican los dos servicios, donde reside la autenticacion, como hacer streaming de respuestas de IA a traves de la frontera, como mantener los tipos consistentes sin duplicacion manual, y como desplegar todo sin sorpresas en la factura por cold starts.


Por que funciona esta combinacion

Next.js 15 y FastAPI resuelven problemas diferentes de forma excelente. El error esta en tratarlos como intercambiables y elegir uno para todo.

Next.js 15 App Router destaca en composicion de UI, obtencion de datos del lado del servidor cerca de la base de datos, caching en el edge de la CDN y el modelo de componentes de React. Con Server Components se obtiene zero-JS por defecto, renderizando HTML en el servidor con llamadas directas a Prisma cuando la consulta es sencilla. Con tRPC se consigue seguridad de tipos de extremo a extremo para las mutaciones. La red edge de Vercel gestiona el despliegue, los entornos de preview e ISR sin sobrecarga operativa.

FastAPI destaca en todo lo que Python domina: LangChain, LlamaIndex, NumPy, Pandas, scikit-learn, transformers de Hugging Face, Azure OpenAI SDK. La historia async de Python es lo suficientemente madura para cargas de trabajo de IA intensivas en I/O. Pydantic v2 proporciona una capa de validacion tan robusta como Zod. Y cuando necesitas recurrir a una extension en C para tokenizacion o un kernel GPU para inferencia, Python es el lenguaje anfitrion adecuado.

La regla es sencilla: UI y logica edge en Next.js, logica de IA y ciencia de datos en FastAPI. Nunca pongas una cadena de LangChain en una API route de Next.js. Nunca pongas gestion de estado de React en Python.


Vision general de la arquitectura

El diagrama siguiente muestra una peticion tipica para una verificacion de cumplimiento normativo con IA — el usuario envia un documento, la UI hace un POST a un Server Action de Next.js, la accion llama al servicio FastAPI y una respuesta en streaming fluye de vuelta.

Dos cosas merecen atencion. Primero, el servicio FastAPI no es accesible publicamente — se encuentra dentro de una Azure Virtual Network o una red privada de Railway. Solo el despliegue de Next.js puede alcanzarlo, lo que reduce considerablemente la superficie de ataque. Segundo, el Route Handler de Next.js en /api/stream actua como un proxy ligero: reenvio el flujo SSE de FastAPI directamente al navegador sin almacenamiento en buffer. Esto evita el timeout de la funcion de Vercel para respuestas largas de IA.


Frontera de autenticacion: donde fluye el JWT

La autenticacion reside en Next.js. Usamos NextAuth (Auth.js v5) respaldado por la misma base de datos PostgreSQL de la que lee Prisma. Al iniciar sesion, Auth.js emite una cookie de sesion. Para las peticiones que necesitan alcanzar FastAPI, generamos un JWT de corta duracion a partir de los datos de sesion:

// lib/fastapi-token.ts
import { SignJWT } from 'jose';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';

const secret = new TextEncoder().encode(process.env.FASTAPI_JWT_SECRET);

export async function getFastAPIToken(): Promise<string> {
  const session = await getServerSession(authOptions);
  if (!session?.user?.id) throw new Error('Unauthenticated');

  return new SignJWT({
    sub: session.user.id,
    org: session.user.orgId,
    role: session.user.role,
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('2m') // short-lived: one request
    .sign(secret);
}

El lado de FastAPI valida el JWT con el mismo secreto en cada peticion. El token nunca se almacena en una cookie o en local storage — se genera en el servidor, se usa una vez para el fetch saliente y se descarta.

En el lado de FastAPI, una dependencia reutilizable se encarga de la validacion:

# auth.py
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from pydantic import BaseModel
from app.config import settings

bearer_scheme = HTTPBearer()

class TokenPayload(BaseModel):
    sub: str
    org: str
    role: str

def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> TokenPayload:
    try:
        payload = jwt.decode(
            credentials.credentials,
            settings.fastapi_jwt_secret,
            algorithms=["HS256"],
        )
        return TokenPayload(**payload)
    except JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token",
        )

Streaming de respuestas de IA desde FastAPI a Next.js

Esta es la parte que suele pillar desprevenidos a muchos. El metodo .astream() de LangChain produce tokens a medida que llegan del modelo. El StreamingResponse de FastAPI envuelve ese generador en una respuesta HTTP. Pero conseguir que esos tokens lleguen al navegador en tiempo real requiere un manejo cuidadoso en el lado de Next.js.

FastAPI: el endpoint SSE

# routers/analyse.py
import asyncio
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from langchain_openai import AzureChatOpenAI
from langchain_core.messages import HumanMessage
from app.auth import TokenPayload, get_current_user
from app.schemas import AnalyseRequest

router = APIRouter(prefix="/v1", tags=["analyse"])

llm = AzureChatOpenAI(
    azure_deployment="gpt-4o",
    api_version="2024-10-01-preview",
    streaming=True,
)

async def _token_stream(prompt: str) -> AsyncIterator[str]:
    """Yield SSE-formatted chunks from the LLM."""
    async for chunk in llm.astream([HumanMessage(content=prompt)]):
        token = chunk.content
        if token:
            # SSE format: data: {json}\n\n
            yield f"data: {json.dumps({'token': token})}\n\n"
    yield "data: [DONE]\n\n"

@router.post("/analyse/stream")
async def analyse_stream(
    body: AnalyseRequest,
    user: TokenPayload = Depends(get_current_user),
) -> StreamingResponse:
    prompt = _build_prompt(body, user.org)
    return StreamingResponse(
        _token_stream(prompt),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # disable Nginx buffering
        },
    )

def _build_prompt(body: AnalyseRequest, org_id: str) -> str:
    return (
        f"Analyse the following document for regulatory compliance.\n"
        f"Organisation: {org_id}\n\n"
        f"Document:\n{body.content}"
    )

La cabecera X-Accel-Buffering: no es importante cuando hay un proxy inverso Nginx delante de FastAPI — sin ella, Nginx almacena en buffer la respuesta completa antes de reenviarla, eliminando el efecto de streaming. Azure Container Apps no agrega Nginx por defecto, pero Railway si.

Next.js: consumiendo el stream en un Route Handler

Un Route Handler de Next.js actua como proxy del flujo SSE de FastAPI hacia el navegador. La clave esta en canalizar el readable stream directamente sin esperar al body completo.

// app/api/analyse/stream/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getFastAPIToken } from '@/lib/fastapi-token';

export async function POST(req: NextRequest) {
  const body = await req.json();
  const token = await getFastAPIToken();

  const upstream = await fetch(
    `${process.env.FASTAPI_BASE_URL}/v1/analyse/stream`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify(body),
      // do not buffer — we want the ReadableStream
      duplex: 'half',
    } as RequestInit
  );

  if (!upstream.ok) {
    return NextResponse.json(
      { error: 'Upstream error' },
      { status: upstream.status }
    );
  }

  // Pipe the ReadableStream directly to the client response
  return new Response(upstream.body, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  });
}

En el cliente, un componente React lee el stream usando la API EventSource o leyendo manualmente el cuerpo de la respuesta como un ReadableStream. Para la mayoria de casos de uso, lo segundo es mas sencillo porque EventSource no soporta peticiones POST.


Seguridad de tipos a traves de la frontera

La mayor fuente de bugs en un stack poliglota es la brecha entre lo que el servicio Python envia y lo que el consumidor TypeScript espera. La solucion es un paso de build que genera esquemas Zod a partir de modelos Pydantic.

Usamos pydantic-to-typescript (o el mas reciente datamodel-code-generator) para emitir un archivo de interfaces TypeScript, y luego envolvemos las interfaces en esquemas Zod para validacion en tiempo de ejecucion. El pipeline se ejecuta en CI con cada cambio en el directorio de schemas de Python.

# schemas.py — single source of truth for the API contract
from pydantic import BaseModel, Field
from enum import StrEnum

class RiskLevel(StrEnum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

class AnalyseRequest(BaseModel):
    content: str = Field(min_length=1, max_length=50_000)
    document_type: str = Field(default="contract")

class Finding(BaseModel):
    clause: str
    risk: RiskLevel
    explanation: str
    confidence: float = Field(ge=0.0, le=1.0)

class AnalyseResponse(BaseModel):
    findings: list[Finding]
    summary: str
    processed_at: str  # ISO 8601
// generated — do not edit by hand
// run: make generate-types
import { z } from 'zod';

export const RiskLevel = z.enum(['low', 'medium', 'high']);
export type RiskLevel = z.infer<typeof RiskLevel>;

export const Finding = z.object({
  clause: z.string(),
  risk: RiskLevel,
  explanation: z.string(),
  confidence: z.number().min(0).max(1),
});
export type Finding = z.infer<typeof Finding>;

export const AnalyseResponse = z.object({
  findings: z.array(Finding),
  summary: z.string(),
  processed_at: z.string().datetime(),
});
export type AnalyseResponse = z.infer<typeof AnalyseResponse>;

export const AnalyseRequest = z.object({
  content: z.string().min(1).max(50_000),
  document_type: z.string().default('contract'),
});
export type AnalyseRequest = z.infer<typeof AnalyseRequest>;

El archivo generado se incluye en el repositorio. Si un modelo Pydantic cambia y el paso de generacion no se vuelve a ejecutar, el paso de type-check en CI falla porque el esquema Zod estara desactualizado. Esto crea una garantia mecanica de que ambos lados se mantienen sincronizados — sin sorpresas en tiempo de ejecucion.


Despliegue: Next.js en Vercel, FastAPI en Azure Container Apps

Next.js se despliega en Vercel — el camino de configuracion cero es la opcion correcta aqui. Entornos de preview, invalidacion de ISR, Edge Middleware y la CDN global se gestionan automaticamente. No hay nada que configurar mas alla de las variables de entorno.

FastAPI se despliega en Azure Container Apps. Elegimos esta opcion sobre AKS porque abstrae la gestion del cluster sin dejar de soportar integracion con VNET personalizada, Dapr y escalado a cero real con replicas minimas configurables. Las cargas de trabajo de Azure Data Scientist Associate (Azure ML, Azure OpenAI) residen en la misma suscripcion, por lo que la identidad administrada y las asignaciones de roles son directas.

# Dockerfile
FROM python:3.12-slim AS base

WORKDIR /app

# install uv for deterministic, fast dependency resolution
RUN pip install uv==0.4.10

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

---

FROM base AS runtime

COPY app/ ./app/
COPY alembic/ ./alembic/
COPY alembic.ini ./

# non-root user
RUN adduser --disabled-password --gecos "" appuser
USER appuser

EXPOSE 8000

CMD ["uv", "run", "uvicorn", "app.main:app", \
     "--host", "0.0.0.0", \
     "--port", "8000", \
     "--workers", "2", \
     "--loop", "uvloop"]

Dos workers es intencionado. Azure Container Apps escala horizontalmente anadiendo replicas, no verticalmente dentro de una replica. Mas de dos workers por replica genera presion de memoria sin beneficio de rendimiento en una carga de trabajo de consumo estandar.

La configuracion de Container Apps establece las replicas minimas en 1 para el entorno de produccion. Esto elimina los cold starts por completo para los usuarios de pago. En staging, las replicas minimas son 0 — el cold start alli es aceptable y mantiene los costes cercanos a cero.


Aspectos a vigilar

Cold starts

Los contenedores Python tardan mas en arrancar que los contenedores Node. Un contenedor FastAPI nuevo con LangChain y el Azure OpenAI SDK importados tarda tipicamente entre 4 y 8 segundos en alcanzar un estado saludable. Dos mitigaciones: mantener las replicas minimas en 1 en produccion (como se menciono arriba), y usar un endpoint /health que responda inmediatamente sin tocar la base de datos para que el balanceador de carga marque el contenedor como listo rapidamente. No realices I/O bloqueante en el scope a nivel de modulo — difiere la creacion del pool de conexiones a la base de datos al evento del ciclo de vida de startup.

CORS en produccion

Dado que FastAPI no es accesible directamente desde el navegador — todas las peticiones se canalizan a traves de Next.js — el CORS en el servicio FastAPI solo necesita permitir el rango de IPs de salida del servidor Next.js, no *. En la practica restringimos al CIDR de Vercel o, mejor aun, confiamos en la integracion VNET para que el servicio FastAPI sea completamente inalcanzable desde internet. Nunca configures allow_origins=["*"] en un servicio que acepta JWTs autenticados.

Problemas con el streaming

Tres cosas rompen el streaming de formas que no son obvias de depurar:

  • Timeout de funciones de Vercel. Las funciones de streaming tienen un limite de timeout diferente al de las funciones estandar. En el plan Pro, el limite de streaming es de 300 segundos. Si tus respuestas de IA superan habitualmente los dos minutos, establece maxDuration = 300 en la configuracion del segmento de ruta.
  • Buffering de Nginx. Ya mencionado: X-Accel-Buffering: no en las cabeceras de respuesta de FastAPI. Azure Application Gateway tiene una configuracion equivalente (request-timeout) que puede absorber streams lentos — configuralo a 300 segundos.
  • Fetch duplex: 'half'. Node 18+ requiere esta opcion cuando quieres leer el cuerpo de una respuesta en streaming desde fetch(). Sin ella obtienes un TypeError: Cannot set property body of #<Request>. La definicion de tipos de TypeScript aun no incluye duplex, por lo que el cast a RequestInit en el ejemplo anterior es intencionado.

tRPC vs fetch directo

tRPC es la eleccion correcta para la comunicacion Next.js con las API routes de Next.js — te proporciona seguridad de tipos de extremo a extremo sin necesidad de un archivo de esquema. No es la eleccion correcta para Next.js con FastAPI, porque tRPC requiere un servidor Node.js en ambos extremos. Usa fetch() directo con los esquemas Zod generados para validacion en la frontera. Reserva tRPC para la carga de datos interna de servidor a cliente en Next.js, donde se aprovecha la integracion con React Query.


Reflexiones finales

La arquitectura descrita aqui esta en produccion en dos empresas que manejan datos empresariales sensibles. La separacion no se trata de ingenieria poliglota de moda — se trata de usar la herramienta adecuada para cada trabajo. TypeScript es mejor en la capa de UI y edge. Python es mejor en la capa de IA y ciencia de datos. La frontera entre ambos es estrecha, bien definida y segura en tipos.

Las partes que requieren mas cuidado son la frontera de autenticacion (JWTs de un solo uso, sin cookies cruzando servicios), el pipeline de streaming (canalizar el ReadableStream, configurar las cabeceras correctas, ajustar los timeouts) y el paso de generacion de tipos (ejecutarlo en CI, fallar de forma ruidosa ante cualquier desviacion). Acierta en esos tres puntos y el resto es sencillo.