Customize the Developer Portal
Customize the Developer Portal branding, theme, authentication providers, and functionality using the API7 Developer Portal Boilerplate.
The Developer Portal can be built from the API7 Developer Portal Boilerplate, a customizable reference implementation. This guide covers the most common customizations.
This page refers to the Boilerplate because it is the public reference implementation for custom portal development. Deployment pages may instead refer to the official frontend image used in product deployments; that image is not customizable in the ways described here.
Check your Boilerplate version first
3.10.6 rebuilt the portal frontend on TanStack Start with a Vite build, replacing the Next.js application. Its browser assets moved with it, from Next.js paths under /_next/static/ to Vite's /assets/. This page describes the current structure. If you are working from a Boilerplate checkout older than portal frontend v0.15.x, the file layout differs — compare against the config.yaml.example in your own checkout before following the paths here.
How the Boilerplate Is Organized
Everything you are likely to change lives under apps/site:
| Path | What it holds |
|---|---|
apps/site/config.yaml | Runtime configuration. Copy it from config.yaml.example. |
apps/site/public/ | Static assets served as-is, including favicon.ico. |
apps/site/src/globals.css | Theme overrides and application-level CSS. |
apps/site/src/routes/ | File-based routes (TanStack Router), including API routes. |
apps/site/src/components/ | UI components. |
apps/site/src/lib/auth/ | Better Auth server and client configuration. |
apps/site/src/lib/portal-sdk/ | The Portal API client. |
packages/ui/ | The shared @api7/portal-ui component library and its design tokens. |
Routes are file-based: a file at src/routes/support.tsx becomes /support, with no registration step. Run pnpm generate-routes after adding a route file if your editor does not pick it up automatically.
Configuration
All runtime configuration is in apps/site/config.yaml. Values support environment substitution — ${VAR} requires the variable to be set and fails startup if it is missing, while ${VAR:default} falls back to the default. Note the single braces and the colon; this is the portal's own syntax and is unrelated to the gateway's ${{VAR:=default}} form.
Four settings are required and have no defaults:
portal:
url: ${PORTAL_URL} # Portal API address
token: ${PORTAL_TOKEN} # Portal API token, a7prt-...
db:
url: ${DATABASE_URL} # PostgreSQL connection string
auth:
secret: ${AUTH_SECRET} # At least 32 characters: openssl rand -base64 32Configuration changes take effect on restart. Changes to assets, CSS or code require a rebuild.
Branding
Name and Description
app:
name: "Acme API Portal"
desc: "Discover and integrate with Acme APIs"
baseURL: "https://portal.acme.com"
trustedOrigins:
- "https://portal.acme.com"name appears in the browser title and in the portal header. desc is the SEO meta description. baseURL is the public address used to build links and authentication callbacks, and trustedOrigins must list every browser origin that reaches the app — a sign-in that redirects correctly but then rejects the session is almost always a missing origin here.
Favicon and Header Logo
Replace apps/site/public/favicon.ico. The header renders the same file as the logo beside the portal name, so a single asset covers both. There is no separate logo file to replace.
Theme
The portal uses Tailwind CSS v4. The design tokens are defined in @api7/portal-ui/styles.css, which apps/site/src/globals.css imports. Override them by redeclaring the tokens after the imports:
@import "tailwindcss";
@import "@api7/portal-ui/styles.css";
/* ...other imports... */
:root {
--primary: oklch(0.55 0.21 264);
--primary-foreground: oklch(0.985 0 0);
}
.dark {
--primary: oklch(0.72 0.14 264);
--primary-foreground: oklch(0.205 0 0);
}Override both blocks. Dark mode is a .dark class on the document, not a media query, so a token you redefine only under :root keeps its stock value the moment a visitor switches theme. The full token list — --background, --foreground, --card, --muted, --radius and the rest — is in packages/ui/src/styles.css.
Authentication
Most authentication changes are configuration, not code.
Email and Password
auth:
emailAndPassword:
enabled: true
requireEmailVerification: false
revokeSessionsOnPasswordReset: trueSetting requireEmailVerification: true blocks sign-ups until you add a sendVerificationEmail handler in apps/site/src/lib/auth/server.ts. The Boilerplate ships none — the SMTP wiring under TESTING=true serves magic-link sign-in only and is not used for sign-up verification, so enabling this flag on a stock checkout leaves new users unable to complete registration.
Social Login Providers
auth:
socialProviders:
github:
clientId: ${GITHUB_CLIENT_ID}
clientSecret: ${GITHUB_CLIENT_SECRET}
google:
clientId: ${GOOGLE_CLIENT_ID}
clientSecret: ${GOOGLE_CLIENT_SECRET}Register an OAuth application with each provider and set the callback URL to <baseURL>/api/auth/callback/<provider>.
Generic OIDC Providers
Any OIDC provider that publishes a discovery document is configured in config.yaml, with no code changes:
auth:
genericOAuthProviders:
- providerId: keycloak
discoveryUrl: https://sso.acme.com/realms/acme/.well-known/openid-configuration
clientId: ${KEYCLOAK_CLIENT_ID}
clientSecret: ${KEYCLOAK_CLIENT_SECRET}
scopes: ["openid", "profile", "email"]
pkce: true
requireIssuerValidation: true
ssoOnly: falseSet ssoOnly: true to hide the provider from the normal sign-in buttons and only reach it through domain-based routing.
Route Corporate Domains to SSO
Send users whose email matches your corporate domains straight to a provider, while everyone else signs in normally:
auth:
sso:
providers:
- domains: ["@acme\\.com$", "@acme-corp\\.com$"]
providerId: keycloakdomains entries are regular expressions matched against the whole email address, so anchor them with $. An unanchored @acme\.com also matches user@acme.com.attacker.net, which would route that address to your IdP — it cannot authenticate there, so the effect is that the address is locked out rather than let in, but it is still not what you configured.
Each expression is rejected at startup if it is longer than 200 characters or if it fails a safe-regex2 check. That check is a star-height heuristic, not backtracking analysis, and it passes patterns that are still catastrophic — (a|a)+@acme\.com$ is accepted and takes seconds to evaluate on a short input. The pattern runs against an address supplied by an unauthenticated caller, so keep these expressions simple and avoid nested quantifiers.
providerId must match an entry in auth.genericOAuthProviders.
Two-Factor Authentication
auth:
twoFactor:
enabled: true
required: falseTOTP only. Setting required: true without enabled: true fails validation at startup rather than silently doing nothing.
Extending Functionality
| Extension point | Location |
|---|---|
| Pages | apps/site/src/routes/ — a route file exports a Route created with createFileRoute. |
| API endpoints | apps/site/src/routes/api/ — the same route files, exporting server.handlers per HTTP method. |
| Components | apps/site/src/components/ |
| Shared UI library | packages/ui/src/components/ |
| Authentication | apps/site/src/lib/auth/server.ts for the server, client.ts for the browser. |
An API route looks like this:
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/health')({
server: {
handlers: {
GET: () => new Response('ok'),
},
},
});Adding SCIM Support
The Boilerplate does not ship SCIM. To add it:
-
Install the plugin, matching the version to your
better-authversion:pnpm --filter @api7ee/site add @better-auth/scim -
Register it in
apps/site/src/lib/auth/server.ts:import { scim } from '@better-auth/scim'; export const auth = betterAuth({ // ... plugins: [ // ...existing plugins scim(), ], }); -
Generate and apply the database schema:
pnpm db:generate-schema && pnpm db:generate && pnpm db:migrate
The catch-all auth route at apps/site/src/routes/api/auth/$.ts already declares GET, POST, PUT, PATCH and DELETE, so SCIM's methods are served without touching it. This is a change from the Next.js Boilerplate, where the handler had to be widened by hand.
For the full Okta walkthrough, including token generation and Okta provisioning setup, see Configure SCIM Provisioning for a Custom Developer Portal with Okta.
Portal SDK
The Portal SDK (@api7/portal-sdk) is pre-integrated in the Boilerplate and provides a TypeScript client for the Portal API. You can also use it independently.
The SDK README covers installation and usage, not the underlying endpoints. For the full request and response contract, see the Developer Portal API reference.
The client is constructed once, on the server, in apps/site/src/lib/portal-sdk/server.ts:
import '@tanstack/react-start/server-only';
import { API7Portal } from '@api7/portal-sdk';
import axios from 'axios';
import { getConfig } from '@/lib/config';
const portalConfig = getConfig().portal;
export const portal = new API7Portal({
endpoint: portalConfig.url,
token: portalConfig.token,
axios: axios.create({ timeout: 30_000 }),
});Pass an explicit axios instance with a timeout. Without one, a stalled Portal API request hangs for as long as the connection stays open, and the portal page waiting on it hangs with it.
To scope calls to a single organization, supply getDeveloperId — a developer ID is an organization ID:
const scoped = new API7Portal({
endpoint: portalConfig.url,
token: portalConfig.token,
axios: axios.create({ timeout: 30_000 }),
getDeveloperId: async () => organizationId,
});The SDK is server-side only. @api7/portal-sdk v2 ships a single entry point and no browser build, and the Portal API token it carries authenticates as the portal itself: the caller selects the organization with a header, and the same token reaches portal-admin operations such as inviting and deleting developers and approving registrations. Reaching the browser, it is a full credential leak for that portal.
Keep the import '@tanstack/react-start/server-only' line at the top of any module that touches it — that is what makes a client import a build error rather than a shipped secret. Call the SDK from a route handler or a TanStack Start server function and return only the data that request is entitled to.
Building and Deploying
pnpm install
pnpm buildThe build is Vite-based and writes a Nitro server bundle to apps/site/.output. Run it with:
node apps/site/.output/server/index.mjsTo build a container image instead, use the Dockerfile in the Boilerplate repository:
docker build -t my-developer-portal .Serve the built assets from the same origin as the application. Since the 3.10.6 rewrite they are emitted under /assets/ rather than the Next.js /_next/static/, so a reverse proxy or CDN rule that still matches /_next/ silently stops serving the application's JavaScript — the page renders its HTML shell and nothing else.
Additional Resources
- API7 Developer Portal Boilerplate
- Developer Portal API reference — REST contract called by every portal frontend.
- Portal SDK (TypeScript)
- Better Auth Documentation
- TanStack Start Documentation
- Deploy the Developer Portal
- Configure the Developer Portal