Skip to content
2026-10-27

Branding the web app

Everything organisation-specific in the web app lives in one directory at the repo root. No application code changes, no environment variables.

Branding needs a rebuild, not a restart

branding/ is bundled into the Next.js build, so it is the one customisation that cannot be mounted into a running container — see Carrying changes across upgrades.

branding/
├── branding.config.ts        # names, URLs, login copy, legal metadata
├── theme.ts                  # MUI palette and typography
├── assets/
│   └── logo.svg              # header logo
└── legal/
    ├── termsAndConditions.mdx
    └── privacyPolicy.mdx

A TypeScript path alias makes it swappable — application code imports @branding/..., never a relative path:

// tsconfig.json
"paths": {
  "@/*": ["./src/*"],
  "@branding/*": ["./branding/*"]
}

Replace the directory's contents and the app is yours.

branding.config.ts

The shape is BrandingConfig in src/types/branding.ts. Here is a complete worked example for a fictional TRE:

import { BrandingConfig } from "@/types/branding";

const branding: BrandingConfig = {
  productName: "DataLink",
  productLongName: "Northern Research Alliance DataLink",
  description: "Cohort discovery for the Northern Research Alliance TRE",
  organisationName: "Northern Research Alliance",
  logoAlt: "Northern Research Alliance DataLink logo",
  loginTitle: ["Northern Research", "Alliance DataLink"],
  loginHeadline: ["Find your cohort.", "Faster, together."],
  loginSubheadline: "Secure cohort discovery for our research partners.",
  copyrightHolder: "Northern Research Alliance",
  hdrukChromeInStandalone: false,
  links: {
    organisation: "https://example.org",
    cookieNotice: "https://example.org/cookies",
    accessibilityStatement: "https://example.org/accessibility",
  },
  legal: {
    termsAndConditions: {
      title: "Terms and conditions",
      lastUpdated: "January 2026",
    },
    privacyPolicy: {
      title: "Privacy policy",
      lastUpdated: "January 2026",
    },
  },
};

export default branding;

What each field actually drives, and where:

Field Renders in Notes
productLongName <title> on every page (layout.tsx), and "<page> \| <productLongName>" on legal pages This is the browser tab text, not productName
productName Login page body copy ("Sign in to your X workspace"), legal-page source banner Short form, used in sentences
description <meta name="description">
organisationName Footer link label, "Visit the X site" Integrated mode only — not rendered in standalone
logoAlt alt on the header logo Accessibility only, not visible
loginTitle Two-line login heading (LoginLanding.tsx) Exactly two strings
loginHeadline / loginSubheadline Login brand panel (LoginBrandPanel.tsx) loginHeadline is exactly two strings
copyrightHolder Footer and login page copyright line Year is generated, not configured
hdrukChromeInStandalone See HDR UK chrome
links.organisation Fallback for the organisation URL when NEXT_PUBLIC_LOGIN_URL is unset Only a fallback — the env var wins
links.cookieNotice / links.accessibilityStatement Footer secondary links Standalone: shown only if set. Integrated: always shown, derived from the organisation URL
legal.*.title / lastUpdated Legal page heading and "Last updated" line
legal.*.sourceLabel Optional "this policy is sourced from X" banner Omit it once the text is yours

theme.ts

MUI ThemeOptions. There are three layers:

@hdruk/ui themeOptions          ← HDR UK brand palette, from the npm package
  ↓ deepmerge (app wins)
src/config/theme.ts             ← app-wide component and typography defaults
  ↓ deepmerge (branding wins)
branding/theme.ts               ← yours

Anything you set wins; anything you omit falls through — including to HDR UK's brand colours.

Set primary and secondary yourself

Neither src/config/theme.ts nor the shipped branding/theme.ts defines palette.primary or palette.secondary. Both fall through to @hdruk/ui:

primary: brandColors.seaGreen,       // #3db28c
secondary: brandColors.cornflowerBlue, // #475da7

So a deployment that replaces branding/theme.ts without setting them keeps HDR UK's sea green and cornflower blue — on buttons, and on the header app bar, which is filled with palette.secondary.main. Set both explicitly. Replacing the logo while leaving these alone is the most common way a rebrand ends up still looking like HDR UK.

A minimal override is valid — you do not need to respecify the whole palette:

import { ThemeOptions } from "@mui/material/styles";

const brandingTheme: ThemeOptions = {
  palette: {
    primary: { main: "#1b6ca8" },
    secondary: { main: "#13293d" },
    link: { main: "#1b6ca8" },
    text: { primary: "#22303c", secondary: "#6b7a86" },
  },
  typography: {
    fontFamily: '"Inter", sans-serif',
  },
};

export default brandingTheme;
Token Affects
palette.primary Primary buttons and accents. Falls through to HDR UK sea green if unset
palette.secondary Header app bar background — the logo sits on this. Falls through to HDR UK cornflower blue if unset
palette.link Inline links, anywhere color="link" is used
palette.success Confirmation banners, success chips
palette.table Table header and row accent
palette.text.primary / .secondary Body and muted text
palette.background.default / .paper Page background vs card surfaces
palette.tooltip Tooltip background
palette.yellowCustom, sage, secondaryBlack Component-specific accents — grep before assuming unused
typography.fontFamily Base font — see below

Changing the font takes two edits

theme.ts only tells MUI which font name to apply. The font is loaded in src/app/layout.tsx via next/font/google, which currently loads three and exposes them as CSS variables on <body>:

const sourceSans = Source_Sans_3({
  variable: "--font-source-sans-3",
  subsets: ["latin"],
  style: ["normal", "italic"],
  display: "swap",
});

To change the typeface, add your font there in the same pattern, include its variable in the <body className>, and set typography.fontFamily in branding/theme.ts to match. Editing only one leaves the font loaded but unused, or named but never fetched.

Logo and icons

branding/assets/logo.svg renders on the app bar, which is filled with palette.secondary.main — pick artwork that reads against your secondary colour.

The favicon and app icon are the one exception to "everything lives under branding/". Next.js resolves them by filename from the app directory, so they cannot be aliased:

File Replace in place
src/app/favicon.ico Browser tab icon
src/app/icon.svg PWA / app icon

In standalone mode /terms-and-conditions and /about/privacy-policy render MDX straight from branding/legal/. In integrated mode those links point at the Gateway instead.

{/* branding/legal/termsAndConditions.mdx */}

## Who we are

Northern Research Alliance DataLink is operated by the Northern Research
Alliance for federated cohort discovery across our member TREs.

## Using the service

By logging in you agree to use the service only for the research purposes
approved for your account, and not to attempt to identify individuals from
any cohort summary the platform returns.

## Contact

Questions about these terms: [legal@example.org](mailto:legal@example.org).

The source banner appears only when sourceLabel is set.

Replace the legal text before you deploy

branding/legal/ ships HDR UK's own terms and privacy policy, naming HDR UK as the data controller and giving its company number and contact details. Serving those unchanged from a deployment HDR UK does not operate is wrong, and very likely a data protection problem. Write your own, and clear sourceLabel so the "sourced from" banner disappears.

HDR UK chrome

The @hdruk/ui header and footer embed the Gateway logo and an HDR UK copyright line that branding/ cannot override. For a third-party deployment the default is already correct: leave hdrukChromeInStandalone at false and replace branding/assets/logo.svg.

Turning it on is all-or-nothing — the Gateway logo is rendered by the @hdruk/ui header itself, so you cannot take the two logos without also taking the Gateway footer. The hdruk-uk-theme feature flag that would otherwise disable the whole lot is not seeded today.