Cómo crear un portal de autoservicio para empleados en Webflow Cloud con Auth0, D1 y la API de datos de CMS

Cómo crear un portal de autoservicio para empleados en Webflow Cloud con Auth0, D1 y la API de datos del CMS

Aprende a crear un portal de autoservicio para empleados en Webflow Cloud con Next.js, Auth0, SQLite y los CMS de Webflow.

Cómo crear un portal de autoservicio para empleados en Webflow Cloud con Auth0, D1 y la API de datos del CMS

Ismail Ajagbe
Redactor técnico
Ver perfil del autor
Ismail Ajagbe
Redactor técnico
Ver perfil del autor
Índice de contenidos

Crear un portal de autoservicio para empleados en Webflow Cloud te permite tener una aplicación diseñada y autenticada en el mismo dominio que tu sitio público, sin necesidad de una cuenta de hosting ni un sistema de diseño por separado.

Un portal de autoservicio para empleados es una de las herramientas internas más comunes que necesita una empresa en crecimiento y, a la vez, una de las más difíciles de mantener en sintonía con el resto de tus lanzamientos. Por lo general, vive en un dominio independiente, utiliza un sistema de diseño diferente y se despliega a través de un flujo de trabajo totalmente distinto al de tu sitio público.

Crearlo en Webflow Cloud te permite cerrar esa brecha. Tu portal vive en el mismo dominio que tu sitio público, usa el mismo sistema de diseño y se despliega mediante el mismo flujo de trabajo. Auth0 gestiona la autenticación de los empleados en el borde (edge); Cloudflare D1 almacena las solicitudes de vacaciones mediante Drizzle ORM; y la API de datos de CMS de Webflow muestra los anuncios de la empresa que tu equipo de RR. HH. puede actualizar sin tocar una sola línea de código.

En esta guía, crearemos un portal con cuatro funciones operativas: inicio y cierre de sesión con Auth0, un endpoint protegido para el perfil del empleado, una API de solicitudes de vacaciones respaldada por SQLite y un feed de anuncios de la empresa desde el CMS de Webflow.

¿Qué necesitas para crear un portal de autoservicio para empleados en Webflow Cloud?

Necesitas un proyecto de Webflow Cloud ejecutando Next.js, una cuenta de Auth0 con una aplicación configurada, la librería jose para la verificación de JWT compatible con el borde, y un sitio de Webflow con una colección de CMS configurada para los anuncios de la empresa.

Aquí tienes la lista completa de lo que necesitas antes de empezar:

  • Un proyecto de Webflow Cloud ejecutando una aplicación Next.js
  • Una cuenta de Auth0 con una aplicación web regular (Regular Web Application) configurada
  • Node.js 22 o superior instalado localmente
  • Los paquetes jose, drizzle-orm y @opennextjs/cloudflare
  • Un sitio de Webflow con una colección de CMS para anuncios, en cualquier plan de sitio, incluido el plan gratuito Starter
  • Un token de sitio de la API de datos de Webflow con alcance cms:read

Nada de esto requiere planes de pago para empezar. El plan gratuito de Auth0 cubre hasta 25,000 usuarios activos mensuales, y el plan de sitio gratuito Starter de Webflow incluye el CMS. Repasemos cada dependencia.

Para obtener más información sobre cómo construir una base full-stack más completa antes de añadir las funciones del portal de esta guía, consulta cómo crear una aplicación full-stack en Webflow Cloud con Supabase, Auth0 y Stripe, donde se detalla la configuración inicial.

6 pasos para crear un portal de autoservicio para empleados en Webflow Cloud

Crear el portal implica conectar cinco sistemas distintos:

  • Auth0 para la autenticación
  • jose para la verificación de JWT
  • Drizzle ORM con D1 para datos de vacaciones
  • KV para el almacenamiento en caché de anuncios
  • La API de CMS de Webflow para el contenido

Cada paso es independiente, pero se basa en el anterior.

Vamos a seguirlos en orden.

1. Prepara el proyecto y configura las credenciales de Auth0 como variables de entorno

Empieza con un proyecto limpio de Webflow Cloud Next.js e instala los paquetes necesarios:

npm install jose drizzle-orm drizzle-kit @opennextjs/cloudflare
npm install --save-dev @types/better-sqlite3

Esto instala jose para la verificación de JWT, drizzle-orm para consultas SQL con tipado seguro, drizzle-kit para la gestión de migraciones y el adaptador OpenNext Cloudflare, que te da acceso a los enlaces D1 y KV en los Route Handlers.

