Building a Multi-Portal SaaS with Next.js 15+ and PostgreSQL
A few months ago I shipped the backend for a campus admissions platform. The product needed four distinct user experiences: a student portal for applicants, an admin portal for the operations team, a partner portal for affiliated universities, and a public marketing site for organic traffic.
Four portals. Different layouts. Different navigation. Different permission boundaries. Different SEO profiles.
The default instinct on a team this size is to reach for four separate Next.js apps. Don't. You'll spend the next year fighting drift between four codebases, four deploy pipelines, four auth systems, and one increasingly confused Postgres database.
We built it as a single Next.js 15 application with one Postgres schema, and it worked beautifully. This post is the playbook.
What "portal" actually means
Before the code, a definition. In this article a portal is two things:
- A scoped UX shell — its own layout, navigation, and design language.
- A permission boundary — only certain user roles can enter.
A portal is not a separate codebase, a separate deployment, or even a separate Next.js app. It's a route prefix with a layout and a guard.
Once you accept that framing, the App Router gives you everything you need.
Why one app beats four
I want to be honest before showing the structure: separate apps are sometimes the right call. We'll cover when in the closing section. But for the typical four-portal SaaS, a single app wins on five fronts:
- One auth flow. Sessions are issued and validated in one place. No cross-domain cookie weirdness.
- One database schema. Prisma migrations stay sane. Foreign keys work without RPC.
- Shared business logic. A
services/folder is consumed by all portals. No npm-publishing internal packages. - One deploy. Vercel doesn't get confused. Rollbacks are atomic. Feature flags are global.
- Real DRY. When the admin portal needs to view a student's data, it imports the same query the student portal uses. Zero duplication.
The cost is build time and a slightly larger bundle per route — both of which Next 15's per-route bundling handles gracefully.
Folder structure that scales
The pattern that holds everything together is route groups. Wrap a folder name in parentheses and Next.js treats it as a logical group without affecting the URL:
app/
├── (site)/ # Public marketing site → /, /about, /pricing
│ ├── layout.tsx
│ ├── page.tsx
│ ├── about/page.tsx
│ └── pricing/page.tsx
├── (student)/ # Student portal → /student/*
│ └── student/
│ ├── layout.tsx
│ ├── page.tsx # /student dashboard
│ ├── applications/page.tsx
│ └── profile/page.tsx
├── (admin)/ # Admin portal → /admin/*
│ └── admin/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── users/page.tsx
│ └── reports/page.tsx
├── (partner)/ # Partner portal → /partner/*
│ └── partner/
│ ├── layout.tsx
│ ├── page.tsx
│ └── students/page.tsx
├── api/ # Shared API routes
│ └── auth/
├── login/page.tsx # Single login, redirects by role
└── middleware.ts # The portal guard
Each route group has its own layout.tsx. That layout owns the navigation, the typography, the color palette — anything that defines the portal's identity. The student layout has a clean, friendly UI. The admin layout has a dense data-grid feel. The partner layout splits the difference.
Inside each layout you import shared UI primitives from components/ui/ (buttons, inputs, cards) — those don't change between portals. Only the composition changes.
Auth and the portal guard
A single login page handles everyone. After authentication, we redirect based on role:
// app/login/actions.ts
"use server";
import { redirect } from "next/navigation";
import { signIn } from "@/lib/auth";
export async function login(formData: FormData) {
const { user, error } = await signIn({
email: formData.get("email") as string,
password: formData.get("password") as string,
});
if (error) return { error };
switch (user.role) {
case "STUDENT": redirect("/student");
case "ADMIN": redirect("/admin");
case "PARTNER": redirect("/partner");
default: redirect("/");
}
}The hard part isn't redirecting after login — it's making sure a logged-in student can't poke around /admin/users by typing the URL. That's where middleware earns its keep:
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { getSessionFromCookie } from "@/lib/auth";
const PORTAL_ROLES: Record<string, string[]> = {
"/student": ["STUDENT"],
"/admin": ["ADMIN"],
"/partner": ["PARTNER", "ADMIN"], // admins can impersonate
};
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const guardedPrefix = Object.keys(PORTAL_ROLES).find((prefix) =>
pathname.startsWith(prefix)
);
if (!guardedPrefix) return NextResponse.next();
const session = await getSessionFromCookie(req);
if (!session) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
const allowedRoles = PORTAL_ROLES[guardedPrefix];
if (!allowedRoles.includes(session.role)) {
return NextResponse.rewrite(new URL("/403", req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/student/:path*", "/admin/:path*", "/partner/:path*"],
};A few things worth calling out:
PORTAL_ROLESis a single source of truth. Adding a new portal is two edits: a route group folder, and a row in this map.NextResponse.rewrite(not redirect) for 403. Users see the URL they tried, but the response body is the 403 page. That's the right UX for an authorization failure.nextquery param preserves intent. A student tapping a deep link in an email gets logged in and dropped exactly where they were heading.
Don't put fine-grained authorization (like "can this admin edit this specific user?") in middleware. Middleware runs on the edge before your DB is reachable. Put coarse-grained portal access here, and put record-level checks in your service layer.
The shared core
This is where most multi-portal codebases go wrong. People split the entire codebase by portal:
lib/student/queries.ts
lib/admin/queries.ts
lib/partner/queries.ts
Don't do this. Three months in, the admin portal needs to fetch a student's applications, and now you have either circular imports or a copy-pasted query that drifts out of sync.
Split by domain, not by portal:
lib/services/
├── application-service.ts # CRUD + business rules for applications
├── user-service.ts # User lookup, profile updates
├── partner-service.ts # Partner-specific operations
└── audit-service.ts # Cross-cutting audit log
lib/db.ts # Single Prisma client
prisma/schema.prisma # Single schema
Every portal's pages import from lib/services/. The service layer is where authorization checks live ("can this user update this application?"). The portal layer just orchestrates UI.
// lib/services/application-service.ts
import { db } from "@/lib/db";
import type { Session } from "@/lib/auth";
export async function getApplicationsForStudent(
session: Session,
studentId: string
) {
// Authorization: students can only see their own; admins see anyone's
if (session.role === "STUDENT" && session.userId !== studentId) {
throw new Error("Forbidden");
}
return db.application.findMany({
where: { studentId },
orderBy: { createdAt: "desc" },
});
}A student page calls getApplicationsForStudent(session, session.userId). An admin page calls getApplicationsForStudent(session, params.id). Same function, same query, same authorization rules — no drift possible.
SEO is per-portal
The public site needs structured data, sitemaps, and OG images. The student and admin portals don't — in fact, you want them firmly out of Google.
In Next 15 each route segment can export its own metadata. The pattern I use:
// app/(student)/student/layout.tsx
export const metadata = {
robots: { index: false, follow: false },
};
// app/(site)/layout.tsx
export const metadata = {
// ... full SEO metadata, OG tags, JSON-LD via <script> tag
};Pair this with a sitemap that only lists public routes:
// app/sitemap.ts
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: "https://example.com", priority: 1 },
{ url: "https://example.com/about", priority: 0.8 },
{ url: "https://example.com/pricing", priority: 0.8 },
// never list /student, /admin, /partner
];
}Two layers of defense: meta robots for crawlers that probe URLs, and sitemap silence so Google never discovers them in the first place.
When this pattern breaks
I want to be honest about the failure modes, because that's how you know I've actually shipped this.
Don't use this pattern when:
- You have 10+ portals or true multi-tenant SaaS. At that scale, route groups stop scaling and you want subdomains plus middleware-driven tenant resolution. That's a different post.
- Different portals need wildly different release cadences. If the admin team ships every two days but the public site is locked behind quarterly marketing approvals, the deploy coupling will frustrate everyone.
- Different portals have hard regulatory boundaries. Healthcare, finance, or government workflows sometimes legally require physical separation of code or data. One app won't satisfy your auditor.
For everything else — most B2B SaaS, internal tools, education platforms, marketplaces — one app with route groups is the path that ships fastest and stays maintainable longest.
What I'd do differently next time
Two things, in retrospect:
-
Set up the middleware guard on day one, not day thirty. It's tempting to defer auth wiring while you build pages. Don't. Wire the guard against a stub
getSession()that returns a hardcoded role, then swap in real auth later. Pages built without thinking about auth boundaries always need rework. -
Co-locate portal-specific UI inside the portal folder, not in a global components dir. When admin needs a different
<Sidebar>than student, put it inapp/(admin)/admin/_components/Sidebar.tsxrather thancomponents/admin/Sidebar.tsx. Keeps the portal self-contained and makes the file tree match how you actually navigate the codebase.
Closing
Multi-portal architecture is an exercise in resisting the wrong instinct. The first instinct is always to split. The right move is usually to consolidate, define your boundaries clearly, and let the App Router enforce them.
If you're standing at the start of a project like this and want a second pair of eyes — or if you've already split into four apps and want to know whether to merge them — I take on freelance work. Drop me a message.
Shahid Monowar is a freelance full stack developer building production web apps with Next.js, Node.js, and PostgreSQL. Based in Dhaka, available worldwide.
