How to build a user dashboard with login and booking in Webflow

How to build a user dashboard with login and booking in Webflow

Learn how to build a Next.js login and booking dashboard on Webflow Cloud.

How to build a user dashboard with login and booking in Webflow

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

Webflow Cloud lets you place a secure booking dashboard beside your marketing pages, with middleware.ts and a signed cookie keeping page access and customer data aligned.

A Webflow marketing site can now carry customers directly into their own bookings without sending them to a separate app on a subdomain with its own hosting, deploy pipeline, and disconnected login screen.

Webflow Cloud lets you mount a Next.js app on a path of the same site, so /app/dashboard sits beside the marketing pages and ships from the same project.

A reliable dashboard keeps its two halves aligned on identity. The gate decides whether to render the page at all, and the data route decides whose bookings to return.

When those two check different things, a user sees an empty list, or worse, a list that belongs to someone else. A session cookie signed with one secret, and verified by both halves with that same secret, is what makes them agree.

That is what this build produces: a Next.js app where middleware.ts is the gate, a login Route Handler issues the cookie, booking Route Handlers trust only the user id inside it, and a client-side dashboard page fetches through the mount path Webflow Cloud assigns.

What do you need to build a Next.js login and booking dashboard in Webflow?

You need a Webflow site, local Node.js and npm, a supported Next.js project, an identity endpoint, and a booking endpoint. Webflow Cloud is available from the free Starter site plan up, while mounting an app to a custom domain requires Premium or higher.

Together, these prerequisites provide the project foundation, identity check, and private booking-store connection the dashboard needs.

Prepare these five items before building the application:

  • Webflow site: Use the free Starter site plan or higher, and upgrade to Premium when mounting the dashboard to your custom domain.
  • Local toolchain: Install Node.js and npm locally so you can scaffold, run, and test the application before deployment.
  • Next.js project: Use a Next.js 15 project, which the scaffold command creates with the supported App Router structure. Pin the major version. Next.js 16 deprecates middleware.ts and renames it proxy.ts, and the Next.js docs state that "Proxy defaults to using the Node.js runtime" and that "the config option is not available in Proxy files," so it cannot opt into Edge. Webflow Cloud runs only Edge runtime middleware, so on 16 the gate in step 3 stops running.
  • Identity endpoint: Provide an HTTP endpoint that accepts an email and password and returns a JSON id field on success.
  • Booking endpoint: Provide an HTTP endpoint that lists and creates bookings, protected by a bearer token stored as a secret. This is your booking store.

With these pieces ready, you can build the session boundary first, connect both server-side endpoints, and then deploy the finished dashboard through Webflow Cloud.

7 steps to build a Next.js login and booking dashboard in Webflow Cloud

Build the dashboard by scaffolding a supported Next.js app, sharing one signed session across middleware and Route Handlers, connecting the booking store, and prefixing browser requests with the Webflow Cloud mount path.

Keep every file in one Next.js project.

1. Scaffold the Next.js app with npm

Create the app with the App Router and TypeScript, and let create-next-app choose npm. The npm lockfile must match the package manager Webflow Cloud runs. Webflow's wording is unambiguous: "Currently, Webflow Cloud supports only the npm package manager." Keep lockfiles from other package managers out of the repository.

Run this from the directory where you keep projects:

npx create-next-app@15 booking-dashboard --typescript --app --eslint --no-src-dir --no-tailwind --import-alias "@/*" --use-npm
cd booking-dashboard
npm run dev

The @15 matters twice over. Next.js 16 deprecates middleware.ts and renames it proxy.ts, which cannot run on Webflow Cloud, so the major has to stay at 15. Within 15, stay above 15.2.3: releases from 15.0.0 up to that version carry GHSA-f82v-jwr5-mffw, a critical authorization bypass that lets a crafted request skip middleware entirely.

Since this build's gate is middleware, an older pin would hand you a dashboard anyone can open. @15 resolves to the newest 15.x, which is clear of it.

You now have a Next.js project with an app/ directory and an npm lockfile. Leave next.config unchanged, with no base path or output mode; Webflow's bring-your-own-app page puts it as "No adapter, no base path, no wrangler.json," because the mount path is injected at build time. Do not add export const runtime = 'edge' to route files.

Open http://localhost:3000 and you should see the default Next.js welcome page, which confirms the toolchain is healthy before any of your own code goes in.

2. Write the signed session helper