El paquete @types/better-sqlite3 proporciona tipos de TypeScript para la interfaz SQLite de D1, que Drizzle utiliza internamente para el dialecto D1.

Crear un.dev.varsarchivo en la raíz de tu proyecto

Crea un .dev.vars archivo en la raíz de tu proyecto para las claves secretas de desarrollo local. Este archivo es específico para el servidor de desarrollo local de Wrangler y es independiente de .env.local.

La plantilla de proyecto de Webflow Cloud lo añade a .gitignore automáticamente:

# .dev.vars
AUTH0_DOMAIN=your-tenant.auth0.com
AUTH0_CLIENT_ID=your_client_id
AUTH0_CLIENT_SECRET=your_client_secret
AUTH0_CALLBACK_URL=http://localhost:3000/auth/callback
WEBFLOW_API_TOKEN=your_site_token
WEBFLOW_COLLECTION_ID=your_announcements_collection_id

Wrangler lee .dev.vars automáticamente durante wrangler dev. Para producción, añade estas mismas claves en el panel de control de Webflow Cloud, en la sección de Ajustes de tu proyecto, bajo Variables de entorno. Nunca subas .dev.vars al control de versiones, ya que contiene tu secreto de cliente de Auth0 y el token de la API de Webflow en texto plano.

Añadir los enlaces D1 y KV a tu wrangler.json.

Abre el archivo en la raíz de tu proyecto y añade los bloques d1_databases y kv_namespaces:

{
  "name": "employee-portal",
  "compatibility_date": "2024-09-23",
  "compatibility_flags": ["nodejs_compat"],
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "portal-db",
      "database_id": "your-d1-database-id",
      "migrations_dir": "drizzle"
    }
  ],
  "kv_namespaces": [
    {
      "binding": "CACHE_KV",
      "id": "your-kv-namespace-id"
    }
  ]
}

Los valores binding (DB y CACHE_KV) son los nombres a los que harás referencia en getCloudflareContext().env.DB y getCloudflareContext().env.CACHE_KV dentro de los Route Handlers.

Crea la base de datos D1 y el espacio de nombres KV ejecutando wrangler d1 create portal-db y wrangler kv namespace create CACHE_KV desde tu terminal, luego pega los IDs obtenidos en wrangler.json.

2. Conecta los Route Handlers de inicio de sesión, devolución de llamada y cierre de sesión de Auth0

El flujo de autenticación tiene tres rutas:

  • /auth/login redirige a Auth0
  • /auth/callback intercambia el código de autorización por tokens y establece una cookie
  • /auth/logout elimina la cookie y finaliza la sesión de Auth0

Los tres son Route Handlers estándar de Next.js y se ejecutan de forma predeterminada en el entorno de ejecución de Workers. No añadas runtime = 'edge': el adaptador de OpenNext Cloudflare que utiliza Webflow Cloud no lo admite.

Comienza con la redirección de inicio de sesión:

// app/auth/login/route.ts
export async function GET() {
  // Random, single-use value tying this redirect to the callback that follows.
  const state = crypto.randomUUID()

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.AUTH0_CLIENT_ID!,
    redirect_uri: process.env.AUTH0_CALLBACK_URL!,
    scope: 'openid profile email',
    state,
  })

  return new Response(null, {
    status: 302,
    headers: {
      Location: `https://${process.env.AUTH0_DOMAIN}/authorize?${params}`,
      'Set-Cookie': `auth_state=${state}; HttpOnly; Secure; SameSite=Lax; Max-Age=600; Path=/`,
    },
  })
}

Esta ruta redirige al navegador al punto de conexión de autorización de Auth0. Auth0 gestiona la interfaz de inicio de sesión, que puede incluir nombre de usuario y contraseña, Google, Microsoft o SSO empresarial, según la configuración de tu inquilino.

Después de que el empleado se autentique, Auth0 redirige de vuelta a tu URL de callback con un código de autorización en la cadena de consulta, junto con el mismo valor de state.

Ese valor de state es lo que impide que un atacante envíe a tu callback un código de autorización elegido por ellos y registre silenciosamente a un empleado en la cuenta equivocada. Auth0 es explícito en que siempre debes usar el parámetro state en el flujo de código de autorización. La ruta de inicio de sesión genera un valor aleatorio, lo envía a Auth0 y lo almacena en una cookie HttpOnly de corta duración. El callback compara ambos y rechaza el intercambio a menos que coincidan, razón por la cual la comprobación se ejecuta antes de la solicitud del token y no después.

La ruta de callback valida ese estado, intercambia el código por tokens y establece la cookie de sesión:

