DEV Community

Cover image for How I Built a Multi-Tenant Documentation Platform with Next.js 15 (Subdomain Routing, MDX, and ISR)
Gautam
Gautam

Posted on

How I Built a Multi-Tenant Documentation Platform with Next.js 15 (Subdomain Routing, MDX, and ISR)

I recently built Dokly, a documentation platform where users get instant subdomains (like acme.dokly.co) for their docs. Think Mintlify/GitBook but affordable.

Here's the technical deep-dive on how multi-tenant subdomain routing works in Next.js 15, including the gotchas that took me days to figure out.

The Architecture Challenge

The goal was simple: one Next.js app serving multiple purposes:

dokly.co           → Marketing site
app.dokly.co       → Dashboard (auth, editor)
*.dokly.co         → User documentation sites
docs.acme.com      → Custom domains (Pro feature)
Enter fullscreen mode Exit fullscreen mode

All from a single deployment. No separate apps. No complex infrastructure.

The Secret: Middleware Routing

Next.js middleware intercepts every request before it hits your pages. We use it to rewrite URLs based on the hostname.

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const hostname = request.headers.get("host") || "";
  const pathname = request.nextUrl.pathname;

  const baseDomain = "dokly.co";

  // Skip static files and API routes
  if (
    pathname.startsWith("/_next") ||
    pathname.startsWith("/api") ||
    pathname.includes(".")
  ) {
    return NextResponse.next();
  }

  // Marketing site: dokly.co
  if (hostname === baseDomain || hostname === `www.${baseDomain}`) {
    return NextResponse.next(); // Uses app/(marketing)/
  }

  // Dashboard: app.dokly.co
  if (hostname === `app.${baseDomain}`) {
    return NextResponse.rewrite(
      new URL(`/dashboard${pathname}`, request.url)
    );
  }

  // User docs: *.dokly.co
  if (hostname.endsWith(`.${baseDomain}`)) {
    const subdomain = hostname.replace(`.${baseDomain}`, "");
    return NextResponse.rewrite(
      new URL(`/sites/${subdomain}${pathname}`, request.url)
    );
  }

  // Custom domain - lookup in database
  const response = NextResponse.rewrite(
    new URL(`/sites/_custom${pathname}`, request.url)
  );
  response.headers.set("x-custom-domain", hostname);
  return response;
}
Enter fullscreen mode Exit fullscreen mode

File Structure That Makes It Work

The App Router's route groups are perfect for this:

app/
├── (marketing)/          # dokly.co (route group, no URL prefix)
│   ├── page.tsx          # Landing page
│   ├── pricing/page.tsx  # Pricing
│   └── layout.tsx        # Marketing layout
│
├── (dashboard)/          # app.dokly.co
│   ├── dashboard/page.tsx
│   ├── project/[id]/
│   │   └── editor/[pageId]/page.tsx
│   └── layout.tsx        # Auth-protected layout
│
└── sites/[subdomain]/    # *.dokly.co (user docs)
    └── [[...slug]]/page.tsx  # Catch-all for all doc pages
Enter fullscreen mode Exit fullscreen mode

The parentheses in (marketing) and (dashboard) create route groups - they organize code without affecting the URL structure.

The Catch-All Route for Docs

Every user's docs site uses the same dynamic route:

// app/sites/[subdomain]/[[...slug]]/page.tsx
import { notFound } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { renderMDX } from "@/lib/mdx/processor";

interface Props {
  params: Promise<{ subdomain: string; slug?: string[] }>;
}