Create a single shared module that keeps the gate and data routes in sync by turning a user ID into a signed token and checking that token back into a user ID. The signature is an HMAC over a base64url payload, produced with crypto.subtle, the Web Crypto API that exists in Next.js middleware and on the Cloudflare Workers runtime Webflow Cloud deploys to.

I use crypto.subtle.verify here for consistent behavior across environments. crypto.subtle.timingSafeEqual is a Cloudflare extension to Web Crypto: it exists on the deployed Workers runtime but is undefined under local next dev, where Node's SubtleCrypto has no such method. verify performs the signature comparison itself and behaves the same in both environments.

Create lib/session.ts with this content:

// lib/session.ts
const encoder = new TextEncoder();
const decoder = new TextDecoder();

export type SessionPayload = { sub: string; exp: number };

async function hmacKey(secret: string) {
  return crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign', 'verify']
  );
}

function toBase64Url(bytes: Uint8Array) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

function fromBase64Url(text: string) {
  const padded = text.replace(/-/g, '+').replace(/_/g, '/');
  return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
}

export async function createSession(
  userId: string,
  secret: string,
  ttlSeconds = 60 * 60 * 24 * 7
) {
  const payload: SessionPayload = { sub: userId, exp: Date.now() + ttlSeconds * 1000 };
  const body = toBase64Url(encoder.encode(JSON.stringify(payload)));
  const key = await hmacKey(secret);
  const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(body));
  return `${body}.${toBase64Url(new Uint8Array(signature))}`;
}

export async function verifySession(
  token: string,
  secret: string
): Promise<SessionPayload | null> {
  try {
    const [body, signature] = token.split('.');
    if (!body || !signature) return null;
    const key = await hmacKey(secret);
    const valid = await crypto.subtle.verify(
      'HMAC',
      key,
      fromBase64Url(signature),
      encoder.encode(body)
    );
    if (!valid) return null;
    const payload = JSON.parse(decoder.decode(fromBase64Url(body))) as SessionPayload;
    return payload.exp > Date.now() ? payload : null;
  } catch {
    return null;
  }
}

You now have two functions that share a secret and nothing else. The try-around verification matters more than it looks: a tampered or truncated cookie makes atob throw, and without the catch, that throw becomes a server error in middleware instead of a redirect to login.

A token signed with one secret and checked with another returns null, which is exactly the behavior the gate needs.

3. Gate the dashboard with middleware.ts

Add a middleware.ts file at the project root to redirect unauthenticated page requests to /login and return an unauthorized JSON response for unauthenticated API requests. The file verifies the cookie before handling either result.

The file name is load-bearing on Webflow Cloud. Webflow's framework customization docs state: "Node.js runtime middleware isn't supported. Only Edge runtime middleware works on the Workers runtime." Next.js middleware in a file named middleware.ts runs on the Edge runtime by default, which is why it works here without any directive.

Clone request.nextUrl to build the redirect. The clone keeps the redirect tied to the incoming request URL while you change its path to /login.

Save this at the root of the project, beside package.json:

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { verifySession } from '@/lib/session';

export async function middleware(request: NextRequest) {
  const secret = process.env.SESSION_SECRET;
  if (!secret) {
    return new NextResponse('SESSION_SECRET is not configured', { status: 500 });
  }

  const token = request.cookies.get('session')?.value;
  const session = token ? await verifySession(token, secret) : null;

  if (session) {
    return NextResponse.next();
  }

  if (request.nextUrl.pathname.startsWith('/api/')) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const loginUrl = request.nextUrl.clone();
  loginUrl.pathname = '/login';
  loginUrl.search = '';
  loginUrl.searchParams.set('next', request.nextUrl.pathname);
  return NextResponse.redirect(loginUrl);
}

export const config = {
  matcher: ['/dashboard/:path*', '/api/bookings/:path*'],
};

The matcher covers both the page and the API, so an unauthenticated fetch gets a clean unauthorized JSON response. Add SESSION_SECRET=any-long-random-string to .env.local, restart next dev, and visit http://localhost:3000/dashboard.

You should be redirected to /login?next=%2Fdashboard, and a request to /api/bookings should return an unauthorized JSON response. The dashboard page doesn't exist yet; the redirect proves the gate is in place.

4. Build the login route and page

Turn a successful response from your identity endpoint into a signed session cookie. The Route Handler forwards the email and password to that endpoint, then trusts a successful response with a valid string id. Password storage and comparison stay behind AUTH_API_URL.

That is the single line an agency developer swaps when a client changes identity providers.

Put the handler at app/api/login/route.ts:

