One Next.js app, every school website: subdomains, custom domains and automatic HTTPS
The GungunERP Website Builder gives every school its own website, which it edits itself. The dashboard lives on gungunerp.org. Each school's site is served on school.gungunerp.org or on a domain the school already owns. All of it is one Next.js app, one Postgres database and one server.
That setup raises four questions: how a request finds the right school, how a domain we've never seen gets HTTPS, how one school's data stays out of another's, and how non-developers build pages. Here's how we answered each.
1. Route by the Host header
Every request carries the hostname it was sent to. Next.js middleware (renamed proxy.ts in Next 16) reads it before any page renders and rewrites the URL to an internal route:
// proxy.ts (simplified)
export function proxy(request: NextRequest) {
const host = (request.headers.get("host") ?? "").split(":")[0].toLowerCase();
const { pathname, search } = request.nextUrl;
// school.gungunerp.org -> /sites/school/...
const slug = tenantSlug(host); // null for the platform itself
if (slug) return rewriteTo(`/sites/${slug}${pathname}`, request);
// Anything that isn't the platform is a school's custom domain.
if (host !== PLATFORM_DOMAIN) {
if (host.startsWith("www.")) {
return NextResponse.redirect(`https://${host.slice(4)}${pathname}${search}`, 308);
}
return rewriteTo(`/sites/${host}${pathname}`, request);
}
return authenticatePlatformRequest(request); // dashboard, admin, APIs
}
The visitor's address bar never changes; the rewrite is internal. All school sites are rendered by one route tree, app/(public)/sites/[domain]/…, which looks the school up by slug or by custom domain and renders its pages. Because a slug never contains a dot and a domain always does, the two lookups can't collide.
Two details that matter in practice:
- Normalize domains once, on the way in. People paste
https://www.School.co.in/about. Store the bare, lowercase form (school.co.in), reject the platform's own domain and its subdomains, and accept multi-part suffixes like.co.inand.edu.inthat a naive "one dot" regex rejects. The same function is used everywhere a domain is compared. - Pick one canonical host.
www.gets a 308 to the bare domain, so search engines see one URL per page instead of two.
2. HTTPS for domains you don't know in advance
The platform's own subdomains are easy: they sit behind Cloudflare, which presents its own certificate to visitors.
Custom domains are harder. A school adds its domain in the dashboard, points an A record at our server, and expects https:// to work within minutes, without anyone from our team touching the server.
Caddy's on-demand TLS does exactly this. The first HTTPS request for an unknown hostname triggers a Let's Encrypt certificate, issued on the spot. The catch is that "any hostname" includes hostnames you don't serve. Anyone could point a thousand domains at your IP and burn through your Let's Encrypt rate limits. So Caddy asks first:
{
on_demand_tls {
ask http://127.0.0.1:3000/api/domains/check
}
}
https:// {
tls {
on_demand
}
reverse_proxy 127.0.0.1:3000
}
And the app answers only for domains an active school has saved:
// GET /api/domains/check?domain=<host>
export async function GET(request: Request) {
const domain = normalizeCustomDomain(new URL(request.url).searchParams.get("domain") ?? "");
if (!domain) return new Response(null, { status: 400 });
const org = await prisma.org.findUnique({ where: { customDomain: domain } });
return new Response(null, { status: org?.isActive ? 200 : 404 });
}
Because normalization strips www., the www variant is approved too, so the redirect to the bare domain is itself served over a valid certificate. Adding a school is now a database row, not a server change.
3. Keep every query inside one school
All schools share the same tables. Each tenant-owned row has an orgId, and unique constraints are per tenant (@@unique([orgId, slug])), so two schools can both have an /admissions page.
The risk is a route that forgets where: { orgId }. Rather than rely on every developer remembering, dashboard routes use a Prisma client extension that adds it for them:
const GLOBAL_MODELS = new Set(["User", "Org", "RefreshToken"]);
const FILTERED = new Set([
"findMany", "findFirst", "findFirstOrThrow", "findUnique", "findUniqueOrThrow",
"count", "aggregate", "groupBy", "update", "updateMany", "delete", "deleteMany",
]);
export const tenantDb = (orgId: string) =>
prisma.$extends({
query: {
$allOperations({ model, operation, args, query }) {
if (!model || GLOBAL_MODELS.has(model)) return query(args);
if (FILTERED.has(operation)) args.where = { ...args.where, orgId };
if (operation === "create") args.data = { ...args.data, orgId };
if (operation === "createMany") args.data = args.data.map((row) => ({ ...row, orgId }));
if (operation === "upsert") {
args.where = { ...args.where, orgId };
args.create = { ...args.create, orgId };
}
return query(args);
},
},
});
A route then does const db = tenantDb(user.orgId) and queries normally. Know where this kind of guard stops, though:
- List operations explicitly and review the list when you upgrade Prisma. The
…OrThrowvariants and newer operations likecreateManyAndReturnare separate operation names. If they aren't in the list, they go through unfiltered. - It only guards the top-level query. Nested writes and
included relations aren't rewritten. Raw SQL isn't either. - The
orgIdmust come from the verified session, never from the request body or a header the client can set.
It's a safety net, not a substitute for thinking about tenancy in each route. But it turns the most common mistake into a non-event.
4. Pages are data, blocks are code
Schools edit pages in Puck, a visual editor for React. Puck saves a page as JSON: an ordered list of blocks and their props. Each block is a normal React component with a field schema:
const config: Config = {
components: {
HeroBanner: {
fields: {
heading: { type: "text" },
image: { type: "custom", render: MediaUrlField },
},
render: (props) => <HeroBanner {...props} />,
},
// …admissions, courses with fee concessions, faculty, galleries, disclosure tables, blog feed…
},
};
The same config renders the editor and the live site, so what a school sees while editing is what visitors get. The library has grown to more than 70 blocks. When a new kind of page is needed, it ships as reusable blocks instead of a one-off template.
Each page row stores two JSON columns: draftContent for what the editor autosaves, and content for what's live. Publishing copies draft to live and records a revision, keeping the last 20 per page, so a school can roll back a bad publish. Revisions are taken on publish, not on every autosave, because publish is the checkpoint anyone would want to return to.
5. SEO per school, not per platform
Each school's site is its own website to Google, so it needs its own sitemap.xml, robots.txt, canonical URLs and per-page titles and descriptions. They all hang off the [domain] route and build absolute URLs from one helper:
const baseUrl = (org) =>
org.customDomain ? `https://${org.customDomain}` : `https://${org.slug}.${PLATFORM_DOMAIN}`;
One gotcha: a sitemap.ts under a dynamic segment has no params at build time, and next build tries to prerender it anyway and crashes. export const dynamic = "force-dynamic" makes Next generate it per request, for the right school.
What I'd tell someone building one
- Route on
Hostin middleware, and render all tenants from one route tree. Subdomains and custom domains are just two ways of finding the same tenant. - Use on-demand TLS with an
askendpoint. Without the ask, your server issues certificates for anyone. With it, adding a domain is a database write. - Normalize domains in exactly one function and use it for storage, the TLS check and routing.
- Scope queries by tenant in one place, and know what that one place doesn't cover.
- Store pages as data and ship blocks as code. Non-developers get freedom inside a design system, and developers keep control of the components.
The builder is live at gungunerp.org, and there's more about the project in the case study.