⚠️ Porta is in beta — APIs and features may change before v1.0
Skip to content

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?

LayerWhat It CoversEffort
API-driven brandingLogo, favicon, colors, company name, custom CSS per orgZero code — API/CLI only
Custom CSS injectionFull style override via customCss field (up to 10KB)CSS only
Template overrideReplace any or all Handlebars templates via Docker volume mountHTML/Handlebars
Email templatesCustomize HTML and plain-text transactional emailsHTML/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

SettingAPI FieldDefaultDescription
Logo URLlogoUrl(none)Image displayed in page headers and email headers
Favicon URLfaviconUrl(none)Browser tab icon
Primary ColorprimaryColor#3B82F6Buttons, links, accents (sets CSS --primary variable)
Company NamecompanyNameOrganization namePage titles, headers, footers, email signatures
Custom CSScustomCss(none)Raw CSS injected into <head> (max 10KB)

Setting Branding via CLI

bash
# 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

bash
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:

css
/* 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.hbs

How Templates Are Rendered

  1. The layout (layouts/main.hbs) provides the HTML shell — <head>, <body>, styles, branding CSS variable, and the {{{body}}} placeholder
  2. The page (e.g., pages/login.hbs) provides the content rendered inside the layout
  3. Partials ({{> header}}, {{> footer}}, {{> flash-messages}}) are reusable snippets included by pages
  4. Branding values from the organization are automatically injected as template variables
  5. 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)

VariableTypeDescription
branding.logoUrlstring | nullOrganization logo URL
branding.faviconUrlstring | nullFavicon URL
branding.primaryColorstringHex color (default #3B82F6)
branding.companyNamestringOrganization display name
branding.customCssstring | nullRaw CSS for injection
pageTitlestringPage title (e.g., "Sign In", "Reset Password")
localestringCurrent locale (e.g., en)
yearnumberCurrent year (for copyright footers)
tfunctionTranslation helper — {{t "key"}}

Login Page (pages/login.hbs)

VariableTypeDescription
uidstringOIDC interaction ID
csrfTokenstringCSRF token for form submission
loginHintstring | nullPre-filled email from OIDC login_hint parameter
flash.errorstring | nullError message to display
flash.successstring | nullSuccess message to display
showPasswordbooleanWhether to show the password form
showMagicLinkbooleanWhether to show the magic link form
showForgotPasswordbooleanWhether to show "Forgot password?" link
VariableTypeDescription
uidstringOIDC interaction ID
csrfTokenstringCSRF token
clientobjectClient metadata (name, logoUri, tosUri, policyUri)
scopesstring[]Requested scopes
claimsstring[]Requested claims

2FA Pages (pages/two-factor-verify.hbs)

VariableTypeDescription
uidstringOIDC interaction ID
csrfTokenstringCSRF token
methodstringCurrent 2FA method (email_otp, totp, recovery)
maskedEmailstring | nullMasked email for OTP display
flash.errorstring | nullError message

Email Templates

VariableTypeDescription
branding.*objectSame branding variables as pages
urlstringAction URL (magic link, reset link, invite link)
codestringOTP code (for otp-code template)
expiresInstringHuman-readable expiry (e.g., "15 minutes")
userNamestringRecipient's display name
yearnumberCurrent 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

bash
# 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:

bash
cp -r templates/default my-templates

Step 2: Edit the Login Page

Edit my-templates/pages/login.hbs:

handlebars
{{!-- Custom login page --}}
<div class="card" style="max-width: 400px; margin: 40px auto;">
  {{> header}}
  {{> flash-messages}}

  <h1 style="text-align: center; margin-bottom: 24px;">Welcome Back</h1>

  {{#if showPassword}}
  <form method="post" action="/interaction/{{uid}}/login">
    <input type="hidden" name="_csrf" value="{{csrfToken}}">

    <div style="margin-bottom: 16px;">
      <label for="email">Email</label>
      <input type="email" id="email" name="email"
        value="{{loginHint}}" required autofocus
        placeholder="you@example.com"
        style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px;">
    </div>

    <div style="margin-bottom: 16px;">
      <label for="password">Password</label>
      <input type="password" id="password" name="password" required
        placeholder="Enter your password"
        style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px;">
    </div>

    <button type="submit" class="btn" style="width: 100%;">Sign In</button>

    {{#if showForgotPassword}}
    <p style="text-align: center; margin-top: 12px;">
      <a href="/interaction/{{uid}}/forgot-password">Forgot your password?</a>
    </p>
    {{/if}}
  </form>
  {{/if}}

  {{#if showMagicLink}}
    {{#if showPassword}}
    <div style="text-align: center; margin: 20px 0; color: #888;">
      — or —
    </div>
    {{/if}}

    <form method="post" action="/interaction/{{uid}}/magic-link">
      <input type="hidden" name="_csrf" value="{{csrfToken}}">

      {{#unless showPassword}}
      <div style="margin-bottom: 16px;">
        <label for="magic-email">Email</label>
        <input type="email" id="magic-email" name="email"
          value="{{loginHint}}" required
          placeholder="you@example.com"
          style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px;">
      </div>
      {{else}}
      <input type="hidden" name="email" value="">
      {{/unless}}

      <button type="submit" class="btn"
        style="width: 100%; background: transparent; color: var(--primary); border: 2px solid var(--primary);">
        ✉ Send me a magic link
      </button>
    </form>
  {{/if}}

  {{> footer}}
</div>

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:

handlebars
<!DOCTYPE html>
<html lang="{{locale}}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {{#if branding.faviconUrl}}<link rel="icon" href="{{branding.faviconUrl}}">{{/if}}
  <title>{{pageTitle}}{{branding.companyName}}</title>

  <!-- Add custom fonts -->
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">

  <style>
    :root { --primary: {{branding.primaryColor}}; }
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: "Inter", system-ui, sans-serif;
      background: #f8fafc;
      color: #334155;
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .card {
      background: white;
      padding: 32px;
      border-radius: 12px;
      box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
    }
    .btn {
      display: inline-block;
      background: var(--primary);
      color: white;
      border: none;
      padding: 12px 24px;
      border-radius: 8px;
      font-size: 15px;
      font-weight: 500;
      cursor: pointer;
    }
    .btn:hover { opacity: 0.9; }
    label { display: block; font-weight: 500; margin-bottom: 4px; font-size: 14px; }
    a { color: var(--primary); }
    /* Flash messages */
    .flash-error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
    .flash-success { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
  </style>

  {{!-- Inject per-org custom CSS (overrides everything above) --}}
  {{#if branding.customCss}}<style>{{{branding.customCss}}}</style>{{/if}}
</head>
<body>
  {{{body}}}
</body>
</html>

Step 4: Mount in Docker Compose

yaml
services:
  porta:
    image: blendsdk/porta:latest
    volumes:
      - ./my-templates:/app/templates/default
    # ... rest of configuration

Restart the container and your custom templates are live:

bash
docker compose up -d

Important

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

TemplateWhen It's Sent
magic-linkUser requests a magic link login
password-resetUser requests a password reset
invitationAdmin invites a user to an organization
otp-code2FA email OTP code delivery
password-changedNotification after password is changed
welcomeWelcome email after account creation

Edit my-templates/emails/magic-link.hbs:

handlebars
<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: system-ui, sans-serif; background: #f8f9fa; padding: 40px 20px; }
    .container { max-width: 480px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; }
    .header { background: {{branding.primaryColor}}; color: white; padding: 24px; text-align: center; }
    .content { padding: 32px 24px; }
    .btn { display: inline-block; background: {{branding.primaryColor}}; color: white; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-weight: 500; }
    .footer { padding: 16px 24px; text-align: center; color: #888; font-size: 13px; border-top: 1px solid #eee; }
  </style>
</head>
<body>
  <div class="container">
    <div class="header">
      {{#if branding.logoUrl}}<img src="{{branding.logoUrl}}" alt="{{branding.companyName}}" style="max-height: 40px;">{{/if}}
      <h2>{{branding.companyName}}</h2>
    </div>
    <div class="content">
      <p>Hi {{userName}},</p>
      <p>Click the button below to sign in. This link expires in {{expiresIn}}.</p>
      <p style="text-align: center; margin: 24px 0;">
        <a href="{{url}}" class="btn">Sign In to {{branding.companyName}}</a>
      </p>
      <p style="color: #888; font-size: 13px;">If you didn't request this, you can safely ignore this email.</p>
    </div>
    <div class="footer">&copy; {{year}} {{branding.companyName}}</div>
  </div>
</body>
</html>

And the plain-text version my-templates/emails/magic-link.txt.hbs:

handlebars
Hi {{userName}},

Sign in to {{branding.companyName}} by visiting this link:

{{url}}

This link expires in {{expiresIn}}.

If you didn't request this, you can safely ignore this email.

© {{year}} {{branding.companyName}}

Docker Compose Configuration

Development Setup (With Hot Reload)

For developing custom templates, mount your templates directory and restart on changes:

yaml
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)

yaml
volumes:
  - ./my-templates:/app/templates/default:ro  # read-only mount

Option B: Custom Docker image (for immutable deployments)

dockerfile
FROM blendsdk/porta:latest
COPY my-templates/ /app/templates/default/

Testing Custom Templates

Quick Test Workflow

  1. Start Porta with your custom templates mounted
  2. 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"
  3. Open the login page at http://localhost:3000/<org-slug>/auth
  4. Test each flow:
    • Password login
    • Magic link request (check MailHog at http://localhost:8025 for 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:

bash
docker compose --profile dev up -d

Then 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:

handlebars
<h1>{{t "login.title"}}</h1>
<button type="submit">{{t "common.submit"}}</button>
<p>{{t "login.forgot_password"}}</p>

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

Released under the MIT License.