Client Portal
Build a customer-facing site on top of an erxes Client Portal using the standalone Next.js template.
See Authentication & Permissions for the credential mechanics behind these headers. For a production portal implementation, compare Help Center, which applies the same pattern.
A Client Portal is a configuration record (ClientPortals collection) in the clientportal module in Core API. It carries a portal token plus per-portal settings: authentication options (OTP, verification, token lifetimes, delivery method), styles, and plugin-specific config such as the CMS clientPortalId that scopes public content.
Plugins expose cp* GraphQL operations (cpPosts, cpGetTickets, cpForms, clientPortalCurrentUser, …) that look up the portal from the request context instead of from a logged-in team member. That makes the portal token the only credential a public website needs.
Two credentials, not one
| Credential | What it is | Transport | Becomes |
|---|---|---|---|
| Portal token | JWT { clientPortalId } signed with JWT_TOKEN_SECRET, issued by clientPortalAdd / clientPortalChangeToken | x-app-token header | req.clientPortal at the gateway, forwarded as the base64 clientportal header |
| Portal-user session | JWT { userId, clientPortalId } issued on login/verify/reset; separate refresh token (type: "refresh") | client-auth-token cookie (httpOnly) or header when the portal's deliveryMethod is header | req.cpUser, forwarded as the base64 cpuser header |
The gateway only identifies a portal user after the x-app-token has identified a valid portal, and the user must belong to that portal. Default lifetimes are 1 day (access) and 7 days (refresh), overridable per portal via auth.authConfig. clientPortalUserRefreshToken exchanges a refresh token for a new access token.
Portal-user auth flows (register, verify, credentials or OTP login, forgot/reset password, change email/phone) are implemented in backend/core-api/src/modules/clientportal/services/ and documented in docs/auth-flows.md inside that module. The mutations are the clientPortalUser* family plus clientPortalCurrentUser.
The template app
apps/client-portal-template is a standalone Next.js 16.0.3 (React 19) starter, not an Nx project, with its own package-lock.json. Apollo Client 4 plus @apollo/client-integration-nextjs provide both an RSC client and a browser client.
cd apps/client-portal-template
npm install
npm run dev # next dev --port 3800
Two Apollo entry points exist:
modules/apollo/apolloClient.ts: server components. Sendsx-app-token: NEXT_PUBLIC_ERXES_CP_TOKENto${ERXES_API_URL}/graphqlwithcredentials: 'include'(needed for theclient-auth-tokencookie round trip).modules/apollo/ApolloWrapper.tsx: client components ("use client"). Same token header against${NEXT_PUBLIC_ERXES_API_URL}/graphql.
modules/cpposts/ is a worked example: getExamplePosts.ts calls the getCPExamplePosts query exposed by the clientportal module, and CPPosts.tsx renders it.
Environment variables
| Variable | Used by | Purpose |
|---|---|---|
ERXES_API_URL | server Apollo client | Gateway base URL, no trailing slash (e.g. http://localhost:4000) |
NEXT_PUBLIC_ERXES_API_URL | browser Apollo client | Same gateway, reachable from the browser |
NEXT_PUBLIC_ERXES_CP_TOKEN | both clients | Portal token sent as x-app-token |
The template leaks the portal token to the browser
NEXT_PUBLIC_* variables end up in the client bundle, so the template sends x-app-token from the browser. The gateway CORS list must allow the portal's origin. For a hardened deployment move the token to a non-public variable read only by the RSC client (apolloClient.ts), or proxy all GraphQL through your own route handler. Never use an Apps token (erxes-app-token) in a public app.
There is no .env.example or Dockerfile in the template; copy apps/help-center's docker-entrypoint.sh pattern if you want one image to serve multiple gateways.
Walkthrough
Create the portal and copy its token
In erxes, create a Client Portal (Settings → Client portal, or via
clientPortalAdd). The creation call signs and stores the portal token; rotate it later withclientPortalChangeToken. If you use the CMS or Frontline plugins, link them to this portal in their own settings.Point the template at your gateway
ERXES_API_URL=http://localhost:4000 NEXT_PUBLIC_ERXES_API_URL=http://localhost:4000 NEXT_PUBLIC_ERXES_CP_TOKEN=<portal token>Restart
npm run dev;loadEnvConfiginapolloClient.tspicks up.env.local.Exercise a query and a session
getCPExamplePostsrenders on the home page. Then exercise a session: callclientPortalUserRegister+clientPortalUserVerify(orclientPortalUserLoginWithCredentials) and confirm theclient-auth-tokencookie is set, thenclientPortalCurrentUserreturns the visitor.
Check it works
- With only
x-app-tokenset, acp*query works butclientPortalCurrentUserreturns null; the portal is known, the visitor is anonymous. - After login, the same
client-auth-tokencookie works from the browser and from curl (-H "client-auth-token: <jwt>"). - A token from a different portal sees only its own data; the gateway binds
cpUsertoclientPortalId.