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)
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;
}
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
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>
);
}
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 } })
}
});
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 };
}
Custom components (Callouts, Tabs, Code blocks) are passed via mdxComponents, letting users write rich documentation.
Custom Domains (The Tricky Part)
Custom domains require:
- User adds domain in dashboard
- They configure DNS (CNAME to
cname.vercel-dns.com) - Domain is added to Vercel (manually for now, API for scale)
- 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)
);
}
}
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.txtfor 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)