// app/api/login/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { createSession } from '@/lib/session';

export async function POST(request: NextRequest) {
  const authApiUrl = process.env.AUTH_API_URL;
  const sessionSecret = process.env.SESSION_SECRET;

  if (!authApiUrl || !sessionSecret) {
    return NextResponse.json({ error: 'Server configuration is incomplete' }, { status: 500 });
  }

  let identityUrl: URL;
  try {
    identityUrl = new URL(authApiUrl);
  } catch {
    return NextResponse.json({ error: 'AUTH_API_URL is invalid' }, { status: 500 });
  }

  const { email, password } = (await request.json()) as {
    email?: string;
    password?: string;
  };
  if (!email || !password) {
    return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
  }

  let identity: Response;
  try {
    identity = await fetch(identityUrl, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });
  } catch {
    return NextResponse.json({ error: 'Identity service is unavailable' }, { status: 502 });
  }

  if (!identity.ok) {
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
  }

  let identityBody: unknown;
  try {
    identityBody = await identity.json();
  } catch {
    return NextResponse.json({ error: 'Identity service returned invalid JSON' }, { status: 502 });
  }

  if (
    typeof identityBody !== 'object' ||
    identityBody === null ||
    !('id' in identityBody) ||
    typeof identityBody.id !== 'string' ||
    !identityBody.id
  ) {
    return NextResponse.json({ error: 'Identity service returned an invalid user id' }, { status: 502 });
  }

  const token = await createSession(identityBody.id, sessionSecret);

  const response = NextResponse.json({ ok: true });
  response.cookies.set('session', token, {
    httpOnly: true,
    sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production',
    path: '/',
    maxAge: 60 * 60 * 24 * 7,
  });
  return response;
}

The cookie is httpOnly so page scripts cannot read it, and secure only in production so local HTTP still works. The page that posts to this route is a client component, and it needs one guard of its own: the next parameter comes from the URL, so accept it only when it is a same-site path.

Without that check, ?next=//evil.example becomes an open redirect after a successful login.

The login form goes in app/login/page.tsx:

// app/login/page.tsx
'use client';

import { useState, type FormEvent } from 'react';

const base = process.env.NEXT_PUBLIC_BASE_PATH ?? '';

export default function LoginPage() {
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);

    const response = await fetch(`${base}/api/login`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        email: form.get('email'),
        password: form.get('password'),
      }),
    });

    if (!response.ok) {
      setError('Check your email and password.');
      return;
    }

    const requested = new URLSearchParams(window.location.search).get('next');
    const next =
      requested && new URL(requested, window.location.origin).origin === window.location.origin
        ? requested
        : '/dashboard';
    window.location.assign(`${base}${next}`);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" placeholder="Email" required />
      <input name="password" type="password" placeholder="Password" required />
      <button type="submit">Log in</button>
      {error && <p>{error}</p>}
    </form>
  );
}

Add AUTH_API_URL to .env.local pointing at your identity endpoint, then submit valid credentials at /login. The browser's cookie jar should now hold a session cookie with a dot in the middle, and the page should navigate to /dashboard, which currently returns a not-found response because it has not been written yet.

That response confirms that the cookie was verified.

5. Add the booking routes

Scope both listing and creation requests with the verified cookie's sub value. One Route Handler file can expose GET and POST, with both methods taking the user id from that cookie. This stops one logged-in customer from reading or creating another customer's bookings: the browser can send any userId it likes in JSON, and the route ignores it.

The route also holds the bearer token for your booking store. That key lives in a server-side environment variable and is attached to the upstream request inside the handler, so the browser only ever sees your own /api/bookings path.

For an agency developer, that boundary is the difference between a client's booking store being public and being private.

Create app/api/bookings/route.ts with both methods:

// app/api/bookings/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { verifySession } from '@/lib/session';

async function currentUserId(request: NextRequest, sessionSecret: string) {
  const token = request.cookies.get('session')?.value;
  if (!token) return null;
  const session = await verifySession(token, sessionSecret);
  return session?.sub ?? null;
}

function upstreamHeaders(bookingApiKey: string) {
  return {
    'content-type': 'application/json',
    authorization: `Bearer ${bookingApiKey}`,
  };
}

async function upstreamJson(upstream: Response) {
  try {
    return await upstream.json();
  } catch {
    return null;
  }
}