// app/auth/callback/route.ts
export async function GET(request: Request) {
  const url = new URL(request.url)
  const code = url.searchParams.get('code')
  const state = url.searchParams.get('state')

  // The state echoed back by Auth0 must match the one set at login.
  const cookieState = request.headers
    .get('Cookie')
    ?.match(/(?:^|;\s*)auth_state=([^;]+)/)?.[1]

  if (!code || !state || !cookieState || state !== cookieState) {
    return new Response('Invalid authentication request', { status: 400 })
  }

  const tokenResponse = await fetch(
    `https://${process.env.AUTH0_DOMAIN}/oauth/token`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        client_id: process.env.AUTH0_CLIENT_ID,
        client_secret: process.env.AUTH0_CLIENT_SECRET,
        code,
        redirect_uri: process.env.AUTH0_CALLBACK_URL,
      }),
    }
  )

  if (!tokenResponse.ok) {
    return new Response('Authentication failed', { status: 401 })
  }

  const { id_token } = (await tokenResponse.json()) as { id_token: string }

  const headers = new Headers({
    Location: new URL('/portal', request.url).toString(),
  })
  headers.append(
    'Set-Cookie',
    `auth_token=${encodeURIComponent(id_token)}; HttpOnly; Secure; SameSite=Lax; Max-Age=86400; Path=/`
  )
  // Retire the state cookie so it cannot be replayed.
  headers.append(
    'Set-Cookie',
    'auth_state=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/'
  )

  return new Response(null, { status: 302, headers })
}

Esto almacena el token de ID como una cookie httpOnly con una caducidad de 24 horas. El indicador HttpOnly impide que JavaScript lea la cookie, lo que bloquea la exfiltración directa de tokens mediante XSS, aunque un script inyectado aún puede realizar solicitudes autenticadas del mismo origen.

SameSite=Lax permite la cookie en navegaciones de nivel superior pero la bloquea en solicitudes de subrecursos de origen cruzado, lo que mitiga el CSRF. La cookie es el único estado de autenticación que mantiene el portal entre solicitudes, lo que mantiene la capa de autenticación sin estado y compatible con el modelo de ejecución distribuida de Cloudflare Workers.

Finalmente, la ruta de cierre de sesión elimina la cookie y redirige al punto de conexión de cierre de sesión de Auth0 para finalizar también la sesión de Auth0:

// app/auth/logout/route.ts
export async function GET(request: Request) {
  const returnTo = encodeURIComponent(new URL('/', request.url).toString())
  const logoutUrl =
    `https://${process.env.AUTH0_DOMAIN}/v2/logout` +
    `?client_id=${process.env.AUTH0_CLIENT_ID}&returnTo=${returnTo}`

  return new Response(null, {
    status: 302,
    headers: {
      Location: logoutUrl,
      'Set-Cookie': 'auth_token=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/',
    },
  })
}

Establecer Max-Age=0 en la cookie le indica al navegador que la elimine inmediatamente. Redirigir al punto de conexión /v2/logout de Auth0 invalida simultáneamente la sesión de Auth0, por lo que los empleados no pueden ser reautenticados silenciosamente por una sesión de SSO de Auth0 existente después de hacer clic en cerrar sesión.

Sin la redirección de cierre de sesión de Auth0, los empleados que comparten una computadora volverían a iniciar sesión inmediatamente después de borrar la cookie, ya que la sesión de Auth0 permanecería activa.

3. Crea la utilidad de verificación JWT y protege los puntos de conexión de datos de los empleados

Con las cookies de autenticación implementadas, cada Route Handler protegido debe verificar el token antes de servir los datos. Una utilidad de verificación compartida en lib/auth.ts mantiene esta lógica en un solo lugar y es consistente en todos los puntos de conexión:

// lib/auth.ts
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'

const domain = process.env.AUTH0_DOMAIN!

export const JWKS = createRemoteJWKSet(
  new URL(`https://${domain}/.well-known/jwks.json`)
)

export async function verifyToken(token: string): Promise<JWTPayload> {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: `https://${domain}/`,
    audience: process.env.AUTH0_CLIENT_ID,
  })
  return payload
}

export function getTokenFromCookie(cookieHeader: string | null): string | null {
  if (!cookieHeader) return null
  const match = cookieHeader.match(/auth_token=([^;]+)/)
  return match ? decodeURIComponent(match[1]) : null
}

createRemoteJWKSet obtiene las claves públicas de Auth0 desde el punto de conexión JWKS en la primera llamada y las almacena en caché en la memoria durante la vida útil de la instancia del Worker. Las opciones issuer y audience validan que el token haya sido emitido por tu inquilino de Auth0 específico y para el ID de cliente de tu aplicación.

