2 min read

One Next.js app, two hostnames

How the portfolio and the blog share a single container by rewriting on the Host header, and why a static export could not do it.

  • nextjs
  • middleware
  • self-hosting

The portfolio and this blog are the same deployment. vasharma05.com and blogs.vasharma05.com resolve to one Caddy instance, which forwards both to one Next.js container on port 3000. Nothing about the request distinguishes them except the Host header, and that turns out to be enough.

Two request paths entering one container and diverging into a site branch and a blog branch
One container, two hostnames, two route groups.

Why not two containers

The obvious alternative is a second app. I ran the numbers on the box first:

MB
One Next.js process150
A second Next.js process150
Headroom left on a 2 GB box~180

A second process costs about what the headroom is worth, and the two apps would share the theme provider, the fonts, the CSS custom properties, and the navigation shell anyway. Duplicating all of that to get a separate port is a bad trade.

The rewrite

Middleware runs before routing, so it can move a request to a different path without the browser ever seeing it:

export function middleware(request: NextRequest) {
  const host = normalizeHost(request.headers.get("host"));
  const prefix = host.startsWith("blogs.") ? "/_blog" : "/_site";

  const url = request.nextUrl.clone();
  url.pathname = prefix + request.nextUrl.pathname;
  return NextResponse.rewrite(url);
}

Two details cost me time. First, App Router treats a directory whose name begins with _ as private and excludes it from routing entirely — to get a real /_blog URL segment the directory has to be named %5Fblog. Second, the matcher has to exclude anything with a file extension, or /favicon.ico gets rewritten into a route group that has no handler for it.

What this rules out

  • output: "export". Middleware does not run in a static export, so hostname routing disappears with it.
  • Client-side host sniffing. It would work, but only after hydration, which means shipping the wrong page first and correcting it.

The default branch is the portfolio. Localhost, the Docker healthcheck, and any Host header I did not anticipate all land somewhere sensible instead of a 404.

Pages are still prerendered — output: "standalone" keeps a Node runtime for middleware and image optimisation, not for rendering. The server is handing back cached HTML on both hostnames.