Appearance Customization
This guide explains how to customize KUKAN when you fork the repository for your organization. Changes can be applied incrementally — start with the simplest approach.
Scope of Changes
Section titled “Scope of Changes”Files you modify in a fork are limited to apps/web/brands/default/. Its contents:
Path (under brands/default/) |
Contents | Section |
|---|---|---|
brand-config.ts |
Site name, description, logo, footer links, OG image, favicon, GA ID | Tier 2 |
theme.css |
CSS variable overrides (colors, sizing) | Tier 1 |
messages/ja.json / en.json |
UI text (i18n message) overrides | i18n Message Overrides |
overrides/ |
Component replacements for Header / Footer / TopPage (slots) | Tier 3 |
pages/ |
Custom static pages (terms, privacy policy, etc.) | Static Pages |
public/ |
Static files: logo, favicon, OG image, etc. | Static Files |
index.ts / messages/index.ts |
Export barrels; normally no need to edit | — |
Do not modify files outside this directory. Doing so will cause merge conflicts when pulling upstream updates.
Tier 1: Colors and Sizing
Section titled “Tier 1: Colors and Sizing”Write CSS variables in brands/default/theme.css to change colors and sizes across the entire site.
:root { --primary: 142 64% 32%; /* Main color (HSL) */ --primary-foreground: 0 0% 100%; /* Text color on main color */ --kukan-header-height: 72px; /* Header height */ --kukan-container-max-width: 1400px;}Available CSS Variables
Section titled “Available CSS Variables”You can override shadcn/ui standard variables and KUKAN-specific variables (prefixed with --kukan-*). See apps/web/src/app/globals.css for the full list.
Each variable carries an override rating to make the go/no-go decision easier:
- Recommended — variables intended to be changed to match your brand
- Caution — overridable, but shared across multiple components and states. Check the “Purpose and main usage” column and preserve the default light/dark relationship
- Not recommended — variables that convey UI meaning (error, success, warning). Changing them for branding reasons is discouraged
| Variable | Rating | Purpose and main usage |
|---|---|---|
--primary / --primary-foreground |
Recommended | Main brand color. Buttons (default), links, progress bars, text selection, checked states of checkboxes etc. |
--highlight |
Recommended | Search keyword highlighting (only affects search result display) |
--radius |
Recommended | Base border radius (all components) |
--kukan-* |
Recommended | Layout values such as header height, logo height, container width |
--background / --foreground |
Caution | Page background and base text. Affects the whole site |
--card / --popover (each with -foreground) |
Caution | Backgrounds and text of cards and popups such as dropdowns and selects |
--secondary / --secondary-foreground |
Caution | Backgrounds of secondary buttons and badges. Expected to stay light |
--muted / --muted-foreground |
Caution | Supplementary text (the most widely used variable on the site), placeholders, skeletons, tinted backgrounds. Expected to stay light |
--accent / --accent-foreground |
Caution | Hover and selection color. Hover of outline/ghost buttons, focused items in selects/dropdowns, dialog close buttons, etc. Expected to stay light |
--border / --input |
Caution | Borders and input outlines across all components |
--ring |
Caution | Focus ring. Directly affects keyboard-navigation visibility — keep sufficient contrast against backgrounds |
--destructive / --destructive-foreground |
Not recommended | Semantic color for errors and deletion (destructive buttons, form errors, delete menu items). Solid fill and text on it |
--success / --warning |
Not recommended | Semantic colors for success / warning states (alerts, pipeline status, form warnings). Used for borders, tinted backgrounds, icons |
--success-tint-foreground / --warning-tint-foreground |
Not recommended | Text color on the tinted (/10–/15) success / warning surfaces; not for solid fills |
--ai / --ai-tint-foreground |
Not recommended | Semantic color marking what AI generated (abstract badge, search cards, etc.). -tint-foreground is the text color on the tinted surface |
Rules for Overriding
Section titled “Rules for Overriding”Follow these rules to swap colors safely:
- Override raw tokens only — redeclare variables like
--primaryas-is. Never touch the--color-*mappings inglobals.css(they wire tokens into Tailwind and are managed upstream). - Keep the bare HSL triplet format — write
--primary: 142 64% 32%;with no commas and nohsl()wrapper. Hex values orhsl(...)-wrapped values will not work. - Do not bake in opacity — transparency is applied at usage sites (e.g.
bg-success/10). Tokens must hold fully opaque base colors. - Update paired tokens together and check contrast — when changing
--primary, also update--primary-foregroundand keep a contrast ratio of at least 4.5:1 (WCAG AA). When changing--success/--warning, set the matching-tint-foregroundto a dark shade of the same hue and verify at least 4.5:1 on the/15tinted surface (unlike-foregroundtokens, these are not meant for solid fills). - Do not introduce
dark:classes — KUKAN’s theme tokens are light-only. Addingdark:in override components breaks the display only for users whose OS prefers dark mode. - Avoid raw palette colors in override components too — use semantic classes like
text-successinstead oftext-green-600, so a future recoloring only takes edits totheme.css.
Adding Custom Tokens (--brand-*)
Section titled “Adding Custom Tokens (--brand-*)”When a designer-specified palette includes colors that do not fit any official token role (primary, accent, etc.), you can add custom tokens with the --brand- prefix in brands/default/theme.css.
:root { --brand-accent-green: 183 29% 87%; --brand-link: 183 79% 28%;}--brand-*is a fork-only namespace — upstream never defines or references--brand-*tokens, so they cannot conflict with upstream updates. Conversely,--kukan-*is reserved for upstream; do not use it for custom tokens- Use the same bare HSL triplet format as official tokens (so opacity modifiers like
/10work at usage sites) - Custom tokens can only be consumed in fork-owned components (
brands/default/overrides//brands/default/pages/). Upstream components never reference them - No Tailwind utility classes are generated —
theme.cssis a separate CSS unit fromglobals.css, so@themecannot generate classes for it. Reference custom tokens with the arbitrary value syntax:
<a className="text-[hsl(var(--brand-link))] bg-[hsl(var(--brand-accent-green))]">...</a>Changing Colors on Non-Overridden Screens
Section titled “Changing Colors on Non-Overridden Screens”If you want to recolor a single component on an upstream screen you have not overridden, overriding official tokens often has too wide a blast radius (see the table above). For this case, the upstream repository adds component-scoped alias tokens on demand (e.g. --kukan-header-bg, defaulting to var(--primary) so nothing changes unless a fork overrides it). Propose the addition via an issue on the upstream repository when you need one.
Tier 2: Text and Metadata
Section titled “Tier 2: Text and Metadata”Edit brands/default/brand-config.ts to change the site name, copyright, and metadata.
import type { BrandConfig } from '@/types/brand'
export const brandConfig: BrandConfig = { siteName: 'City of Example Open Data Catalog', siteDescription: 'Search and download open data published by the City of Example', copyright: 'City of Example.', copyrightUrl: 'https://www.city.example.lg.jp',
logo: { type: 'image', src: '/brand/logo.svg', width: 160, height: 40, alt: 'City of Example' },
headerNavExtra: [ { label: 'Official Site', href: 'https://www.city.example.lg.jp', external: true }, ], footerLinks: [ { label: { ja: '利用規約', en: 'Terms of Use' }, href: '/terms' }, { label: 'Contact', href: '/contact' }, ],
ogImage: '/brand/og-image.png', faviconPath: '/brand/favicon.ico',}Field Reference
Section titled “Field Reference”| Field | Description |
|---|---|
siteName |
Site title (browser tab, header, etc.) |
siteDescription |
meta description (for search engines) |
copyright |
Footer copyright text (© 2026 is prepended automatically) |
copyrightUrl |
Link target for the site name in the footer (omit for no link) |
logo |
Header logo configuration (see below) |
headerNavExtra |
Additional navigation items for the header |
footerLinks |
Footer link list |
ogImage |
OGP image path (relative to public/) |
faviconPath |
Favicon path |
noindex |
When true, adds a noindex, nofollow robots meta tag to every page (omitted = none) |
gaMeasurementId |
GA4 Measurement ID (G-XXXXXXXXXX). null / omitted disables GA4 (Access Analytics) |
Text fields (siteName, siteDescription, copyright, logo.alt, headerNavExtra[].label, footerLinks[].label) accept either a plain string or a locale map (e.g. { ja: '利用規約', en: 'Terms of Use' }). A plain string applies to all locales; a map follows locale switching. Missing locales fall back to English (the default locale), then to any defined entry.
noindex is a switch for staging or private deployments that should stay out of search engines. It does not turn crawlers away from the whole site in robots.txt (that would keep them from ever seeing the meta tag). To restrict access itself, combine this with access controls such as Basic auth or IP restrictions.
What the site gives search engines follows noindex as below. No Search Console registration is needed: the major engines find the sitemap from the Sitemap: line in robots.txt.
| Output | noindex: false |
noindex: true |
|---|---|---|
/sitemap.xml (datasets, resources, organizations, categories, tags) |
Public ones only, split by kind into files of 10,000 at /sitemap/<kind>/<n>.xml |
404 |
Sitemap: line in robots.txt |
Present | Absent |
Structured data on dataset pages (schema.org Dataset, JSON-LD) |
On public datasets (read by Google Dataset Search) | Absent |
| List pages with a filter or a search term | noindex, follow |
noindex, nofollow (every page) |
Whatever noindex says, robots.txt turns crawlers away from list queries (/dataset?, /organization?, /group?), /api/, /odata/, /dashboard and /auth/. Filter combinations make endless URLs; datasets are reached from the sitemap and from the organization, category and tag pages. The sitemap and robots.txt write their URLs with the origin of BETTER_AUTH_URL.
The example-query chips under the search box are configured from the admin Site Management page (/dashboard/admin/site), not the brand config. Values you would rather not rebuild for, such as a population and its as-of date, go in Site metadata on the same page (Reading Site Metadata).
Logo Configuration
Section titled “Logo Configuration”The logo field supports two modes:
// Default (KUKAN logo)logo: { type: 'default' }
// Image file (recommended: 32-40px height, SVG format)logo: { type: 'image', src: '/brand/logo.svg', width: 160, height: 40, alt: 'City of Example' }i18n Message Overrides
Section titled “i18n Message Overrides”You can override UI translation text for your organization. Specify only the keys you want to change in brands/default/messages/{locale}.json.
{ "home": { "title": "○○市オープンデータカタログ", "description": "○○市のオープンデータを検索・活用できるポータル" }}{ "home": { "title": "City of Example Open Data Catalog", "description": "A portal to search and utilize open data from the City of Example" }}- Only specified keys are overridden; all others use the defaults from
apps/web/messages/{locale}.json - Nested objects are merged recursively — you don’t need to repeat sibling keys
- See
apps/web/messages/en.jsonfor the full list of available keys
Choosing Between brandConfig and Messages
Section titled “Choosing Between brandConfig and Messages”| Use case | Where to configure |
|---|---|
| Brand-specific values (site name, nav items, copyright) | brand-config.ts (locale maps for multilingual text) |
| Overriding the main UI’s translated text | brands/default/messages/{locale}.json |
Tier 3: Component Replacement
Section titled “Tier 3: Component Replacement”To change the structure of the Header or Footer, create custom components and register them as overrides.
1. Create a custom component
import { getCurrentUser } from '@/lib/server-api'import { LanguageSwitcher } from '@/components/layout/language-switcher'import { MobileNav } from '@/components/layout/mobile-nav'import { UserMenu } from '@/components/auth/user-menu'
export async function Header() { const user = await getCurrentUser()
return ( <header className="sticky top-0 z-40 bg-[hsl(var(--primary))]"> <div className="mx-auto flex h-[var(--kukan-header-height)] max-w-[var(--kukan-container-max-width)] items-center justify-between px-4"> <img src="/brand/logo.svg" alt="City of Example" className="h-8" /> <div className="flex items-center gap-2"> <LanguageSwitcher /> {user && <UserMenu user={user} />} <MobileNav user={user} /> </div> </div> </header> )}2. Register in overrides/index.ts
import type { BrandOverrides } from '@/types/brand'import { Header } from './header'
export const overrides: BrandOverrides = { Header,}That’s all it takes to replace the Header.
Available Slots
Section titled “Available Slots”| Slot | Replaces |
|---|---|
Header |
Entire site header |
Footer |
Entire site footer |
TopPage |
Entire top page (home page) |
Reusing Default Parts
Section titled “Reusing Default Parts”Inside custom components, you can import and reuse parts of the default implementation.
Use DefaultHeader / DefaultFooter instead of Header / Footer to avoid circular references.
import { DefaultHeader } from '@/components/layout/header'import { DefaultFooter } from '@/components/layout/footer'Reading Brand Text
Section titled “Reading Brand Text”When a custom component displays brandConfig text fields (siteName etc.), resolve them through resolveBrandConfig() first. Text fields are typed LocalizedText (a string or a locale map), so rendering them directly like {brandConfig.siteName} is a type error.
In async server components, use getLocale():
import { getLocale } from 'next-intl/server'import { resolveBrandConfig } from '@/lib/resolved-brand'
export async function CustomHeader() { const brand = resolveBrandConfig(await getLocale()) // brand.siteName is a string resolved for the current locale return <span>{brand.siteName}</span>}In client components, use useLocale() (do not import from next-intl/server — it is server-only and breaks the build). useLocale() also works in non-async server components.
'use client'
import { useLocale } from 'next-intl'import { resolveBrandConfig } from '@/lib/resolved-brand'
export function SiteName() { const brand = resolveBrandConfig(useLocale()) return <span>{brand.siteName}</span>}Reading Site Metadata
Section titled “Reading Site Metadata”Values that change about once a month, such as a population, a household count or their as-of date, belong in Site metadata on the admin Site Management page (/dashboard/admin/site) rather than in the brand config, so updating one needs no rebuild. Each value is a key, a type (number, text, text per language, date) and the value; the component that reads a key decides what it is for. Keys starting with kukan- are reserved for KUKAN itself, so brands use other keys.
In a server component, pass the key to a formatting component as name. Nothing renders when the key is missing or holds another type.
import { SiteDate, SiteNumber, SiteText } from '@/components/site-metadata'
export async function PopulationBand() { return ( <dl> <dt>Population</dt> <dd> <SiteNumber name="population" /> </dd> <dt>Change from last month</dt> <dd> <SiteNumber name="population-change" signed /> </dd> <dd> <SiteDate name="population-as-of" /> </dd> <dd> <SiteText name="tagline" /> </dd> </dl> )}| Component | Renders |
|---|---|
SiteNumber |
A number with digit grouping for the page language. signed adds + to positive values; fractionDigits fixes the decimals |
SiteDate |
A date. Japanese era (令和8年6月1日) on a Japanese page and the Gregorian calendar on an English one, unless calendar="gregory" / "japanese" |
SiteText |
A text. A per-language text is picked by the page language, with the same fallback as the brand texts |
To combine values yourself, read them with getSiteMetadata(). The settings are read once per request however many components ask.
import { getLocale } from 'next-intl/server'import { getSiteMetadata } from '@/lib/server-api'
const [metadata, locale] = await Promise.all([getSiteMetadata(), getLocale()])metadata.number('population') // number | undefinedmetadata.text('tagline', locale) // string | undefinedmetadata.date('population-as-of') // 'YYYY-MM-DD' | undefinedUsing Values in Client Components
Section titled “Using Values in Client Components”The components above and getSiteMetadata() are server-only; a 'use client' file cannot use them. Even for interactive parts such as tabs or a carousel, read the values on the server and pass them down. The values are then in the first HTML, so nothing flickers while loading and the browser never reads the settings again.
- Pass the values: read them in a server component and pass the plain values (numbers, ISO date strings) as props
- Pass what was rendered: render with
SiteNumberand the others on the server and pass the elements aschildrenor other props
// overrides/top-page.tsx — server componentimport { SiteDate, SiteNumber } from '@/components/site-metadata'import { StatsCarousel, StatSlide } from './stats-carousel' // 'use client'
export async function TopPage() { return ( <StatsCarousel> <StatSlide label="Population" asOf={<SiteDate name="population-as-of" />}> <SiteNumber name="population" /> </StatSlide> <StatSlide label="Households" asOf={<SiteDate name="population-as-of" />}> <SiteNumber name="households" /> </StatSlide> </StatsCarousel> )}The values appear as key–value pairs under metadata in the public GET /api/v1/site/settings (dates as ISO strings). After a save, visitors who are not signed in see the change within about 90 seconds on AWS and 30 seconds on Docker Compose.
Organizing Override Files
Section titled “Organizing Override Files”If you have many overrides, organize them into subdirectories:
brands/default/overrides/├── index.ts├── layout/│ ├── header.tsx│ └── footer.tsx└── pages/ └── hero-section.tsxThe internal structure is up to you as long as index.ts exports everything.
Static Pages
Section titled “Static Pages”You can add organization-specific static pages such as terms of use or privacy policies.
A sample terms page (/terms) is included by default. Remove it if not needed.
1. Create a page component in brands/default/pages/
import type { Metadata } from 'next'
export const metadata: Metadata = { title: 'Privacy Policy',}
export default function PrivacyPage() { return ( <article className="mx-auto max-w-3xl px-4 py-12"> <h1 className="mb-8 text-2xl font-bold">Privacy Policy</h1> <p>...</p> </article> )}2. Register in brands/default/pages/index.ts
export const pages: Record<string, () => Promise<BrandPage>> = { terms: () => import('./terms'), privacy: () => import('./privacy'), // added}The page is now accessible at /privacy.
3. Optionally add links to headerNavExtra or footerLinks in brand-config.ts
headerNavExtra: [ { label: 'Privacy Policy', href: '/privacy' },],footerLinks: [ { label: 'Terms of Use', href: '/terms' }, { label: 'Privacy Policy', href: '/privacy' },],Removing Pages
Section titled “Removing Pages”To remove a page, delete its entry from the pages map in brands/default/pages/index.ts and delete the corresponding .tsx file.
Static Files
Section titled “Static Files”Place logos, favicons, and OG images in apps/web/brands/default/public/.
apps/web/brands/default/public/├── logo.svg├── favicon.ico└── og-image.pngFiles placed here are served at URLs like /brand/logo.svg.
Multiple Brands (Multiple Sites in One Fork)
Section titled “Multiple Brands (Multiple Sites in One Fork)”If one fork operates multiple sites (Multi-Site Operation) and you want a different look per site, you can hold multiple brands (ADR-042).
- The default brand is
apps/web/brands/default/. Add a new brand by copyingbrands/default/toapps/web/brands/<name>/and editing it - Passing
KUKAN_BRAND=<name>at build time switches what@/brandresolves to (unset →brands/default/). Only the selected brand enters the build - On AWS, set
brand: '<name>'on each site inenvironments.tsand the pipeline builds a web image per site with the matchingKUKAN_BRAND(Environment Configuration Reference). An unknown brand fails the image build - With Docker Compose, pass
--build-arg KUKAN_BRAND=<name>when building the web image (for multi-site, uncommentKUKAN_BRANDindocker/multi-site/compose.site.yml) - The
BrandConfig/BrandOverridescontract and the CSS variable rules are common to all brands; each brand only supplies values and override components
Pulling Upstream Updates
Section titled “Pulling Upstream Updates”Periodically pull updates from the upstream main branch:
git remote add upstream https://github.com/kukan-project/kukan.gitgit fetch upstreamgit merge upstream/mainAs long as your changes are limited to brands/default/ (including its public/), merge conflicts should not occur.
If Conflicts Occur
Section titled “If Conflicts Occur”When the upstream adds new fields to src/types/brand.ts, you may need to add them to your brand-config.ts. TypeScript will report type errors — follow the error messages to add the missing fields.
Testing
Section titled “Testing”The upstream unit tests are designed to be brand-independent: inside tests, the contents of brands/default/ are pinned to the KUKAN defaults. No matter how you customize the brand, the upstream tests (CI) will keep passing.
- Do not modify upstream test files. If a customization breaks an upstream test, report it to the upstream repository via an issue instead of patching it in your fork
- Place tests for your custom components in
apps/web/brands/default/__tests__/(with several brands, in each brand’sbrands/<name>/__tests__/). Files named*.test.ts/*.test.tsxare discovered automatically and run withpnpm test. In tests,@/brandalways points atbrands/default/, so import a brand’s files by relative path (such as../overrides/header). Whether to write tests is up to your fork
import { describe, it, expect } from 'vitest'import { render, screen } from '@testing-library/react'import { Header } from '../overrides/header'
describe('Custom Header', () => { it('should render the organization logo', async () => { render(await Header()) expect(screen.getByAltText('City logo')).toBeInTheDocument() })})Summary
Section titled “Summary”| Goal | Method | Estimated time |
|---|---|---|
| Change colors | Write CSS variables in brands/default/theme.css |
5 min |
| Use custom colors outside official tokens | Define --brand-* tokens in brands/default/theme.css |
5 min |
| Change site name and text | Edit brands/default/brand-config.ts |
30 min |
| Override UI translation text | Add override keys to brands/default/messages/{locale}.json |
10 min |
| Replace logo | Place file in brands/default/public/ + edit config |
10 min |
| Change header/footer structure | Create components in brands/default/overrides/ |
A few hours |
| Replace the top page | Create component in brands/default/overrides/ |
A few hours |
| Add static pages | Create components in brands/default/pages/ + register |
30 min |