Un token de un inquilino o aplicación de Auth0 diferente fallará en la verificación, incluso si su firma es válida, lo que evita la reutilización de tokens en diferentes entornos o proyectos.

Usa esta utilidad en cada Route Handler protegido.

Aquí tienes el punto de conexión del perfil del empleado como patrón de referencia canónico:

// app/api/me/route.ts
import { getTokenFromCookie, verifyToken } from '@/lib/auth'

export async function GET(request: Request) {
  const token = getTokenFromCookie(request.headers.get('cookie'))
  if (!token) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const payload = await verifyToken(token)
    return Response.json({
      sub: payload.sub,
      name: payload.name,
      email: payload.email,
      picture: payload.picture,
    })
  } catch {
    return Response.json({ error: 'Invalid session' }, { status: 401 })
  }
}

Cada Route Handler en el portal sigue la misma protección de dos líneas en la parte superior: extraer el token del encabezado de la cookie, verificarlo y devolver un 401 si alguno de los pasos falla. La comprobación explícita a nivel de Route Handler evita el vector de omisión CVE-2025-29927, en el que un encabezado x-middleware-subrequest manipulado puede omitir el middleware de Next.js por completo.

Independientemente de cómo se cree esa cabecera, el Route Handler verifica el JWT de forma independiente. Este patrón de verificación por manejador refleja cómo las aplicaciones serverless en Webflow Cloud gestionan la autenticación por solicitud en el borde.

4. Configura el esquema de la base de datos de empleados en SQLite con Drizzle ORM

La función de solicitud de tiempo libre necesita almacenamiento persistente.

El binding D1 de Webflow Cloud proporciona una base de datos SQLite que se ejecuta en la infraestructura global de Cloudflare, y Drizzle ORM te ofrece consultas con tipado seguro sin la sobrecarga de las capas de abstracción de los ORM tradicionales.

He descubierto que el enfoque de Drizzle es especialmente adecuado para Webflow Cloud, ya que genera migraciones SQL sencillas que puedes inspeccionar, controlar mediante versiones y aplicar con Wrangler, lo que te da una visibilidad total de lo que se ejecuta en tu base de datos.

Define el esquema en src/db/schema/index.ts:

// src/db/schema/index.ts
import { sqliteTable, text, int } from 'drizzle-orm/sqlite-core'

export const employeesTable = sqliteTable('employees', {
  id: int().primaryKey({ autoIncrement: true }),
  auth0Sub: text().notNull().unique(),
  name: text().notNull(),
  email: text().notNull().unique(),
  department: text().notNull().default(''),
  createdAt: int({ mode: 'timestamp' }).notNull(),
})

export const timeOffRequestsTable = sqliteTable('time_off_requests', {
  id: int().primaryKey({ autoIncrement: true }),
  employeeId: int()
    .notNull()
    .references(() => employeesTable.id),
  startDate: text().notNull(),
  endDate: text().notNull(),
  reason: text().notNull().default(''),
  status: text().notNull().default('pending'),
  createdAt: int({ mode: 'timestamp' }).notNull(),
})

El campo auth0Sub almacena la reclamación sub de Auth0 proveniente del payload del JWT, que es el identificador estable e inmutable de un usuario en todos los métodos de autenticación de Auth0. Usar sub en lugar del correo electrónico como clave de búsqueda principal es importante porque las direcciones de correo pueden cambiar, mientras que el sub de Auth0 nunca cambia para un usuario determinado.

El campo status en time_off_requests tiene como valor predeterminado 'pending' y se actualizará a 'approved' o 'rejected' mediante un flujo de aprobación del gerente que podrás añadir más adelante sin necesidad de cambiar el esquema.

Crea un drizzle.config.ts para la gestión de migraciones:

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  schema: './src/db/schema/index.ts',
  out: './drizzle',
  dialect: 'sqlite',
  driver: 'd1-http',
})

Ejecuta npx drizzle-kit generate para crear los archivos SQL de migración en el directorio drizzle/. Aplica la migración localmente con wrangler d1 migrations apply portal-db --local durante el desarrollo. Antes de desplegar a producción, ejecuta wrangler d1 migrations apply portal-db --remote.

Este proceso de dos pasos garantiza que tu esquema sea coherente entre el desarrollo local y la base de datos en vivo.

5. Crea la API de envío e historial de solicitudes de tiempo libre

Con el esquema listo, crea dos Route Handlers en el mismo archivo: uno POST para enviar nuevas solicitudes y otro GET para recuperar el historial de un empleado.

