diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index fae8462eaf..fa13de7ccb 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -9,6 +9,7 @@ import {
import { useEffect, useRef, useState } from "react";
import { SearchTrigger } from "@/components/search";
+import { useBranding } from "@/contexts/BrandingProvider";
import { useDocsDrawer } from "@/hooks/use-docs-drawer";
import { useHandbookDrawer } from "@/hooks/use-handbook-drawer";
import { getPlatformCTA, usePlatform } from "@/hooks/use-platform";
@@ -243,12 +244,14 @@ function DrawerButton({
}
function Logo() {
+ const branding = useBranding();
+
return (
-
+
);
}
diff --git a/apps/web/src/contexts/BrandingProvider.tsx b/apps/web/src/contexts/BrandingProvider.tsx
new file mode 100644
index 0000000000..665059f89c
--- /dev/null
+++ b/apps/web/src/contexts/BrandingProvider.tsx
@@ -0,0 +1,67 @@
+import { createContext, type ReactNode, useContext, useMemo } from "react";
+
+import { type Brand, BRANDING, detectBrand } from "@/lib/branding";
+
+interface BrandingContextValue {
+ brand: Brand;
+ logo: string;
+ productName: string;
+ domain: string;
+ twitterHandle: string;
+ ogImage: string;
+}
+
+const BrandingContext = createContext(null);
+
+interface BrandingProviderProps {
+ hostname: string;
+ children: ReactNode;
+}
+
+/**
+ * Provides branding configuration based on the current domain.
+ * Should be initialized with the hostname from the request or window.location.
+ */
+export function BrandingProvider({
+ hostname,
+ children,
+}: BrandingProviderProps) {
+ const brand = detectBrand(hostname);
+ const branding = BRANDING[brand];
+
+ const value = useMemo(
+ () => ({
+ brand,
+ logo: branding.logo,
+ productName: branding.productName,
+ domain: branding.domain,
+ twitterHandle: branding.twitterHandle,
+ ogImage: branding.ogImage,
+ }),
+ [brand, branding],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Hook to access branding configuration.
+ *
+ * Returns an object with stable primitive values. Components only re-render
+ * when the brand changes, not on every context update. Destructure only the
+ * values you need:
+ *
+ * @example
+ * const { logo, productName } = useBranding();
+ */
+export function useBranding(): BrandingContextValue {
+ const context = useContext(BrandingContext);
+ if (!context) {
+ throw new Error("useBranding must be used within BrandingProvider");
+ }
+ return context;
+}
diff --git a/apps/web/src/lib/branding.ts b/apps/web/src/lib/branding.ts
new file mode 100644
index 0000000000..c95d709236
--- /dev/null
+++ b/apps/web/src/lib/branding.ts
@@ -0,0 +1,73 @@
+/**
+ * Branding System
+ *
+ * This module provides domain-based branding configuration.
+ */
+import { env } from "@/env";
+
+export type Brand = "hyprnote" | "char";
+
+export interface BrandingConfig {
+ logo: string;
+ productName: string;
+ domain: string;
+ twitterHandle: string;
+ ogImage: string;
+}
+
+export const BRANDING: Record = {
+ hyprnote: {
+ logo: "/api/images/hyprnote/logo.svg",
+ productName: "Hyprnote",
+ domain: "hyprnote.com",
+ twitterHandle: "@tryhyprnote",
+ ogImage: "/api/images/hyprnote/og-image.jpg",
+ },
+ char: {
+ logo: "/api/images/char/logo.svg",
+ productName: "Char",
+ domain: "char.com",
+ twitterHandle: "@trychar",
+ ogImage: "/api/images/char/og-image.jpg",
+ },
+};
+
+/**
+ * Detect brand from hostname - works server-side and client-side
+ */
+export function detectBrand(hostname: string): Brand {
+ const normalized = hostname.toLowerCase();
+
+ if (normalized.includes("char.com")) {
+ return "char";
+ }
+
+ // Default to hyprnote (including localhost, staging, etc.)
+ return "hyprnote";
+}
+
+/**
+ * Get branding config from hostname
+ */
+export function getBrandingFromHostname(hostname: string): BrandingConfig {
+ const brand = detectBrand(hostname);
+ return BRANDING[brand];
+}
+
+/**
+ * Get current hostname - works in browser and during SSR
+ * Uses VITE_APP_URL as fallback during SSR to avoid hydration mismatch
+ */
+export function getCurrentHostname(): string {
+ if (typeof window !== "undefined") {
+ return window.location.hostname;
+ }
+
+ // During SSR, extract hostname from VITE_APP_URL to avoid hydration mismatch
+ try {
+ const url = new URL(env.VITE_APP_URL);
+ return url.hostname;
+ } catch {
+ return "";
+ }
+}
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index 20b4accb3b..1557755657 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -4,7 +4,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRouter } from "@tanstack/react-router";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
+import { BrandingProvider } from "./contexts/BrandingProvider";
import { env } from "./env";
+import { getCurrentHostname } from "./lib/branding";
import { PostHogProvider } from "./providers/posthog";
import { routeTree } from "./routeTree.gen";
@@ -18,14 +20,20 @@ export function getRouter() {
scrollRestoration: true,
trailingSlash: "always",
Wrap: (props: { children: React.ReactNode }) => {
+ const hostname = getCurrentHostname();
return (
-
-
-
- {props.children}
-
-
-
+
+
+
+
+ {props.children}
+
+
+
+
);
},
});