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.
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.
| Helper | Returns |
|---|---|
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.
| Option | Default |
|---|---|
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.
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.
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
| Topic | Page |
|---|---|
| The role rule these helpers check | Authentication |
| Rules on the data itself, not the route | Access control |
| Routes of your own, and their own access | Routes |
Identity tables
Better Auth's own tables arrive as ordinary collections. Four of them refuse generic CRUD outright, the user table is narrower than it looks, and the session endpoints never hand back a reusable token.
Auth writes that queue a job
withAuthTransactionalQueue commits one Better Auth mutation and one encrypted job dispatch together. The callback gets a transaction-scoped adapter and a publisher that takes only the jobs your app registered.