Ambos requieren verificación JWT y acceden al binding D1 a través de getCloudflareContext():

// app/api/time-off/route.ts
import { getCloudflareContext } from '@opennextjs/cloudflare'
import { drizzle } from 'drizzle-orm/d1'
import { eq } from 'drizzle-orm'
import { getTokenFromCookie, verifyToken } from '@/lib/auth'
import { employeesTable, timeOffRequestsTable } from '@/db/schema'

export async function POST(request: Request) {
  const token = getTokenFromCookie(request.headers.get('cookie'))
  if (!token) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  let payload
  try {
    payload = await verifyToken(token)
  } catch {
    return Response.json({ error: 'Invalid session' }, { status: 401 })
  }

  const { env } = getCloudflareContext()
  const db = drizzle(env.DB)

  const employee = await db
    .select()
    .from(employeesTable)
    .where(eq(employeesTable.auth0Sub, payload.sub as string))
    .get()

  if (!employee) {
    return Response.json({ error: 'Employee not found' }, { status: 404 })
  }

  const body = (await request.json()) as {
    startDate: string
    endDate: string
    reason?: string
  }

  const record = await db
    .insert(timeOffRequestsTable)
    .values({
      employeeId: employee.id,
      startDate: body.startDate,
      endDate: body.endDate,
      reason: body.reason ?? '',
      status: 'pending',
      createdAt: new Date(),
    })
    .returning()
    .get()

  return Response.json({ id: record.id, status: 'pending' }, { status: 201 })
}

export async function GET(request: Request) {
  const token = getTokenFromCookie(request.headers.get('cookie'))
  if (!token) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  let payload
  try {
    payload = await verifyToken(token)
  } catch {
    return Response.json({ error: 'Invalid session' }, { status: 401 })
  }

  const { env } = getCloudflareContext()
  const db = drizzle(env.DB)

  const employee = await db
    .select()
    .from(employeesTable)
    .where(eq(employeesTable.auth0Sub, payload.sub as string))
    .get()

  if (!employee) {
    return Response.json({ requests: [] })
  }

  const requests = await db
    .select()
    .from(timeOffRequestsTable)
    .where(eq(timeOffRequestsTable.employeeId, employee.id))
    .orderBy(timeOffRequestsTable.createdAt)
    .all()

  return Response.json({ requests })
}

La llamada getCloudflareContext() recupera el binding D1 del contexto del Worker, y drizzle(env.DB) lo envuelve con el cliente ORM.

Cada consulta se limita al empleado autenticado haciendo coincidir la reclamación sub del JWT con la columna auth0Sub, lo que significa que un empleado autenticado solo puede ver y crear sus propios registros de tiempo libre.

El manejador GET devuelve un array requests vacío en lugar de un error 404 cuando no existe un registro de empleado, ya que un usuario recién autenticado que aún no ha enviado una solicitud no se encuentra en un estado de error.

Si quieres enviar a los empleados un correo de confirmación cuando se envíe su solicitud de tiempo libre, añade la notificación dentro del manejador POST después de la inserción. La guía de email y SMS con SendGrid y Twilio explica el patrón de correo electrónico basado en fetch que funciona perfectamente en Cloudflare Workers sin dependencias del SDK de Node.js.

6. Obtén los anuncios de la empresa desde la API de datos de Webflow CMS

La sección de anuncios obtiene elementos de la colección de Webflow CMS y los almacena en caché en KV para evitar alcanzar el límite de tasa de la API en cada carga de página. La API de datos v2 de Webflow impone un límite de solicitudes por minuto que varía según el plan del sitio, lo cual parece generoso hasta que decenas de empleados abren el portal simultáneamente en el mismo minuto.

La capa de caché KV permite que solo una solicitud por cada ventana de 10 minutos llegue a la API de Webflow, sin importar cuántos empleados carguen la página.

// app/api/announcements/route.ts
import { getCloudflareContext } from '@opennextjs/cloudflare'
import { getTokenFromCookie, verifyToken } from '@/lib/auth'

const CACHE_TTL = 600 // 10 minutes

