Skip to content

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.

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.

Write CSS variables in brands/default/theme.css to change colors and sizes across the entire site.

apps/web/brands/default/theme.css
: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;
}

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

Follow these rules to swap colors safely:

  1. Override raw tokens only — redeclare variables like --primary as-is. Never touch the --color-* mappings in globals.css (they wire tokens into Tailwind and are managed upstream).
  2. Keep the bare HSL triplet format — write --primary: 142 64% 32%; with no commas and no hsl() wrapper. Hex values or hsl(...)-wrapped values will not work.
  3. Do not bake in opacity — transparency is applied at usage sites (e.g. bg-success/10). Tokens must hold fully opaque base colors.
  4. Update paired tokens together and check contrast — when changing --primary, also update --primary-foreground and keep a contrast ratio of at least 4.5:1 (WCAG AA). When changing --success / --warning, set the matching -tint-foreground to a dark shade of the same hue and verify at least 4.5:1 on the /15 tinted surface (unlike -foreground tokens, these are not meant for solid fills).
  5. Do not introduce dark: classes — KUKAN’s theme tokens are light-only. Adding dark: in override components breaks the display only for users whose OS prefers dark mode.
  6. Avoid raw palette colors in override components too — use semantic classes like text-success instead of text-green-600, so a future recoloring only takes edits to theme.css.

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.

apps/web/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 /10 work 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.css is a separate CSS unit from globals.css, so @theme cannot 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>

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.

Edit brands/default/brand-config.ts to change the site name, copyright, and metadata.

apps/web/brands/default/brand-config.ts
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 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).

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' }

You can override UI translation text for your organization. Specify only the keys you want to change in brands/default/messages/{locale}.json.

apps/web/brands/default/messages/ja.json
{
"home": {
"title": "○○市オープンデータカタログ",
"description": "○○市のオープンデータを検索・活用できるポータル"
}
}
apps/web/brands/default/messages/en.json
{
"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.json for the full list of available keys
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

To change the structure of the Header or Footer, create custom components and register them as overrides.

1. Create a custom component

apps/web/brands/default/overrides/header.tsx
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

apps/web/brands/default/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.

Slot Replaces
Header Entire site header
Footer Entire site footer
TopPage Entire top page (home page)

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'

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>
}

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 | undefined
metadata.text('tagline', locale) // string | undefined
metadata.date('population-as-of') // 'YYYY-MM-DD' | undefined

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 SiteNumber and the others on the server and pass the elements as children or other props
// overrides/top-page.tsx — server component
import { 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.

If you have many overrides, organize them into subdirectories:

brands/default/overrides/
├── index.ts
├── layout/
│ ├── header.tsx
│ └── footer.tsx
└── pages/
└── hero-section.tsx

The internal structure is up to you as long as index.ts exports everything.

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/

apps/web/brands/default/pages/privacy.tsx
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' },
],

To remove a page, delete its entry from the pages map in brands/default/pages/index.ts and delete the corresponding .tsx file.

Place logos, favicons, and OG images in apps/web/brands/default/public/.

apps/web/brands/default/public/
├── logo.svg
├── favicon.ico
└── og-image.png

Files 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 copying brands/default/ to apps/web/brands/<name>/ and editing it
  • Passing KUKAN_BRAND=<name> at build time switches what @/brand resolves to (unset → brands/default/). Only the selected brand enters the build
  • On AWS, set brand: '<name>' on each site in environments.ts and the pipeline builds a web image per site with the matching KUKAN_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, uncomment KUKAN_BRAND in docker/multi-site/compose.site.yml)
  • The BrandConfig / BrandOverrides contract and the CSS variable rules are common to all brands; each brand only supplies values and override components

Periodically pull updates from the upstream main branch:

ターミナルウィンドウ
git remote add upstream https://github.com/kukan-project/kukan.git
git fetch upstream
git merge upstream/main

As long as your changes are limited to brands/default/ (including its public/), merge conflicts should not 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.

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’s brands/<name>/__tests__/). Files named *.test.ts / *.test.tsx are discovered automatically and run with pnpm test. In tests, @/brand always points at brands/default/, so import a brand’s files by relative path (such as ../overrides/header). Whether to write tests is up to your fork
apps/web/brands/default/__tests__/header.test.tsx
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()
})
})
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