Custom UI Tutorial
Porta gives you full control over every user-facing page — login, consent, password reset, magic link, two-factor authentication, invitations, and all transactional emails. This guide covers everything from quick API-driven branding to building completely custom templates.
What Can Be Customized?
| Layer | What It Covers | Effort |
|---|---|---|
| API-driven branding | Logo, favicon, colors, company name, custom CSS per org | Zero code — API/CLI only |
| Custom CSS injection | Full style override via customCss field (up to 10KB) | CSS only |
| Template override | Replace any or all Handlebars templates via Docker volume mount | HTML/Handlebars |
| Email templates | Customize HTML and plain-text transactional emails | HTML/Handlebars |
Quick Start: API-Driven Branding
The fastest way to customize Porta's UI is through per-organization branding. No template files needed — just set values via the Admin API or CLI.
Available Branding Settings
| Setting | API Field | Default | Description |
|---|---|---|---|
| Logo URL | logoUrl | (none) | Image displayed in page headers and email headers |
| Favicon URL | faviconUrl | (none) | Browser tab icon |
| Primary Color | primaryColor | #3B82F6 | Buttons, links, accents (sets CSS --primary variable) |
| Company Name | companyName | Organization name | Page titles, headers, footers, email signatures |
| Custom CSS | customCss | (none) | Raw CSS injected into <head> (max 10KB) |
Setting Branding via CLI
# Set branding for an organization
porta org branding <org-id> \
--logo-url "https://cdn.example.com/logo.png" \
--favicon-url "https://cdn.example.com/favicon.ico" \
--primary-color "#E11D48" \
--company-name "Acme Corp"Setting Branding via Admin API
curl -X PUT http://localhost:3000/api/admin/organizations/<org-id>/branding \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"logoUrl": "https://cdn.example.com/logo.png",
"faviconUrl": "https://cdn.example.com/favicon.ico",
"primaryColor": "#E11D48",
"companyName": "Acme Corp",
"customCss": "body { font-family: \"Inter\", sans-serif; }"
}'Custom CSS Examples
The customCss field lets you override any style without touching templates:
/* Change the font */
body { font-family: "Inter", system-ui, sans-serif; }
/* Rounded buttons */
.btn { border-radius: 24px; }
/* Custom background */
body { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
/* Hide the footer */
.footer { display: none; }
/* Dark mode */
body { background: #1a1a2e; color: #eee; }
.card { background: #16213e; border-color: #0f3460; }
.btn { background: #e94560; }TIP
Custom CSS is injected after the default styles, so your rules take precedence. Use browser dev tools on the login page to inspect class names and structure.
Understanding the Template System
Porta uses Handlebars as its template engine. Templates are organized in a clear directory structure:
templates/default/
├── layouts/
│ └── main.hbs # Base HTML layout wrapping all pages
├── pages/
│ ├── login.hbs # Login page (password + magic link forms)
│ ├── consent.hbs # OAuth consent screen
│ ├── forgot-password.hbs # Password reset request form
│ ├── reset-password.hbs # New password form
│ ├── reset-success.hbs # Password reset confirmation
│ ├── magic-link-sent.hbs # "Check your email" page
│ ├── magic-link-success.hbs # Magic link verification success
│ ├── accept-invite.hbs # User invitation acceptance
│ ├── invite-success.hbs # Invitation accepted confirmation
│ ├── invite-expired.hbs # Expired invitation page
│ ├── two-factor-setup.hbs # 2FA setup (QR code for TOTP)
│ ├── two-factor-verify.hbs # 2FA code entry page
│ ├── logout.hbs # Logout confirmation
│ ├── logout-success.hbs # Post-logout success page
│ └── error.hbs # Error page
├── partials/
│ ├── header.hbs # Organization logo/name header
│ ├── footer.hbs # Copyright footer
│ └── flash-messages.hbs # Success/error notification banners
└── emails/
├── magic-link.hbs # Magic link email (HTML)
├── magic-link.txt.hbs # Magic link email (plain text)
├── password-reset.hbs # Password reset email (HTML)
├── password-reset.txt.hbs
├── invitation.hbs # Invitation email (HTML)
├── invitation.txt.hbs
├── otp-code.hbs # 2FA OTP code email (HTML)
├── otp-code.txt.hbs
├── password-changed.hbs # Password changed notification (HTML)
├── password-changed.txt.hbs
├── welcome.hbs # Welcome email (HTML)
└── welcome.txt.hbsHow Templates Are Rendered
- The layout (
layouts/main.hbs) provides the HTML shell —<head>,<body>, styles, branding CSS variable, and the{{{body}}}placeholder - The page (e.g.,
pages/login.hbs) provides the content rendered inside the layout - Partials (
{{> header}},{{> footer}},{{> flash-messages}}) are reusable snippets included by pages - Branding values from the organization are automatically injected as template variables
- Custom CSS from the branding is injected into the layout's
<head>section
Template Variables Reference
All templates have access to these variables:
Global Variables (All Pages)
| Variable | Type | Description |
|---|---|---|
branding.logoUrl | string | null | Organization logo URL |
branding.faviconUrl | string | null | Favicon URL |
branding.primaryColor | string | Hex color (default #3B82F6) |
branding.companyName | string | Organization display name |
branding.customCss | string | null | Raw CSS for injection |
pageTitle | string | Page title (e.g., "Sign In", "Reset Password") |
locale | string | Current locale (e.g., en) |
year | number | Current year (for copyright footers) |
t | function | Translation helper — {{t "key"}} |
Login Page (pages/login.hbs)
| Variable | Type | Description |
|---|---|---|
uid | string | OIDC interaction ID |
csrfToken | string | CSRF token for form submission |
loginHint | string | null | Pre-filled email from OIDC login_hint parameter |
flash.error | string | null | Error message to display |
flash.success | string | null | Success message to display |
showPassword | boolean | Whether to show the password form |
showMagicLink | boolean | Whether to show the magic link form |
showForgotPassword | boolean | Whether to show "Forgot password?" link |
Consent Page (pages/consent.hbs)
| Variable | Type | Description |
|---|---|---|
uid | string | OIDC interaction ID |
csrfToken | string | CSRF token |
client | object | Client metadata (name, logoUri, tosUri, policyUri) |
scopes | string[] | Requested scopes |
claims | string[] | Requested claims |
2FA Pages (pages/two-factor-verify.hbs)
| Variable | Type | Description |
|---|---|---|
uid | string | OIDC interaction ID |
csrfToken | string | CSRF token |
method | string | Current 2FA method (email_otp, totp, recovery) |
maskedEmail | string | null | Masked email for OTP display |
flash.error | string | null | Error message |
Email Templates
| Variable | Type | Description |
|---|---|---|
branding.* | object | Same branding variables as pages |
url | string | Action URL (magic link, reset link, invite link) |
code | string | OTP code (for otp-code template) |
expiresIn | string | Human-readable expiry (e.g., "15 minutes") |
userName | string | Recipient's display name |
year | number | Current year |
Creating a Custom Login Page
Let's create a completely custom login page. Start by copying the default templates:
Step 1: Copy Default Templates
# Create a directory for your custom templates
mkdir -p my-templates
# Copy the default templates as a starting point
docker cp porta-app:/app/templates/default/. my-templates/Or if working from the source repository:
cp -r templates/default my-templatesStep 2: Edit the Login Page
Edit my-templates/pages/login.hbs:
Step 3: Customize the Layout
Edit my-templates/layouts/main.hbs to change the overall page structure, add external fonts, or modify the base styles:
Step 4: Mount in Docker Compose
services:
porta:
image: blendsdk/porta:latest
volumes:
- ./my-templates:/app/templates/default
# ... rest of configurationRestart the container and your custom templates are live:
docker compose up -dImportant
When mounting a custom templates directory, you must include all template files — the entire layouts/, pages/, partials/, and emails/ directories. Porta does not merge custom templates with defaults; it reads from the mounted directory exclusively.
Customizing Email Templates
Email templates come in pairs — an HTML version (.hbs) and a plain-text version (.txt.hbs). Both are sent together as a multipart email so recipients always get a readable version.
Available Email Templates
| Template | When It's Sent |
|---|---|
magic-link | User requests a magic link login |
password-reset | User requests a password reset |
invitation | Admin invites a user to an organization |
otp-code | 2FA email OTP code delivery |
password-changed | Notification after password is changed |
welcome | Welcome email after account creation |
Example: Custom Magic Link Email
Edit my-templates/emails/magic-link.hbs:
And the plain-text version my-templates/emails/magic-link.txt.hbs:
Docker Compose Configuration
Development Setup (With Hot Reload)
For developing custom templates, mount your templates directory and restart on changes:
services:
porta:
image: blendsdk/porta:latest
ports:
- "3000:3000"
env_file: [.env]
environment:
DATABASE_URL: postgresql://porta:porta_secret@postgres:5432/porta
REDIS_URL: redis://redis:6379
volumes:
# Mount custom templates (changes visible on next page load)
- ./my-templates:/app/templates/default
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
postgres:
image: postgres:16-alpine
environment: { POSTGRES_DB: porta, POSTGRES_USER: porta, POSTGRES_PASSWORD: porta_secret }
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U porta"], interval: 5s, retries: 5 }
redis:
image: redis:7-alpine
healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 5s, retries: 5 }
volumes:
pgdata:Production Setup
For production, you can either:
Option A: Volume mount (recommended for easy updates)
volumes:
- ./my-templates:/app/templates/default:ro # read-only mountOption B: Custom Docker image (for immutable deployments)
FROM blendsdk/porta:latest
COPY my-templates/ /app/templates/default/Testing Custom Templates
Quick Test Workflow
- Start Porta with your custom templates mounted
- Create a test organization and set branding:bash
porta org branding <org-id> \ --logo-url "https://example.com/logo.png" \ --primary-color "#E11D48" \ --company-name "Test Corp" - Open the login page at
http://localhost:3000/<org-slug>/auth - Test each flow:
- Password login
- Magic link request (check MailHog at
http://localhost:8025for emails) - Password reset flow
- 2FA setup and verification (if enabled)
Template Reload
Templates are read from disk on each request in development. After editing a template file, simply refresh the page to see changes — no container restart needed.
MailHog for Email Testing
For testing email templates locally, use the development Docker Compose profile which includes MailHog:
docker compose --profile dev up -dThen open http://localhost:8025 to view all sent emails.
Internationalization (i18n)
Porta supports locale-based translations. Translation files are stored in locales/default/<locale>/:
locales/default/
└── en/
├── common.json # Shared strings (buttons, labels)
├── login.json # Login page strings
├── consent.json # Consent page strings
├── errors.json # Error messages
├── emails.json # Email subject lines and content
└── ...Use the {{t "key"}} helper in templates to reference translated strings:
To add a new language, create a new locale directory (e.g., locales/default/nl/) with the same JSON files and mount it alongside your custom templates.
Next Steps
- Capabilities Overview — See everything Porta can do
- Authentication Modes — Login methods and 2FA in depth
- Admin API Reference — Branding API details
- CLI Reference —
porta org brandingcommand - Deployment Guide — Production deployment with custom templates