export async function GET(request: Request) {
  const token = getTokenFromCookie(request.headers.get('cookie'))
  if (!token) return Response.json({ error: 'Unauthorized' }, { status: 401 })

  try {
    await verifyToken(token)
  } catch {
    return Response.json({ error: 'Invalid session' }, { status: 401 })
  }

  const { env } = getCloudflareContext()
  const kv = env.CACHE_KV
  const cacheKey = 'announcements:v1'

  const cached = await kv.get(cacheKey)
  if (cached) {
    return Response.json(JSON.parse(cached))
  }

  const collectionId = process.env.WEBFLOW_COLLECTION_ID!
  const apiResponse = await fetch(
    `https://api.webflow.com/v2/collections/${collectionId}/items?limit=10`,
    {
      headers: {
        Authorization: `Bearer ${process.env.WEBFLOW_API_TOKEN}`,
        Accept: 'application/json',
      },
    }
  )

  if (!apiResponse.ok) {
    return Response.json({ error: 'Failed to fetch announcements' }, { status: 502 })
  }

  const data = (await apiResponse.json()) as {
    items: Array<{
      id: string
      fieldData: { name: string; body: string; 'publish-date': string }
    }>
  }

  const announcements = data.items.map((item) => ({
    id: item.id,
    title: item.fieldData.name,
    body: item.fieldData.body,
    publishDate: item.fieldData['publish-date'],
  }))

  await kv.put(cacheKey, JSON.stringify({ announcements }), {
    expirationTtl: CACHE_TTL,
  })

  return Response.json({ announcements })
}

La comprobación de la caché KV se ejecuta antes de la llamada a la API de Webflow en cada solicitud. Si la caché contiene una entrada reciente, el controlador la devuelve de inmediato, sin realizar solicitudes de red externas. Si no hay coincidencia en la caché, el controlador obtiene los elementos más recientes de la API de datos de Webflow, adapta la respuesta a un formato más ligero, la escribe en KV con un TTL de 600 segundos y devuelve los datos.

La clave de caché está versionada () para que los cambios futuros en el formato de la respuesta puedan implementarse actualizando la versión, lo que obliga a que todas las cachés existentes caduquen de inmediato sin necesidad de una operación de limpieza manual.announcements:v1

Las fieldData claves corresponden a los slugs de campo que definiste al crear la colección en el Webflow Designer. Si llamas a tu campo "Publish Date", Webflow genera automáticamente el slug "publish-date".

Puedes verificar los slugs exactos de tu colección llamando a GET /v2/sites/:site_id/collections con tu token de sitio e inspeccionando el array fields en la respuesta.

¿Qué es lo que hace que un portal de autoservicio para empleados en Webflow Cloud deje de funcionar?

La mayoría de los fallos en un portal de Webflow Cloud se deben a uno de estos cuatro problemas: una configuración de cookies que bloquea silenciosamente el token de autenticación, migraciones de base de datos faltantes que provocan errores 500 opacos, discrepancias en las claves JWKS por variables de entorno de Auth0 mal configuradas y una caché KV obsoleta que muestra anuncios antiguos después de una publicación en el CMS.

Aquí te explico cómo reconocer cada uno y qué cambiar.

La cookie de autenticación no se envía en solicitudes de origen cruzado

El atributo de cookie SameSite=Lax funciona correctamente cuando el frontend de tu portal y las rutas de la API comparten el mismo dominio.

Si llamas a la API de tu aplicación de Webflow Cloud desde un origen diferente, por ejemplo, un frontend independiente alojado en Webflow en company.webflow.io realizando solicitudes fetch a portal.company.com, el navegador no incluirá la cookie auth_token en esas solicitudes de origen cruzado.

Cada controlador de ruta protegido devolverá un error 401, y el fallo parecerá idéntico a un error de autenticación real, lo que dificulta el diagnóstico sin revisar la pestaña de red del navegador para ver si falta la cabecera de solicitud Cookie.

La solución estándar es asegurarse de que el frontend y la API de Webflow Cloud se ejecuten en el mismo dominio registrado, que es la arquitectura natural para una aplicación Next.js de Webflow Cloud que gestiona tanto la interfaz de usuario como las rutas de la API desde un único origen.

Si tu proyecto requiere realmente llamadas a la API de origen cruzado, cambia la cookie a SameSite=None; Secure y configura cabeceras CORS explícitas en tus controladores de ruta para incluir el origen solicitante en la lista blanca. Nunca uses SameSite=None sin Secure y una política CORS precisa.

Para obtener una referencia funcional de una aplicación de Webflow Cloud que abarca múltiples fuentes de datos, la guía del panel de control de Supabase para Webflow Cloud demuestra una arquitectura de origen único con integraciones de API externas.

Las migraciones de SQLite no se aplican en el primer despliegue

La causa más común de errores 500 en un despliegue nuevo de Webflow Cloud es una base de datos D1 que existe en el panel de control de Cloudflare pero cuyas migraciones no se han aplicado. Wrangler crea el enlace a la base de datos cuando ejecutas wrangler d1 create, pero las tablas no existen hasta que aplicas la migración explícitamente.

