ADR-002: Keycloakify Migration
Status: ✅ Complete Date: 2025-10 Deciders: Frontend Team, DevOps Lead Related: ADR-001, Frontend Architecture
Context
Initially, we used Keycloak's default Freemarker templates for the login and registration UI. These templates were stored in backend/keycloak/themes/noumaris-old/ and required:
- Learning Freemarker template syntax
- Separate styling from our React codebase
- Manual CSS management
- Limited TypeScript support
- Difficult to maintain consistency with main app
As our frontend evolved with React, TailwindCSS, and modern tooling, the disconnect between our main app and the Keycloak theme became a maintenance burden.
Decision
Migrate from Freemarker templates to Keycloakify - A React-based Keycloak theme framework.
What Changed
Old Approach (Freemarker):
backend/keycloak/themes/noumaris-old/
├── login/
│ ├── login.ftl
│ ├── register.ftl
│ └── resources/
│ └── css/
│ └── login.cssNew Approach (Keycloakify):
frontend/src/keycloak-theme/
├── kc.gen.tsx # Auto-generated
└── login/
├── KcPage.jsx # Main theme router
├── KcContext.jsx # Context definitions
├── KcApp.jsx # Dev preview wrapper
└── pages/
├── Login.jsx # Custom login page
└── Register.jsx # Custom registration pageRationale
Alternatives Considered
Option 1: Keep Freemarker, Improve Styling
Pros:
- No migration needed
- Familiar to backend team
Cons:
- Still requires Freemarker knowledge
- CSS separate from TailwindCSS
- Can't reuse React components
- No TypeScript support
- Difficult to preview changes
Verdict: ❌ Rejected - Doesn't solve core problems
Option 2: Headless Keycloak with Custom Frontend
Pros:
- Full control over auth UI
- Pure React implementation
- No Keycloak theme constraints
Cons:
- Much more complex implementation
- Need to handle all OAuth flows manually
- Security risk if implemented incorrectly
- Lose Keycloak's battle-tested auth flows
- More maintenance burden
Verdict: ❌ Rejected - Too risky, over-engineered
Option 3: Keycloakify (SELECTED)
Pros:
- ✅ React-based - Use familiar React patterns
- ✅ TailwindCSS - Consistent styling with main app
- ✅ TypeScript - Type-safe development
- ✅ Component Reuse - Share components with main app
- ✅ Hot Reload - Fast development cycle
- ✅ Preview Mode - Test theme without Keycloak
- ✅ JAR Output - Standard Keycloak deployment
- ✅ Active Community - v11 with good support
Cons:
- Migration effort (~2 days)
- New build step (JAR generation)
- Learning Keycloakify API
Verdict: ✅ SELECTED
Consequences
Positive
- Consistent Design: Same TailwindCSS utilities as main app
- Faster Development: React + hot reload vs Freemarker editing
- Type Safety: TypeScript catches errors at compile time
- Better DX: Familiar React patterns instead of Freemarker
- Component Reuse: Can use Button, Input components from main app
- Easier Maintenance: One stack (React) instead of two (React + Freemarker)
- Preview Mode: Test theme changes without running Keycloak
Negative
- Build Complexity: Extra build step to generate JAR files
- JAR Deployment: Need to copy JAR to Keycloak providers directory
- Learning Curve: Team needs to learn Keycloakify API
- Two JARs: Separate builds for KC 22-25 vs KC 26+
Mitigations
- Documentation: Created
frontend/KEYCLOAK_THEME.mdguide - Build Scripts: Added
npm run build-keycloak-themecommand - Preview Mode:
npm run keycloak-theme:devfor local testing - Deployment Guide: Step-by-step instructions for JAR deployment
Implementation
Migration Steps
- ✅ Install Keycloakify:
npm install keycloakify@^11 - ✅ Create
src/keycloak-theme/directory structure - ✅ Set up KcPage router for page selection
- ✅ Implement Login.jsx with TailwindCSS
- ✅ Implement Register.jsx with TailwindCSS
- ✅ Configure vite.config.ts for Keycloakify build
- ✅ Add build script to package.json
- ✅ Test locally in Keycloak Docker
- ✅ Document in KEYCLOAK_THEME.md
- ✅ Remove old Freemarker theme from backend/
Build Process
# Development (preview mode)
cd frontend
npm run keycloak-theme:dev # Opens browser to test theme
# Production (build JARs)
npm run build-keycloak-theme
# Output:
# frontend/dist_keycloak/
# ├── keycloak-theme-for-kc-22-to-25.jar # For Keycloak 22-25
# └── keycloak-theme-for-kc-all-other-versions.jar # For Keycloak 26+Deployment
# Local Docker
docker cp frontend/dist_keycloak/keycloak-theme-for-kc-22-to-25.jar \
keycloak:/opt/keycloak/providers/
docker-compose restart keycloak
# Production Cloud Run
gcloud run deploy keycloak \
--image gcr.io/noumaris/keycloak:latest \
--update-env-vars KEYCLOAK_THEME_PATH=/opt/keycloak/providers/Theme Selection
In Keycloak Admin Console:
- Navigate to Realm Settings → Themes
- Set "Login Theme" to
noumaris - Save
Code Examples
Before (Freemarker)
<!-- login.ftl -->
<#import "template.ftl" as layout>
<@layout.registrationLayout displayInfo=social.displayInfo; section>
<#if section = "form">
<div id="kc-form">
<div id="kc-form-wrapper">
<form id="kc-form-login" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<label for="username" class="${properties.kcLabelClass!}">
${msg("usernameOrEmail")}
</label>
<input tabindex="1" id="username" name="username" type="text"/>
</div>
</form>
</div>
</div>
</#if>
</@layout.registrationLayout>After (Keycloakify/React)
// Login.jsx
import { useState } from 'react';
import { useKcMessage } from 'keycloakify';
export default function Login({ kcContext }) {
const { msg } = useKcMessage();
const [username, setUsername] = useState('');
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8">
<h2 className="text-3xl font-bold text-gray-900">
{msg("loginTitle")}
</h2>
<form method="post" action={kcContext.url.loginAction}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
{msg("usernameOrEmail")}
</label>
<input
type="text"
name="username"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<button
type="submit"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
{msg("doLogIn")}
</button>
</div>
</form>
</div>
</div>
);
}Benefits Realized
Development Speed
- Before: 30 minutes to update login button color (edit CSS, restart Keycloak, test)
- After: 2 minutes (change TailwindCSS class, hot reload, test in browser)
Design Consistency
- Before: Login page looked different from main app
- After: Seamless transition from login → app (same design system)
Type Safety
- Before: 0% type coverage in Freemarker templates
- After: 100% TypeScript coverage in theme
Lessons Learned
- Preview Mode is Essential: Being able to test without Keycloak running saved hours
- JAR Versioning Matters: Keep old JAR versions in git history for rollback
- i18n Still Works: Keycloakify preserves Keycloak's internationalization
- Custom Components: Can build shared component library for theme + main app
Future Enhancements
- [ ] Add Keycloak theme to main app's Storybook
- [ ] Create more custom pages (forgot-password, email-verification)
- [ ] Add loading skeletons for better UX
- [ ] Implement custom error pages
- [ ] Add analytics tracking to auth flows
References
- Keycloakify Documentation
- Theme Documentation:
frontend/KEYCLOAK_THEME.md(in repo) - Migration PR
- Frontend Architecture