export async function GET(request: NextRequest) {
  const sessionSecret = process.env.SESSION_SECRET;
  const bookingApiUrl = process.env.BOOKING_API_URL;
  const bookingApiKey = process.env.BOOKING_API_KEY;

  if (!sessionSecret || !bookingApiUrl || !bookingApiKey) {
    return NextResponse.json({ error: 'Server configuration is incomplete' }, { status: 500 });
  }

  const userId = await currentUserId(request, sessionSecret);
  if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  let url: URL;
  try {
    url = new URL(bookingApiUrl);
  } catch {
    return NextResponse.json({ error: 'BOOKING_API_URL is invalid' }, { status: 500 });
  }

  url.searchParams.set('userId', userId);

  let upstream: Response;
  try {
    upstream = await fetch(url, { headers: upstreamHeaders(bookingApiKey) });
  } catch {
    return NextResponse.json({ error: 'Booking store is unavailable' }, { status: 502 });
  }

  const body = await upstreamJson(upstream);
  if (body === null) {
    return NextResponse.json({ error: 'Booking store returned invalid JSON' }, { status: 502 });
  }

  return NextResponse.json(body, { status: upstream.status });
}

export async function POST(request: NextRequest) {
  const sessionSecret = process.env.SESSION_SECRET;
  const bookingApiUrl = process.env.BOOKING_API_URL;
  const bookingApiKey = process.env.BOOKING_API_KEY;

  if (!sessionSecret || !bookingApiUrl || !bookingApiKey) {
    return NextResponse.json({ error: 'Server configuration is incomplete' }, { status: 500 });
  }

  const userId = await currentUserId(request, sessionSecret);
  if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const { service, date } = (await request.json()) as { service?: string; date?: string };
  if (!service || !date) {
    return NextResponse.json({ error: 'service and date are required' }, { status: 400 });
  }

  let url: URL;
  try {
    url = new URL(bookingApiUrl);
  } catch {
    return NextResponse.json({ error: 'BOOKING_API_URL is invalid' }, { status: 500 });
  }

  let upstream: Response;
  try {
    upstream = await fetch(url, {
      method: 'POST',
      headers: upstreamHeaders(bookingApiKey),
      body: JSON.stringify({ userId, service, date }),
    });
  } catch {
    return NextResponse.json({ error: 'Booking store is unavailable' }, { status: 502 });
  }

  const body = await upstreamJson(upstream);
  if (body === null) {
    return NextResponse.json({ error: 'Booking store returned invalid JSON' }, { status: 502 });
  }

  return NextResponse.json(body, { status: upstream.status });
}

Security status by route: POST /api/login is intentionally unauthenticated; authorization does not apply before identity is established, and the route keeps credentials and signing secrets server-side; however, this sample does not implement application-level rate limiting or brute-force controls, and it does not perform an explicit origin or CSRF check before issuing a session cookie.

Both are unresolved production requirements. GET /api/bookings authenticates the signed cookie and authorizes access by deriving the user ID from its verified sub; CSRF protection does not apply to this read-only operation, but a rate limit is still an unresolved production requirement.

POST /api/bookings uses the same authentication and user-scoped authorization. Still, it has no application-level rate limit and no explicit origin or CSRF check for the state-changing request, so both remain unresolved production requirements.

Production security requirements and spend caps

None of these routes enforces an application-level spend cap for calls to the identity endpoint or booking store, so spend caps are also an unresolved production requirement when either upstream system is usage-billed.

The BOOKING_API_KEY and SESSION_SECRET remain server-side, while NEXT_PUBLIC_BASE_PATH is intentionally client-visible and contains no secret. This build has no OAuth or webhook flow, so OAuth state validation, webhook signature verification, and webhook replay defense do not apply.

The route verifies the cookie a second time because it needs the actual sub value to scope the upstream call. Verification keeps identity tied to the signed payload and prevents clients from forging it through a header.

Add BOOKING_API_URL and BOOKING_API_KEY to .env.local, then, while logged in, open /api/bookings in the browser. You should get back whatever your booking store returns for your user, as JSON, with the store's own status code passed through.

6. Build the dashboard page with the base path prefix

Prefix every browser request from the page with NEXT_PUBLIC_BASE_PATH so a dashboard served at /app/dashboard sends requests through the same mount path. The client component lists bookings and submits new ones.

Webflow Cloud injects the mount path into the server-side build, but the bring-your-own-app docs are explicit about the browser side: "Client-side fetch calls must manually include the base path to correctly reach your endpoints." A bare fetch('/api/bookings') asks the Webflow site for /api/bookings, which the site does not have.