Cualquier INSERT o SELECT contra una tabla que no existe devuelve un error de base de datos que Cloudflare muestra como un 500 sin un mensaje útil en el cuerpo de la respuesta estándar. El error es silencioso desde la perspectiva de la aplicación, lo que hace que parezca un error de código en lugar de un paso de configuración de infraestructura.

Ejecuta wrangler d1 migrations apply portal-db --remote después de cada despliegue que incluya un nuevo archivo de migración. El enfoque más seguro es incluir este comando como un paso previo al despliegue en tu canalización de CI, para que se ejecute automáticamente antes de wrangler deploy.

Para confirmar que la migración se aplicó correctamente, ejecuta wrangler d1 execute portal-db --remote --command "SELECT name FROM sqlite_master WHERE type='table'" y verifica que los nombres de tus tablas aparezcan en el resultado. Si el resultado está vacío, falta un paso de migración.

Conectar el seguimiento de errores de Sentry a tu aplicación de Webflow Cloud mostrará estos errores de base de datos con un seguimiento de pila completo desde la primera vez que ocurran, lo cual es mucho más fácil de gestionar que la respuesta genérica 500.

La verificación de JWT falla con "JWKSNoMatchingKey"

Un error de proviene de y significa que el encabezado (ID de clave) del JWT no coincide con ninguna clave en el JWKS obtenido. Esto ocurre en dos escenarios distintos. El primero es cuando se configura con un valor incorrecto en las variables de entorno de Webflow Cloud, lo que provoca que el JWKS se obtenga de un inquilino de Auth0 diferente al que emitió el token.JWKSNoMatchingKeyjwtVerifykidAUTH0_DOMAIN

Comprueba que el dominio en tus variables de entorno coincida exactamente con el dominio que aparece en la configuración de tu aplicación de Auth0, incluido el sufijo , y confirma que no haya una barra diagonal al final..auth0.com

El segundo escenario es una rotación de claves de firma de Auth0. Una rotación de claves de firma en tu inquilino de Auth0, activada manualmente por un administrador, significa que la instancia del Worker que almacenó en caché el JWKS antiguo fallará brevemente al verificar los tokens firmados con la nueva clave.

La biblioteca gestiona esto automáticamente: cuando ocurre un error de , vuelve a intentar obtener el JWKS antes de lanzar el error.joseJWKSNoMatchingKeycreateRemoteJWKSet

Si sigues viendo fallos persistentes incluso después de unos segundos, verifica que el Worker pueda acceder a abriendo la URL en un navegador fuera del contexto del Worker. Una regla de firewall de Cloudflare o una restricción de red a nivel de cuenta que bloquee las solicitudes salientes a la CDN de Auth0 produciría exactamente este síntoma.https://your-tenant.auth0.com/.well-known/jwks.json

La API de CMS de Webflow devuelve anuncios desactualizados

Si tu equipo de RR. HH. publica un nuevo anuncio en el Webflow Designer pero los empleados en el portal siguen viendo la versión anterior, es porque la caché KV está sirviendo una entrada desactualizada. El TTL de 10 minutos de esta guía significa que los anuncios pueden tener un retraso de hasta 10 minutos respecto al contenido real del CMS, lo cual es un compromiso aceptable para la mayoría de las organizaciones.

Si tu equipo publica anuncios urgentes, como comunicaciones de emergencia o cambios de política que entran en vigor el mismo día, ese retraso podría no ser aceptable.

Tienes dos opciones para reducirlo:

(1) La primera es reducir la constante en el Route Handler. Reducirla a 60 segundos significa que los anuncios tendrán como máximo un minuto de antigüedad, a costa de realizar una llamada a la API de datos de Webflow por minuto por cada instancia activa del Worker.CACHE_TTL

(2) La segunda opción, y la más limpia, es configurar un webhook de Webflow en el evento , apuntando a un Route Handler en tu portal que llame a .collection_item_publishedawait kv.delete('announcements:v1')

La siguiente solicitud después de que se active el webhook obtendrá contenido nuevo de la API y volverá a llenar la caché de inmediato. Este patrón te ofrece frescura en tiempo real en los eventos de publicación sin pagar el coste de límite de tasa de un TTL muy corto.

Crea un portal interno que tu equipo realmente use

Si eres nuevo en la creación de aplicaciones full-stack en Webflow Cloud, la guía de aplicaciones serverless es el punto de partida ideal antes de añadir la autenticación. Una vez que te sientas cómodo con el modelo de vinculación, la guía completa de autenticación de Auth0 cubre el flujo OAuth completo en profundidad, incluida la configuración de inicio de sesión social y SSO empresarial.

