The Visual Website Builder
You Actually Own. React + Next.js, low-code, self-hosted. Your data, your infrastructure, every feature included.
© ChaiBuilder. All rights reserved.

By default the CMS admin panel and the visual builder both live under /admin. A single
environment variable moves them to any path you choose, and every internal link, redirect,
and generated URL follows. This page covers how to change it, how the change is wired
through the app, and how to reference the admin path from your own code.
# Custom admin path, no leading slash
PAYLOAD_ADMIN_ROUTE=cms-a8f3k2m9
PAYLOAD_ADMIN_ROUTE in your hosting platform's environment settings, or in .env
for local development.pnpm dev. In production, trigger
a fresh deploy.https://www.example.com/cms-a8f3k2m9.You do not set NEXT_PUBLIC_PAYLOAD_ADMIN_ROUTE yourself. The build copies the resolved
value into the client bundle so browser-side links resolve correctly. It exists only as a
fallback for setups that cannot expose the server-side variable, and
PAYLOAD_ADMIN_ROUTE wins whenever both are set.
The value is normalized and validated at build time by parseAdminRoute:
cms-a8f3k2m9, not /cms-a8f3k2m9/. Stray slashes
are stripped and repeated slashes collapsed rather than rejected.PAYLOAD_ADMIN_ROUTE must be at least 4 characters.: ? * + ( ) { } [ ] | \. They collide with Next.js
route matching, and the build fails with
PAYLOAD_ADMIN_ROUTE contains unsupported characters. Letters, digits, hyphens, and
underscores are the safe set.internal/cms puts the panel at /internal/cms.admin and an empty value both resolve to the default. Setting
PAYLOAD_ADMIN_ROUTE=admin is the same as leaving it unset.Because validation happens at build time, a bad value fails the deploy rather than shipping a broken panel.
Everything under the admin base path follows the resolved value:
| Default | With PAYLOAD_ADMIN_ROUTE=cms-a8f3k2m9 |
|---|---|
/admin - CMS dashboard |
/cms-a8f3k2m9 |
/admin/login |
/cms-a8f3k2m9/login |
/admin/editor - visual builder |
/cms-a8f3k2m9/editor |
/admin/api/revalidate |
/cms-a8f3k2m9/api/revalidate |
Password reset emails and the robots.txt disallow list are generated from the same value,
so both stay correct without extra work. Anything calling the revalidation route from
outside the app needs its URL updated by hand, since that caller lives in your
infrastructure rather than in the app. See
Caching & Revalidation.
Four touch points read the resolved route. You do not need to edit any of them to change the path, but knowing where they are matters when you customize routing.
| File | Role |
|---|---|
next.config.ts |
Publishes the value to the client bundle and adds the rewrites |
payload.config.ts |
Sets Payload's own routes.admin |
src/proxy.ts |
Redirects legacy /admin requests to the custom path |
src/app/robots.ts |
Keeps the panel out of robots.txt |
next.config.tsThe config exposes the value to the browser and rewrites the public path onto the real route folders. The rewrites are added only when a custom route is set, so the default deployment carries no extra routing work:
import { getAdminRouteSegment, isCustomAdminRoute } from './src/utilities/adminRoute'
const adminRouteSegment = getAdminRouteSegment()
const nextConfig: NextConfig = {
env: {
NEXT_PUBLIC_PAYLOAD_ADMIN_ROUTE: adminRouteSegment,
},
async rewrites() {
if (!isCustomAdminRoute()) {
return []
}
return [
{ source: `/${adminRouteSegment}`, destination: '/admin' },
{ source: `/${adminRouteSegment}/:path*`, destination: '/admin/:path*' },
]
},
}
This is why the change is build-time: rewrites are resolved when the config loads.
payload.config.tsPayload builds its own internal links from routes.admin, so it gets the same value:
import { getAdminRoute } from '@/utilities/adminRoute'
export default buildConfig({
routes: {
admin: getAdminRoute(),
},
// ...
})
src/proxy.tsThe proxy redirects requests that still hit /admin to the custom path, so bookmarks and
older links keep working:
if (isCustomAdminRoute() && (pathname === '/admin' || pathname.startsWith('/admin/'))) {
const url = request.nextUrl.clone()
url.pathname = `${getAdminRoute()}${pathname.slice('/admin'.length)}`
return NextResponse.redirect(url)
}
Note the security consequence: /admin redirects rather than returning a 404, so the
custom path is discoverable by anyone who tries it. A custom route is defense-in-depth
against bots and scanners, not access control. Authentication and roles are what protect
the panel. See Roles & Permissions.
If you want /admin to be a dead end instead, replace that redirect with a
NextResponse.rewrite to your 404 page and tell your team the new URL directly.
Never hardcode /admin in custom blocks, panels, or server code. Build paths with the
helpers so they follow the configured route.
src/utilities/adminRoute.ts is host-app code, deliberately free of framework and package
imports so next.config.ts can load it through plain Node:
import { adminUrl, getAdminRoute, isCustomAdminRoute } from '@/utilities/adminRoute'
adminUrl() // '/cms-a8f3k2m9'
adminUrl('editor') // '/cms-a8f3k2m9/editor'
adminUrl('api', 'revalidate') // '/cms-a8f3k2m9/api/revalidate'
getAdminRoute() // '/cms-a8f3k2m9'
isCustomAdminRoute() // true
Segments are joined with slashes and stray slashes on each segment are stripped, so
adminUrl('/editor/') and adminUrl('editor') produce the same result.
These work on both the server and the client. On the client the value comes from
NEXT_PUBLIC_PAYLOAD_ADMIN_ROUTE, which Next.js inlines at build time.
chaipro/payload exports the same logic for code that should not depend on starter
internals. The URL builder is named defaultAdminUrl here rather than adminUrl:
import {
DEFAULT_ADMIN_ROUTE,
defaultAdminUrl,
getAdminRoute,
parseAdminRoute,
} from 'chaipro/payload'
Both implementations read the same two environment variables and stay in sync.
If your app builds admin URLs differently, for example because a reverse proxy mounts the
panel under a prefix the app never sees, pass your own adminUrl builder when configuring
the Payload integration. It receives path segments and returns the final path:
adminUrl: (...segments) => ['/back-office', ...segments].join('/'),
Everything that constructs an admin link, including the CMS edit and create URLs the builder
opens for collection items, routes through this function. Leave it unset and it falls back
to defaultAdminUrl.
The custom path is a rewrite, not a move. On disk the route folders stay where they are:
src/app/(payload)/admin/[[...segments]] # CMS panel
src/app/(builder)/admin/editor # visual builder
src/app/(builder)/admin/api # builder API routes
Renaming these directories to match your custom path breaks the rewrite targets and Payload's own route resolution. Change the environment variable and leave the folders alone.
/admin/api/revalidate.
Update the caller./admin/login. Some custom code is hardcoding the
path. Replace it with adminUrl('login').