QUESTPIE
AdminAuth

Protecting your own routes

Helpers that turn a Request into an admin check, a beforeLoad guard for TanStack Start, a middleware for Next, and the one case where a guard lets the request through.

View markdown

The panel guards /admin on its own. A page of your own that reads the same data has no guard at all. These helpers run the check the panel runs, against a plain Request.

The helpers

All of them come from @questpie/admin/server.

HelperReturns
requireAdminAuth(opts)A 302 Response to the login path, or null.
getAdminSession(opts)The Better Auth session, or null.
isAdminUser(opts)true when the session user holds the required role.
getNextAdminSession(opts)The same session, from a Next Headers object.

The first three take { request, app }. getNextAdminSession takes { headers, app } and builds the request itself.

requireAdminAuth takes three more options, and isAdminUser takes the first of them.

OptionDefault
requiredRole"admin"
loginPath"/admin/login"
redirectParam"redirect"
import { requireAdminAuth } from "@questpie/admin/server";
import { app } from "#questpie";

export async function handler(request: Request) {
	const redirect = await requireAdminAuth({ request, app });
	if (redirect) return redirect;

	// past this line the session holds role "admin"
}

A null from requireAdminAuth means allowed. That reads backwards the first time, so hold the return value and test it before you carry on.

TanStack Start

createTanStackAuthGuard wraps requireAdminAuth and throws the redirect. That is what TanStack Router expects from beforeLoad.

src/routes/reports.tsx
import { createFileRoute } from "@tanstack/react-router";
import { createTanStackAuthGuard } from "@questpie/admin/server";
import { app } from "#questpie";

export const Route = createFileRoute("/reports")({
	beforeLoad: createTanStackAuthGuard({ app }),
	component: Reports,
});

It reads the request off context.request. With no request there it logs a warning and returns, letting the route render. So this guard needs SSR on.

Keep it off /admin itself. This guard has no public-path list, and a beforeLoad on the parent route runs for every child. It would guard the login and setup pages too.

createTanStackSessionLoader({ app }) is the other half. Use it as loader and the route data carries { session }. With no request, or no auth configured, it carries { session: null }.

Next

createNextAuthMiddleware({ app }) takes the same three options plus two path lists. protectedPaths defaults to ["/admin"]. publicPaths defaults to the login, forgot-password, reset-password and accept-invite paths under it. Both lists match on prefix.

middleware.ts
import { createNextAuthMiddleware } from "@questpie/admin/server";
import { app } from "#questpie";

export default createNextAuthMiddleware({
	app,
	publicPaths: [
		"/admin/login",
		"/admin/forgot-password",
		"/admin/reset-password",
		"/admin/setup",
	],
});

export const config = { matcher: ["/admin/:path*"] };

/admin/setup is missing from the default list. Pass your own list, as above. Otherwise the middleware bounces your first admin off the one page that would create them.

A request that may continue gets an empty 200 carrying the x-middleware-next: 1 header. A request that may not gets the redirect.

In a server component or a route handler, reach for getNextAdminSession instead. It builds the Request for you from headers().

No auth config means no guard

requireAdminAuth returns null when app.auth is missing. It logs a warning and lets the request through. An app with no auth configured is not a locked app. It is an open one.

The redirect parameter is written, never read

Every guard appends ?redirect=<path> to the login URL. The built-in login page ignores it. After a successful sign-in it navigates to the panel's basePath. That is /admin.

A 401 from a query does something else again. The client sends you to login?returnUrl=<path>, and nothing reads that either.

So people land on the dashboard, not on the page they asked for. If that matters to your project, read the parameter yourself and pass it to <LoginPage> as redirectTo.

Where each topic lives

TopicPage
The role rule these helpers checkAuthentication
Rules on the data itself, not the routeAccess control
Routes of your own, and their own accessRoutes

On this page