Si tu portal crece hasta convertirse en una plataforma interna con múltiples funciones, la guía de Webflow Cloud con Supabase muestra cómo estructurar una aplicación más grande con múltiples fuentes de datos bajo el mismo Worker.

Preguntas frecuentes

¿Por qué usar SQLite de Webflow Cloud en lugar de una base de datos externa como Supabase o Neon?

D1 es la opción con menor latencia porque se ejecuta directamente en la infraestructura de Cloudflare sin saltos de red hacia un servidor externo. Para un portal con inserciones y selecciones sencillas por empleado, D1 es la opción predeterminada ideal. Para consultas relacionales complejas o modelos de datos más grandes, Supabase y Neon son alternativas bien documentadas para Webflow Cloud.

¿Cómo añado un flujo de aprobación de gerente a las solicitudes de tiempo libre?

Añade un Route Handler que actualice la columna de a o . Protégelo con una verificación de un rol establecido mediante Auth0 Actions o RBAC en la carga útil del JWT, y utiliza el método de Drizzle para cambiar el estado.PATCH /api/time-off/[id]statuspendingapprovedrejectedmanagerupdate

¿La caché KV persiste tras los despliegues en Webflow Cloud?

Sí. Cloudflare KV es un almacenamiento global y persistente que sobrevive a los despliegues y redespliegues de los Workers. Desplegar una nueva versión de tu aplicación no vacía la caché KV. Si cambias la estructura de la respuesta durante un despliegue, aumenta la versión de la clave de caché (por ejemplo, de announcements:v1 a announcements:v2) para forzar un fallo de caché en la siguiente solicitud.

¿A cuántos empleados puede dar soporte este portal?

Cloudflare Workers escala horizontalmente sin necesidad de configuración, por lo que el portal no tiene un límite práctico de usuarios en la capa de aplicación. El tamaño de la base de datos es el verdadero límite y varía según el plan, así que consulta los límites de Webflow Cloud correspondientes al tuyo. El límite de velocidad de la API de datos de Webflow es el cuello de botella más probable a gran escala, y la capa de caché KV del paso 6 está diseñada específicamente para evitarlo.


Última actualización
August 8, 2026
Categoría

Artículos relacionados

Cómo abrir un modal de Typeform al hacer clic en un botón en Webflow sin redirigir a los usuarios
Cómo abrir un modal de Typeform al hacer clic en un botón en Webflow sin redirigir a los usuarios

Cómo abrir un modal de Typeform al hacer clic en un botón en Webflow sin redirigir a los usuarios

Cómo abrir un modal de Typeform al hacer clic en un botón en Webflow sin redirigir a los usuarios

Desarrollo
Por
Colin Lateano
,
,
Leer artículo
Cómo añadir registros de Datadog a una aplicación de Webflow Cloud que ya utiliza Sentry
Cómo añadir registros de Datadog a una aplicación de Webflow Cloud que ya utiliza Sentry

Cómo añadir registros de Datadog a una aplicación de Webflow Cloud que ya utiliza Sentry

Cómo añadir registros de Datadog a una aplicación de Webflow Cloud que ya utiliza Sentry

Guías
Por
Ismail Ajagbe
,
,
Leer artículo
Cómo crear un cuestionario interactivo en Webflow Cloud con React
Cómo crear un cuestionario interactivo en Webflow Cloud con React

Cómo crear un cuestionario interactivo en Webflow Cloud con React

Cómo crear un cuestionario interactivo en Webflow Cloud con React

Guías
Por
Ismail Ajagbe
,
,
Leer artículo
La guía definitiva para sincronizar pedidos de Webflow con Airtable mediante Zapier
La guía definitiva para sincronizar pedidos de Webflow con Airtable mediante Zapier

La guía definitiva para sincronizar pedidos de Webflow con Airtable mediante Zapier

La guía definitiva para sincronizar pedidos de Webflow con Airtable mediante Zapier

Desarrollo
Por
Colin Lateano
,
,
Leer artículo

verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo

Empieza gratis

Prueba Webflow todo el tiempo que quieras con nuestro plan Starter gratuito. Compra un plan de sitio de pago para publicar, alojar y desbloquear funciones adicionales.

Empieza, es gratis
Ver demostración

Prueba Webflow todo el tiempo que quieras con nuestro plan Starter gratuito. Compra un plan de sitio de pago para publicar, alojar y desbloquear funciones adicionales.