Building an employee self-service portal in Webflow Cloud gives you a designed, authenticated app on the same domain as your public site, without a separate hosting account or a separate design system.
An employee self-service portal is one of the most common internal tools a growing company needs and one of the hardest to keep consistent with the rest of what you ship. It usually lives on a separate domain, uses a different design system, and deploys through a completely different pipeline than your public site.
Building one in Webflow Cloud lets you close that gap. Your portal lives on the same domain as your public site, uses the same design system, and deploys through the same pipeline. Auth0 handles employee authentication at the edge; Cloudflare D1 stores time-off requests via the Drizzle ORM; and the Webflow CMS Data API surfaces company announcements that your HR team can update without touching code.
In this guide, we build a portal with four working features: Auth0 login and logout, a protected employee profile endpoint, a time-off request API backed by SQLite, and a company announcements feed from Webflow CMS.
What do you need to build an employee self-service portal in Webflow Cloud?
You need a Webflow Cloud project running Next.js, an Auth0 account with an application configured, the jose library for edge-compatible JWT verification, and a Webflow site with a CMS collection set up for company announcements.
Here is the full list of what you need before starting:
- A Webflow Cloud project running a Next.js app
- An Auth0 account with a Regular Web Application configured
- Node.js 22 or higher installed locally
- The jose, drizzle-orm, and @opennextjs/cloudflare packages
- A Webflow site with a CMS collection for announcements, on any site plan including the free Starter plan
- A Webflow Data API site token with cms:read scope
None of these requires paid tiers to start. Auth0's free plan covers up to 25,000 monthly active users, and Webflow's free Starter site plan includes the CMS. Let's walk through each dependency.
For reference on building a more complete full-stack foundation before adding the portal features in this guide, see how to build a full-stack Webflow Cloud app with Supabase, Auth0, and Stripe, which covers the initial scaffolding in detail.
6 steps to build an employee self-service portal in Webflow Cloud
Building the portal means wiring five separate systems together:
- Auth0 for authentication
- jose for JWT verification
- Drizzle ORM with D1 for time-off data
- KV for announcement caching
- The Webflow CMS API for content
Each step is self-contained but builds on the previous one.
Let’s work through them in order.
1. Scaffold the project and set Auth0 credentials as environment variables
Start from a clean Webflow Cloud Next.js project and install the required packages:
npm install jose drizzle-orm drizzle-kit @opennextjs/cloudflare
npm install --save-dev @types/better-sqlite3
This installs jose for JWT verification, drizzle-orm for type-safe SQL queries, drizzle-kit for migration management, and the OpenNext Cloudflare adapter, which gives you access to D1 and KV bindings in Route Handlers.
The @types/better-sqlite3 package provides TypeScript types for the D1 SQLite interface, which Drizzle uses internally for the D1 dialect.
Creating a.dev.varsfile in your project root
Create a .dev.vars file in your project root for local development secrets. This file is specific to Wrangler's local development server and is separate from .env.local.
The Webflow Cloud project template adds it to .gitignore automatically:
# .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 reads .dev.vars automatically during wrangler dev. For production, add these same keys in the Webflow Cloud dashboard under your project's Settings, then Environment Variables. Never commit .dev.vars to source control because it contains your Auth0 client secret and Webflow API token in plaintext.
Adding the D1 and KV bindings to your wrangler.json.
Open the file in your project root and add the d1_databases and kv_namespaces blocks:
{
"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"
}
]
}
The binding values (DB and CACHE_KV) are the names you will reference in getCloudflareContext().env.DB and getCloudflareContext().env.CACHE_KV inside Route Handlers.
Create the D1 database and KV namespace by running wrangler d1 create portal-db and wrangler kv namespace create CACHE_KV from your terminal, then paste the returned IDs into wrangler.json.
2. Wire Auth0 login, callback, and logout Route Handlers
The authentication flow has three routes:
/auth/loginredirects to Auth0/auth/callbackexchanges the authorization code for tokens and sets a cookie/auth/logoutclears the cookie and ends the Auth0 session
All three are standard Next.js Route Handlers and run on the Workers runtime by default. Do not add runtime = 'edge': the OpenNext Cloudflare adapter Webflow Cloud uses does not support it.
Start with the login redirect:
// 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=/`,
},
})
}
This route redirects the browser to Auth0's authorization endpoint. Auth0 handles the login UI, which can include username and password, Google, Microsoft, or enterprise SSO, depending on your tenant configuration.
After the employee authenticates, Auth0 redirects back to your callback URL with an authorization code in the query string, along with the same state value.
That state value is what stops an attacker from feeding your callback an authorization code of their own choosing and silently signing an employee into the wrong account. Auth0 is explicit that you should always use the state parameter on the authorization code flow. The login route generates a random value, sends it to Auth0, and stores it in a short-lived HttpOnly cookie. The callback then compares the two and refuses the exchange unless they match, which is why the check runs before the token request rather than after it.
The callback route validates that state, exchanges the code for tokens, and sets the session cookie:
// 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 })
}
This stores the ID token as an httpOnly cookie with a 24-hour expiry. The HttpOnly flag prevents JavaScript from reading the cookie, which blocks direct token exfiltration via XSS, though injected script can still make authenticated same-origin requests.
SameSite=Lax allows the cookie on top-level navigations but blocks it on cross-origin subresource requests, which mitigates CSRF. The cookie is the only authentication state the portal maintains between requests, keeping the auth layer stateless and compatible with Cloudflare Workers' distributed execution model.
Finally, the logout route clears the cookie and redirects to Auth0's logout endpoint to end the Auth0 session as well:
// 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=/',
},
})
}
Setting Max-Age=0 on the cookie instructs the browser to delete it immediately. Redirecting to Auth0's /v2/logout endpoint simultaneously invalidates the Auth0 session, so employees cannot be silently re-authenticated by an existing Auth0 SSO session after they click logout.
Without the Auth0 logout redirect, employees who share a computer would be immediately logged back in after clearing the cookie, since the Auth0 session would remain active.
3. Build the JWT verification utility and protect employee data endpoints
With auth cookies in place, every protected Route Handler needs to verify the token before serving data. A shared verification utility in lib/auth.ts keeps this logic in one place and consistent across all endpoints:
// 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 fetches Auth0's public keys from the JWKS endpoint on the first call and caches them in memory for the lifetime of the Worker instance. The issuer and audience options validate that the token was issued by your specific Auth0 tenant and for your application's Client ID.
A token from a different tenant or Auth0 app will fail verification, even if its signature is valid, preventing token reuse across environments or projects.
Use this utility in every protected Route Handler.
Here is the employee profile endpoint as the canonical reference pattern:
// 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 })
}
}
Every Route Handler in the portal follows the same two-line guard at the top: extract the token from the cookie header, verify it and return 401 if either step fails. The explicit Route Handler-level check prevents the CVE-2025-29927 bypass vector, in which a crafted x-middleware-subrequest header can skip Next.js middleware entirely.
No matter how that header is crafted, the Route Handler still independently verifies the JWT. This per-handler verification pattern mirrors how serverless apps on Webflow Cloud handle per-request authentication at the edge.
4. Set up the SQLite employee database schema with Drizzle ORM
The time-off request feature needs persistent storage.
Webflow Cloud's D1 binding provides a SQLite database running on Cloudflare's global infrastructure, and the Drizzle ORM gives you type-safe queries without the overhead of traditional ORM abstraction layers.
I have found Drizzle's approach particularly well-suited to Webflow Cloud because it generates plain SQL migrations that you can inspect, version-control, and apply with Wrangler, giving you full visibility into what runs against your database.
Define the schema in 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(),
})
The auth0Sub field stores the Auth0 sub claim from the JWT payload, which is the stable, immutable identifier for a user across all of Auth0's authentication methods. Using sub rather than email as the primary lookup key is important because email addresses can change, whereas Auth0's sub never changes for a given user.
The status field on time_off_requests defaults to 'pending' and will be updated to 'approved' or 'rejected' by a manager approval flow you can add later without changing the schema.
Create a drizzle.config.ts for migration management:
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/db/schema/index.ts',
out: './drizzle',
dialect: 'sqlite',
driver: 'd1-http',
})
Run npx drizzle-kit generate to create the migration SQL files in the drizzle/ directory. Apply the migration locally with wrangler d1 migrations apply portal-db --local during development. Before deploying to production, run wrangler d1 migrations apply portal-db --remote.
This two-step process ensures your schema is consistent between local development and the live database.
5. Build the time-off request submission and history API
With the schema in place, build two Route Handlers in the same file: a POST to submit new requests and a GET to retrieve an employee's history.
Both require JWT verification and access the D1 binding through 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 })
}
The getCloudflareContext() call retrieves the D1 binding from the Worker context, and drizzle(env.DB) wraps it with the ORM client.
Every query is scoped to the authenticated employee by matching the JWT sub claim against the auth0Sub column, which means an authenticated employee can only see and create their own time-off records.
The GET handler returns an empty requests array rather than a 404 when no employee record exists, because a newly authenticated user who has not yet submitted a request is not in an error state.
If you want to send employees a confirmation email when their time-off request is submitted, add the notification inside the POST handler after the insert. The email and SMS email and SMS guide with SendGrid and Twilio walks through the fetch-based email pattern that runs cleanly on Cloudflare Workers without any Node.js SDK dependencies.
6. Pull company announcements from the Webflow CMS Data API
The announcements section fetches items from the Webflow CMS collection and caches them in KV to avoid hitting the API rate limit on every page load. The Webflow Data API v2 enforces a per-minute request limit that varies by site plan, which sounds generous until dozens of employees open the portal simultaneously within the same minute.
The KV caching layer means only one request per 10-minute window reaches the Webflow API, regardless of how many employees load the page.
// 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 })
}
The KV cache check runs before the Webflow API call on every request. If the cache contains a fresh entry, the handler returns it immediately, without any external network requests. On a cache miss, the handler fetches the latest items from the Webflow Data API, maps the response to a leaner shape, writes to KV with a 600-second TTL, and returns the data.
The cache key is versioned (announcements:v1) so that future changes to the response shape can be deployed by bumping the version, which forces all existing caches to miss immediately without a manual flush operation.
The fieldData keys correspond to the field slugs you defined when creating the collection in the Webflow Designer. If you name your field "Publish Date," Webflow automatically generates the slug "publish-date".
You can verify the exact slugs for your collection by calling GET /v2/sites/:site_id/collections with your site token and inspecting the fields array in the response.
What breaks an employee self-service portal on Webflow Cloud?
Most failures in a Webflow Cloud portal trace back to one of four issues: cookie configuration that silently blocks the auth token, missing database migrations that cause opaque 500 errors, JWKS key mismatches from misconfigured Auth0 environment variables, and stale KV cache that serves outdated announcements after a CMS publish.
Here is how to recognize each one and what to change.
Auth cookie not sent on cross-origin requests
The SameSite=Lax cookie attribute works correctly when your portal's frontend and API routes share a domain.
If you call your Webflow Cloud app's API from a different origin, for example, a separate Webflow-hosted frontend at company.webflow.io making fetch requests to portal.company.com, the browser will not include the auth_token cookie in those cross-origin requests.
Every protected Route Handler will return 401, and the failure appears identical to a genuine authentication error, which makes it harder to diagnose without checking the browser's Network tab for the missing Cookie request header.
The standard fix is to ensure your frontend and Webflow Cloud API run on the same registered domain, which is the natural architecture for a single Webflow Cloud Next.js app that handles both the UI and API routes from a single origin.
If your project genuinely requires cross-origin API calls, switch the cookie to SameSite=None; Secure and configure explicit CORS headers in your Route Handlers to allowlist the requesting origin. Never use SameSite=None without both Secure and a precise CORS policy.
For a working reference of a Webflow Cloud app that spans multiple data sources, the Supabase dashboard guide for Webflow Cloud demonstrates a single-origin architecture with external API integrations.
SQLite migrations are not applied on the first deploy
The most common source of 500 errors in a fresh Webflow Cloud deployment is a D1 database that exists in the Cloudflare dashboard but has not had its migrations applied. Wrangler creates the database binding when you run wrangler d1 create, but the tables do not exist until you explicitly apply the migration.
Any INSERT or SELECT against a table that does not exist returns a database error that Cloudflare surfaces as a 500 with no useful message in the standard response body. The error is silent from the application's perspective, making it appear to be a code bug rather than an infrastructure setup step.
Run wrangler d1 migrations apply portal-db --remote after every deploy that includes a new migration file. The safest approach is to include this command as a pre-deploy step in your CI pipeline, so it runs automatically before wrangler deploy.
To confirm the migration applied successfully, run wrangler d1 execute portal-db --remote --command "SELECT name FROM sqlite_master WHERE type='table'" and verify your table names appear in the output. If the output is empty, a migration step is missing.
Connecting Sentry error tracking to your Webflow Cloud app will surface these database errors with a full stack trace on the first occurrence, which is far easier to act on than the generic 500 response.
JWT verification fails with "JWKSNoMatchingKey"
A JWKSNoMatchingKey error from jwtVerify means the JWT's kid (Key ID) header does not match any key in the fetched JWKS. This happens in two distinct scenarios. The first is when AUTH0_DOMAIN is set to the wrong value in your Webflow Cloud environment variables, causing the JWKS to be fetched from a different Auth0 tenant than the one that issued the token.
Check that the domain in your environment variables exactly matches the domain shown in your Auth0 application settings, including the .auth0.com suffix, and confirm there is no trailing slash.
The second scenario is an Auth0 signing key rotation. A signing key rotation in your Auth0 tenant, which an admin triggers manually, means the Worker instance that cached the old JWKS will briefly fail to verify tokens signed with the new key.
The jose library handles this automatically: when a JWKSNoMatchingKey error occurs, createRemoteJWKSet retries the JWKS fetch before throwing.
If you are seeing persistent failures even after a few seconds, verify that the Worker can reach https://your-tenant.auth0.com/.well-known/jwks.json by opening the URL in a browser outside the Worker context. A Cloudflare firewall rule or an account-level network restriction that blocks outbound requests to Auth0's CDN would produce exactly this symptom.
Webflow CMS API returns stale announcements
If your HR team publishes a new announcement in the Webflow Designer but employees on the portal still see the previous version, the KV cache is serving a stale entry. The 10-minute TTL in this guide means announcements can lag up to 10 minutes behind the live CMS content, which is an acceptable tradeoff for most organizations.
If your team publishes time-sensitive announcements, say, emergency communications or same-day policy changes, that lag may not be acceptable.
You have two options for reducing it:
(1) The first is to lower the CACHE_TTL constant in the Route Handler. Reducing it to 60 seconds means announcements are at most one minute stale, at the cost of one Webflow Data API call per minute per active Worker instance.
(2) The second and cleaner option is to set up a Webflow webhook on the collection_item_published event, pointing to a Route Handler in your portal that calls await kv.delete('announcements:v1').
The next request after the webhook fires will fetch fresh content from the API and repopulate the cache immediately. This pattern gives you real-time freshness on publish events without paying the rate-limit cost of a very short TTL.
Build an internal portal that your team will use
If you are new to building full-stack apps on Webflow Cloud, the serverless app guide is the right starting point before adding authentication. Once you are comfortable with the binding model, the complete Auth0 authentication guide covers the full OAuth flow in depth, including social login configuration and enterprise SSO.
If your portal grows into a multi-feature internal platform, the Webflow Cloud with Supabase shows how to structure a larger app with multiple data sources under the same Worker.
Frequently asked questions
Why use Webflow Cloud SQLite instead of an external database like Supabase or Neon?
D1 is the lowest-latency option because it runs directly on Cloudflare's infrastructure with no network hop to an external server. For a portal with simple per-employee inserts and selects, D1 is the right default. For complex relational queries or larger data models, Supabase and Neon are well-documented alternatives to Webflow Cloud.
How do I add a manager approval workflow to time-off requests?
Add a PATCH /api/time-off/[id] Route Handler that updates the status column from pending to approved or rejected. Protect it with a check for a manager role set via Auth0 Actions or RBAC in the JWT payload, and use Drizzle's update method to change the status.
Does the KV cache persist across Webflow Cloud deployments?
Yes. Cloudflare KV is a global, persistent store that survives Worker deployments and redeployments. Deploying a new version of your app does not flush KV. If you change the response shape during a deployment, bump the cache key version (for example, from announcements:v1 to announcements:v2) to force a cache miss on the next request.
How many employees can this portal support?
Cloudflare Workers scale horizontally without configuration, so the portal has no practical user cap at the application layer. Database size is the real ceiling and it varies by plan, so check the Webflow Cloud limits for your own. The Webflow Data API rate limit is the most likely bottleneck at scale, and the KV caching layer in Step 6 is specifically designed to prevent it.