Locally, with no mount path, the variable is unset and the prefix resolves to an empty string, so the same code works in both places. One React detail: capture event.currentTarget before the await, because React nulls it once the handler yields, and calling reset() afterward would throw.

Save the page as app/dashboard/page.tsx:

// app/dashboard/page.tsx
'use client';

import { useEffect, useState, type FormEvent } from 'react';

const base = process.env.NEXT_PUBLIC_BASE_PATH ?? '';

type Booking = { id: string; service: string; date: string };

export default function DashboardPage() {
  const [bookings, setBookings] = useState<Booking[]>([]);
  const [error, setError] = useState<string | null>(null);

  async function loadBookings() {
    const response = await fetch(`${base}/api/bookings`);
    if (response.ok) {
      setBookings(await response.json());
      setError(null);
    } else {
      setError('Could not load bookings.');
    }
  }

  useEffect(() => {
    loadBookings();
  }, []);

  async function createBooking(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const formElement = event.currentTarget;
    const form = new FormData(formElement);

    const response = await fetch(`${base}/api/bookings`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ service: form.get('service'), date: form.get('date') }),
    });

    if (!response.ok) {
      setError('Could not create the booking.');
      return;
    }

    setError(null);
    formElement.reset();
    await loadBookings();
  }

  return (
    <main>
      <h1>Your bookings</h1>
      {error && <p>{error}</p>}
      <ul>
        {bookings.map((booking) => (
          <li key={booking.id}>
            {booking.service} on {booking.date}
          </li>
        ))}
      </ul>

      <form onSubmit={createBooking}>
        <input name="service" placeholder="Service" required />
        <input name="date" type="date" required />
        <button type="submit">Book</button>
      </form>
    </main>
  );
}

Log in locally and open /dashboard. The list should populate from your booking store, and submitting the form should add a row and refresh the list without a page reload.

Log out by deleting the session cookie in dev tools and reload; you should land back on /login with next=%2Fdashboard in the query string.

7. Set the environment variables and deploy to Webflow Cloud

Configure the server secrets, upstream endpoints, and public mount path in Webflow Cloud before triggering the production deployment.

Add these five environment variables:

  • SESSION_SECRET: A long random string that signs and verifies the cookie; mark it secret so Webflow redacts it from build logs.
  • AUTH_API_URL: The endpoint that receives an email and password and answers successfully with a JSON id field when the credentials are valid.
  • BOOKING_API_URL: The endpoint that lists bookings for a userId query parameter and accepts a POST body to create a new one.
  • BOOKING_API_KEY: The bearer token your booking store expects; mark it secret, since it only ever leaves the server inside a Route Handler.
  • NEXT_PUBLIC_BASE_PATH: The mount path you gave the app, such as /app, with no trailing slash; Next.js compiles it into the browser bundle at build time.

You now have the complete runtime configuration for signing sessions, reaching both upstream services, and routing browser requests through the mounted application path.

Webflow's bring-your-own-app page states: "Both secret and non-secret environment variables are available to your application's build process ... and remain available to the deployed application at runtime."

That single sentence is why NEXT_PUBLIC_BASE_PATH works here: Next.js reads it during the build to inline it into the browser bundle, and Webflow Cloud makes it available at that stage.

Managing session secrets across environments

The SESSION_SECRET value in Webflow Cloud and the one in .env.local can differ; each environment signs and verifies its own cookies. Within one environment, the value must stay identical between build and runtime. Webflow Cloud guarantees that by exposing the same set to both.

The environment panel should now show all five variables, with SESSION_SECRET and BOOKING_API_KEY marked as secrets and their values hidden.

Point the environment at your app's repository, set the mount path to match NEXT_PUBLIC_BASE_PATH, and trigger a deploy.

Once the build finishes, open the mounted path followed by /dashboard on the live site. You should be redirected to the mounted /login, and after signing in, see your bookings render at the mounted /dashboard, with the network tab showing requests to /app/api/bookings.

What causes a Next.js login and booking dashboard on Webflow Cloud to fail?

Most failures come from an unsupported Route Handler runtime, a renamed middleware file, an incorrect public base path, or a missing or inconsistent session secret in the deployed environment.

Check the relevant runtime, routing, mount-path, or cookie-verification setting for each symptom.

The build fails with a Route Handler runtime error

Cause: Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, and that adapter does not support the Next.js edge runtime target. Developers add the line anyway because the word "edge" means two things on the same docs page.