export default async function DocsPage({ params }: Props) {
  const { subdomain, slug } = await params;
  const pageSlug = slug?.join("/") || "index";

  const supabase = await createClient();

  // Get project by subdomain
  const { data: project } = await supabase
    .from("projects")
    .select("*")
    .eq("subdomain", subdomain)
    .eq("is_public", true)
    .single();

  if (!project) notFound();

  // Get page content
  const { data: page } = await supabase
    .from("pages")
    .select("*")
    .eq("project_id", project.id)
    .eq("slug", pageSlug)
    .eq("is_published", true)
    .single();

  if (!page) notFound();

  const { content } = await renderMDX(page.content);

  return (
    <article className="prose dark:prose-invert max-w-none">
      <h1>{page.title}</h1>
      {content}
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

ISR: The Performance Secret

Documentation doesn't change every second. We use Incremental Static Regeneration (ISR) to cache pages at the edge:

// app/sites/[subdomain]/[[...slug]]/page.tsx
export const revalidate = 60; // Revalidate every 60 seconds

// Also configure Supabase client for ISR
const supabase = createClient({
  global: {
    fetch: (url, options) =>
      fetch(url, { ...options, next: { revalidate: 60 } })
  }
});
Enter fullscreen mode Exit fullscreen mode

Result? First request hits the database. Next 60 seconds of requests are served from Vercel's edge cache. Sub-100ms load times globally.

MDX Processing with next-mdx-remote

Users write MDX in a browser editor. We compile it server-side:

// lib/mdx/processor.ts
import { compileMDX } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import rehypePrettyCode from "rehype-pretty-code";
import { mdxComponents } from "@/components/mdx";

export async function renderMDX(source: string) {
  const { content, frontmatter } = await compileMDX({
    source,
    components: mdxComponents,
    options: {
      parseFrontmatter: true,
      mdxOptions: {
        remarkPlugins: [remarkGfm],
        rehypePlugins: [
          rehypeSlug,
          [rehypePrettyCode, { theme: "github-dark" }],
        ],
      },
    },
  });

  return { content, frontmatter };
}
Enter fullscreen mode Exit fullscreen mode

Custom components (Callouts, Tabs, Code blocks) are passed via mdxComponents, letting users write rich documentation.

Custom Domains (The Tricky Part)

Custom domains require:

  1. User adds domain in dashboard
  2. They configure DNS (CNAME to cname.vercel-dns.com)
  3. Domain is added to Vercel (manually for now, API for scale)
  4. Middleware detects non-dokly.co domains and looks up the project
// For custom domains, look up by domain instead of subdomain
if (hostname !== baseDomain && !hostname.endsWith(`.${baseDomain}`)) {
  const { data: project } = await supabase
    .from("projects")
    .select("subdomain")
    .eq("custom_domain", hostname)
    .single();

  if (project) {
    return NextResponse.rewrite(
      new URL(`/sites/${project.subdomain}${pathname}`, request.url)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Gotchas I Hit

1. Vercel Pro Required for Wildcards

Wildcard domains (*.dokly.co) require Vercel Pro ($20/month). No workaround.

2. Middleware Runs on Every Request

Keep middleware fast. Don't make database calls unless absolutely necessary. Use header rewrites and let the page handle the DB lookup.

3. ISR Cache Keys Include Hostname

This is actually good - acme.dokly.co/getting-started and beta.dokly.co/getting-started are cached separately.

4. next-mdx-remote RSC vs Client

Use the RSC version (next-mdx-remote/rsc) for server components. The old client version adds unnecessary JS bundle size.

The Stack

  • Framework: Next.js 15 (App Router)
  • Database: Supabase (PostgreSQL + Auth + Storage)
  • Styling: Tailwind CSS + shadcn/ui
  • MDX: next-mdx-remote + Shiki syntax highlighting
  • Hosting: Vercel (Pro for wildcards)
  • Payments: Stripe

What's Next

Building:

  • Auto-generated llms.txt for AI agent discoverability
  • BYOK AI writing assistant
  • GitHub sync for docs-as-code workflows

Try It

If you're building developer tools or need documentation, give Dokly a try. The free tier includes subdomain hosting, MDX editor, and search.

Questions about the architecture? Drop them in the comments - happy to dive deeper into any part.


Building in public at @gsharm_

Top comments (0)