Host APIDog in a Subfolder on Vercel with Next.js

Milan Motavar
By Milan MotavarSeptember 20258 min read

We use APIDog for our API documentation and wanted to host it under subfolder on our next.js website running on Vercel. When we searched for it on internet, we couldn't find anything. APIDog's docs cover Nginx and Caddy, but there was no working guide for Vercel/Next.js.

After trial and error (and multiple infinite loops + decoding errors), we finally made it work. If you're trying to serve APIDog docs under /docs/ (or any subpath) in your Vercel-hosted Next.js app, here's the step-by-step solution — with full code you can paste and run.

The Problem

By default, Vercel + Next.js normalize URLs by removing trailing slashes. For example:

  • /partner/ → /partner
  • /docs/ → /docs

For APIDog, this breaks everything:

  • The homepage loads, but
  • All internal links become dead because /docs/ is stripped out.

APIDog expects to live under a subdirectory like /docs/ and rewrites all relative URLs accordingly. Without the trailing slash and a proper proxy, it falls apart.

Failed Attempts

  • Rewrites in next.config.js: You can proxy to APIDog, but you can't set the required headers (X-Apidog-Docs-Site-ID etc.). Links still break.
  • Middleware: Adding redirects for /docs → /docs/ often caused infinite loops.
  • Default Vercel settings: No way to keep /docs/ without custom logic.

The Working Solution

Step 1: Enable Subdirectory in APIDog

In your APIDog custom domain panel:

  • Domain: your actual site domain (e.g. www.sendzen.io)
  • Mode: Reverse Proxy
  • Subdirectory: /docs/ (must end with a slash)
  • Republish

Step 2: Add a Route Handler in Next.js

In your Next.js app, create a new file:

app/docs/[[...path]]/route.ts

Note: Replace YOUR_PROJECT_ID with your actual APIDog project ID and URL.

import { NextRequest, NextResponse } from "next/server";

export const runtime = "nodejs";

const APIDOG_SITE_ID = "YOUR_PROJECT_ID"; // Replace with your APIDog project ID
const APIDOG_ORIGIN = "https://YOUR_PROJECT_ID.eu.apidog.com"; // Replace with your APIDog origin URL
const SUBDIR = "/docs";

function rewriteHtmlToSubdir(html: string): string {
  html = html.replaceAll(`${APIDOG_ORIGIN}/`, `${SUBDIR}/`);

  html = html.replaceAll('href="/', `href="${SUBDIR}/`);
  html = html.replaceAll('src="/', `src="${SUBDIR}/`);
  html = html.replaceAll('action="/', `action="${SUBDIR}/`);

  html = html.replaceAll("url(/", `url(${SUBDIR}/`);

  html = html.replaceAll(`fetch("/`, `fetch("${SUBDIR}/`);
  html = html.replaceAll(`axios.get("/`, `axios.get("${SUBDIR}/`);
  html = html.replaceAll(`axios.post("/`, `axios.post("${SUBDIR}/`);

  if (/]*>/i.test(html) && !/]*)>/i, ``);
  }
  return html;
}

function mapLocationToSubdir(loc: string): string {
  try {
    if (loc.startsWith("/")) return SUBDIR + loc;
    const u = new URL(loc, APIDOG_ORIGIN);
    if (u.origin === APIDOG_ORIGIN) {
      return SUBDIR + u.pathname + u.search + u.hash;
    }
    return loc;
  } catch {
    return loc;
  }
}

async function proxy(req: NextRequest, pathSegs?: string[]) {
  const method = req.method;
  const incoming = new URL(req.url);
  const path = pathSegs?.join("/") ?? "";
  const upstreamUrl = new URL(`${APIDOG_ORIGIN}/${path}`);
  upstreamUrl.search = incoming.search;

  const headers: Record = {
    "X-Apidog-Docs-Site-ID": APIDOG_SITE_ID,
    "X-Forwarded-Prefix": SUBDIR,
    "X-Forwarded-Host": incoming.host,
    // Ask for plain text to avoid gzip mismatches
    "Accept-Encoding": "identity",
  };

  const pass = ["accept", "accept-language", "user-agent", "cookie", "content-type"];
  for (const key of pass) {
    const v = req.headers.get(key);
    if (v) headers[key] = v;
  }

  const init: RequestInit = { method, headers, redirect: "manual" };
  if (method !== "GET" && method !== "HEAD") {
    const body = await req.arrayBuffer();
    init.body = body;
  }

  let upstreamRes: Response;
  try {
    upstreamRes = await fetch(upstreamUrl.toString(), init);
  } catch (err: any) {
    return new NextResponse(
      `Upstream fetch to APIDog failed.\nURL: ${upstreamUrl.toString()}\nError: ${String(
        err?.message || err
      )}`,
      { status: 502, headers: { "Content-Type": "text/plain" } }
    );
  }

  if (upstreamRes.status >= 300 && upstreamRes.status < 400) {
    const loc = upstreamRes.headers.get("location");
    if (loc) return NextResponse.redirect(mapLocationToSubdir(loc), upstreamRes.status);
  }

  const contentType = upstreamRes.headers.get("content-type") ?? "";
  if (!contentType.includes("text/html")) {
    return new NextResponse(upstreamRes.body, {
      status: upstreamRes.status,
      headers: upstreamRes.headers,
    });
  }

  const html = await upstreamRes.text();
  const rewritten = rewriteHtmlToSubdir(html);

  const headersOut = new Headers(upstreamRes.headers);
  // We changed the body. Remove encoding and length from upstream.
  headersOut.delete("content-encoding");
  headersOut.delete("content-length");
  headersOut.delete("transfer-encoding");
  headersOut.set("content-type", "text/html; charset=utf-8");

  return new NextResponse(rewritten, {
    status: upstreamRes.status,
    headers: headersOut,
  });
}

export async function GET(req: NextRequest, ctx: { params: Promise<{ path?: string[] }> }) {
  const params = await ctx.params;
  return proxy(req, params.path);
}

export const HEAD = GET;
export const POST = GET;
export const PUT = GET;
export const PATCH = GET;
export const DELETE = GET;
export const OPTIONS = GET;

Step 3: Clean Up next.config.js

Remove any rewrites for /docs so they don't conflict. Keep it minimal:

/** @type {import('next').NextConfig} */
const nextConfig = {
  // experimental: { skipTrailingSlashRedirect: true },
  // skipTrailingSlashRedirect: true
};
export default nextConfig;

Why This Works

  • We proxy APIDog ourselves and inject the required headers.
  • We force /docs → /docs/.
  • We rewrite HTML and strip upstream gzip headers to prevent decoding errors.
  • We add <base href="/docs/"> so even missed links resolve correctly.

Results

Now https://www.sendzen.io/docs serves my APIDog docs perfectly inside a subfolder. All links and assets stay under /docs/.

This wasn't documented anywhere for Vercel, so hopefully this saves you a few days of pain.

Closing Note

At SendZen, we're building developer-first infrastructure for WhatsApp APIs. We are obsessed with these kinds of edge-case details that make or break a developer experience.

If you enjoyed this post and care about developer tools, check out SendZen

Host APIDog in a Subfolder on Vercel with Next.js - SendZen Blog | SendZen