Webflow Cloud runs on Cloudflare Workers, which is an edge platform, and the bring-your-own-app page describes it that way. The same page also blurs that distinction by suggesting the unsupported Next.js runtime target for API routes.

That runtime target is separate, and following the guidance breaks the build.

Fix: Remove export const runtime = 'edge', leave next.config without a base path or output mode, and redeploy. Check app/api/login/route.ts and app/api/bookings/route.ts if you copied the directive from the docs.

Visiting /dashboard renders without a login redirect after upgrading Next.js

Cause: The upgrade path for newer Next.js releases renames middleware.ts to proxy.ts, and a codemod or a well-meaning teammate may have done that rename. proxy runs on the Node.js runtime and cannot opt into the Edge runtime, and Webflow Cloud's Workers runtime doesn't run Node.js middleware.

The file builds but never executes on the deployed site, so the gate disappears and every path under /dashboard renders for anyone. The API routes still return an unauthorized response without a cookie because they verify the session themselves, so the dashboard remains empty and data stays protected.

Fix: Keep the file named middleware.ts and keep the exported function named middleware. If the rename has already landed, rename it back and confirm in the deployed environment that an unauthenticated request to the mounted /dashboard path redirects to /login.

Check git log on the file before assuming the gate is intact after any Next.js version bump, and add a deploy-time check that requests the dashboard without a cookie and fails the pipeline if the page renders successfully.

The dashboard renders, but the bookings list is empty, and dev tools show a not-found response on /api/bookings

Cause: A missing NEXT_PUBLIC_BASE_PATH sends client-side requests to the Webflow site's root instead of the app's mount path. A trailing slash also produces the wrong request path. If the variable was added after the last build, the browser bundle still contains the previous value because Next.js inlines NEXT_PUBLIC_ values at build time.

Fix: Set NEXT_PUBLIC_BASE_PATH to the app's mount path without a trailing slash, then trigger a fresh build. Confirm in the deployed page's network tab that requests go to /app/api/bookings (or whatever your mount path is) and return JSON.

If the login form works but the dashboard does not, compare the two fetches; both read the same base constant, so a mismatch means one page was built before the variable existed and cached.

Login succeeds on the deployed site, but the browser lands back on /login immediately

Cause: The cookie was issued and then rejected during verification. First, check that SESSION_SECRET is configured consistently in the deployed environment. Middleware in this build returns a server error when the secret is absent, and the login and booking handlers reject incomplete configuration before signing or verifying a session.

Secrets are redacted from build logs, so you cannot confirm the value by reading the log; you confirm it by behavior.

Fix: Restore the SESSION_SECRET configuration, redeploy, and test again. Keep crypto.subtle.verify for consistent verification: crypto.subtle.timingSafeEqual exists on the deployed Workers runtime but is undefined under local next dev. If the loop persists, check that the cookie's path is /.

What you can build next with Next.js and Webflow

Once the gate, the session, and the two booking routes are live, every further feature a customer asks for is one more Route Handler that imports verifySession and forwards a scoped request.

I prefer to stop hard-coding the list of bookable services here. Webflow's CMS is reachable through the Data API, with relationships and locales, so a marketer can maintain the service catalog in a collection. At the same time, a Route Handler reads it with a token held as a Webflow Cloud secret.

Frequently asked questions

Can you use Astro or Vite instead of Next.js for this dashboard?

Yes. Webflow Cloud accepts Astro 6 or 7, Next.js 15 or higher, and Vite 6.1 or higher with React, Vue, Svelte, or vanilla JavaScript. You can port the Web Crypto session helper unchanged, but you must move the request gate from middleware.ts to the interception mechanism your chosen framework provides for protected paths.

Can you preserve query parameters when redirecting through login?

No. This implementation can preserve only the pathname because middleware clears the incoming search string before adding the next parameter. To restore filters or campaign parameters after login, extend the redirect logic while keeping the same-site path validation that blocks protocol-relative destinations after authentication.

When does your signed session stop working?

Your session stops working when the signed payload expires, the cookie reaches its maximum age, or you delete the cookie. This implementation sets both limits when you log in and does not refresh them on later requests, so normal dashboard activity does not extend the session. Keeping an expired cookie cannot restore access.

Can you submit extra fields or change the booking owner from the browser?

No. You cannot change the booking owner or forward arbitrary fields with this handler. It reads only the service and date, then builds the upstream body with those values and the verified session user. To support another field, add it explicitly to the request type, validate it, and include it in the upstream body you construct.


Last Updated
September 25, 2026
Category

Related articles


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

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.