Serve a subdomain from a route
Serve a subdomain from a route in the Routing and data guide.
One app can answer for several hosts. A middleware that looks at the host and calls rewrite() serves a subdomain from a
route of the app: the browser keeps the URL it asked for, and the route answers. The recipe below serves
join.example.com/<code> from the route /join/<code>, sends every other path of the subdomain to the apex, and leaves
the apex alone.
The route is an ordinary page. It has no idea which host asked for it:
import { useParams } from "@shadow-js/router";
export default function Join() {
const params = useParams();
return <h1>Join {params["code"]}</h1>;
}
The middleware decides by event.url.hostname. It is a handler-wide middleware, so it runs for every page and API
request, before the routes:
import { type Middleware, redirect, rewrite } from "@shadow-js/dusk/server";
const APEX = "https://example.com";
const SUBDOMAIN = "join.example.com";
/** What an invite code looks like: one path segment, so `/a/b` and `/code.txt` are not codes. */
const CODE = /^[A-Za-z0-9_-]{4,32}$/;
export const hostRouting: Middleware = (event, next) => {
// Not the subdomain: the app answers as it always did.
if (event.url.hostname !== SUBDOMAIN) return next();
// The framework's own endpoints (server functions, navigation checks, the health check) keep their paths.
if (event.url.pathname.startsWith("/_dusk/")) return next();
const code = event.url.pathname.slice(1);
if (CODE.test(code)) return rewrite(`/join/${code}`);
// Everything else on the subdomain (the root, `/a/b`, `/join/abc`, `/xy`) belongs to the apex.
throw redirect(`${APEX}/`);
};
import type { HandlerOptions } from "@shadow-js/dusk/server";
import { hostRouting } from "./host-routing";
const options: Omit<HandlerOptions, "manifest"> = {
middleware: [hostRouting],
};
export default options;
The hosts go in dusk.config.ts. Both must be listed, or the handler answers 400 for the subdomain, and the redirect to the
apex leaves the origin of the request, so the apex must be a trusted origin:
import { defineConfig } from "@shadow-js/dusk/vite";
export default defineConfig({
security: {
allowedHosts: ["example.com", "join.example.com"],
trustedOrigins: ["https://example.com"],
},
});
These rules come from how Dusk runs a middleware:
rewrite()takes a path of this app. The browser keepsjoin.example.com/abc123, the route/join/[code]answers, and the app-wide middleware does not run again for the target, so the recipe cannot loop.- A path that is not a code is not a 404 on the subdomain: it is sent to the apex with a redirect (
302; pass{ status: 301 }toredirect()for a permanent one)./join/abc123on the subdomain is not a code either (it has a slash), so it goes to the apex too, and the route is reachable on the subdomain only through the short URL. - Files from
public/and the built assets are answered before a global middleware runs, on the subdomain as on the apex. That is why/favicon.icoand/assets/...need no exception here. If you setrunMiddlewareForStaticFiles, the middleware sees them first and must let them through. - The apex keeps serving
/join/<code>as a normal route. To keep it off the apex, add a branch for the apex host that redirects/join/<code>tohttps://join.example.com/<code>.
Try it on your machine#
dusk dev runs Vite's server, and Vite answers 403 to every Host header except localhost. Map the subdomain to your
machine and tell the dev server the name is fine:
127.0.0.1 join.example.com
The dev key of dusk.config.ts lists the names the dev server answers besides localhost. The hosts of
security.allowedHosts are allowed in development already, so for this recipe the file needs no dev line: it only adds
names such as a tunnel's (dev: { allowedHosts: ["my-tunnel.example.net"] }). Such a name only opens Vite's check: the app's own host check still
answers 400 for it, so list a tunnel in security.allowedHosts too.
Start dusk dev and open http://join.example.com:3000/abc123. A wildcard in security.allowedHosts
(*.example.com) is allowed in development too, in Vite's spelling (.example.com, which also lets example.com in).
dev.allowedHosts: true accepts every Host; use it only when the machine is not reachable by others, because a page that
answers to any name is open to DNS rebinding.
On Vercel#
Vercel sends the requests of every domain of a project to the same deployment, so the middleware needs no change. Add
join.example.com to the project's domains and keep both names in security.allowedHosts. The Vercel adapter writes the
list into config.json (see Platform host lists), and a name that is not listed
is answered with the handler's 400. The redirect to the apex is a normal 302 that the platform passes through. The
rewrite is internal to the function, so the address bar keeps join.example.com/abc123.
The host check applies to preview deployments too: a preview address such as my-app-git-feature.vercel.app is not in
the list, so it answers 400 unless you add it.