Try it Free

How We Added Custom Branding to Shared Video Pages

You record a product walkthrough, share the link with a prospect, and they land on a page with someone else’s logo. Not yours — the tool’s. Every shared video becomes an ad for the recording tool instead of reinforcing your brand.

This is fine when you’re sharing internally. But the moment a video goes to a client, a prospect, or a partner, you want the page to look like it came from you. Your logo, your colors, your name.

We added custom branding to SendRec so the watch page reflects the sender’s brand instead of ours. Here’s how it works.

The three-layer merge

Branding could live in two places: the user’s account settings (applies to all their videos) or on a specific video (overrides the default for that one recording). We needed both, plus sensible defaults when neither is set.

The resolution is a three-layer cascade:

  1. SendRec defaults — navy background, green accent, SendRec logo
  2. User branding — stored in a user_branding table, overrides defaults for all of the user’s videos
  3. Video branding — nullable columns on the videos table, overrides user branding for a specific video
func resolveBranding(ctx context.Context, storage ObjectStorage,
    userBranding, videoBranding brandingSettingsResponse) brandingConfig {

    cfg := brandingConfig{
        CompanyName:     "SendRec",
        LogoURL:         "/images/logo.png",
        ColorBackground: "#0a1628",
        ColorSurface:    "#1e293b",
        ColorText:       "#ffffff",
        ColorAccent:     "#00b67a",
    }

    applyOverrides(&cfg, userBranding)
    applyOverrides(&cfg, videoBranding)

    // resolve logo URL from S3...
    return cfg
}

Each layer only overrides non-null fields. If you set a custom company name at the account level but leave colors alone, you get your name with SendRec’s color scheme. If you then set a custom background color on one specific video, that video gets the custom background while everything else inherits from your account settings.

The applyOverrides function is the same for both layers — it checks each field for non-null, non-empty values and overwrites the config. No special cases, no layer-specific logic.

What you can customize

Four things:

  • Company name — appears next to the logo and in the page title
  • Logo — PNG or SVG, max 512KB, uploaded to S3 via presigned URL
  • Colors — background, surface (cards/panels), text, and accent (buttons, links, progress bar)
  • Footer text — a line at the bottom of the watch page, above the “Shared via SendRec” attribution

Colors are validated as hex codes (#1a2b3c). We considered supporting named CSS colors but decided hex-only is simpler and avoids ambiguity. The frontend shows native color pickers with the hex value displayed alongside.

The logo problem

Logos seem simple until you consider the states:

  1. No branding set — show the SendRec logo (default)
  2. Custom logo uploaded — show the user’s logo from S3
  3. Logo explicitly hidden — show no logo at all

State 3 is the tricky one. A user might want their company name displayed without any logo image. But the database column is nullable, and NULL already means “use the default.” We needed a way to say “I’ve made a deliberate choice to have no logo.”

We used a sentinel value. The logo_key column stores one of three things:

  • NULL — no preference, inherit from the layer above or use SendRec’s logo
  • A valid S3 key like branding/user-id/logo.png — show this image
  • "none" — explicitly hide the logo
logoKey := resolveLogoKey(userBranding.LogoKey, videoBranding.LogoKey)
if logoKey == "none" {
    cfg.LogoURL = ""
    cfg.HasCustomLogo = true
} else if logoKey != "" {
    url, err := storage.GenerateDownloadURL(ctx, logoKey, 1*time.Hour)
    if err == nil {
        cfg.LogoURL = url
        cfg.HasCustomLogo = true
    }
}

The watch page template conditionally renders the logo image only if LogoURL is non-empty. The company name always shows.

Server-side rendering, not client-side theming

The watch page is a Go HTML template, not a React component. Colors are injected as CSS custom properties in a <style> block:

<style nonce="{{.CSPNonce}}">
    :root {
        --brand-bg: {{.Branding.ColorBackground}};
        --brand-surface: {{.Branding.ColorSurface}};
        --brand-text: {{.Branding.ColorText}};
        --brand-accent: {{.Branding.ColorAccent}};
    }
</style>

The template CSS references these variables everywhere it previously had hardcoded hex values. This means the colors are baked into the HTML on the server — no JavaScript runs to apply theming, no flash of default colors before the custom theme kicks in.

We kept secondary colors (muted text, borders) as fixed values rather than deriving them from the brand colors. Computing accessible contrast ratios and generating a full palette from four input colors is a rabbit hole. The four customizable colors cover the visual identity; the secondary tones are neutral enough to work with most brand palettes.

Feature gating

Custom branding is a premium feature. On the hosted platform, it requires a paid plan. For self-hosters, it’s unlocked with a single environment variable:

BRANDING_ENABLED=true

The API endpoints return 403 when branding is disabled. But the watch page rendering is not gated — if branding data exists in the database, the watch page renders it regardless of the flag. This handles the case where a user sets up branding, their subscription lapses, and their existing shared links should still look right. The gate prevents new branding changes, not viewing existing ones.

The frontend checks brandingEnabled in the limits API response. When disabled, the branding section in Settings shows the fields in a disabled state so users know the feature exists.

The data model

Two tables, seven columns each:

CREATE TABLE user_branding (
    user_id          UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    company_name     TEXT,
    logo_key         TEXT,
    color_background TEXT,
    color_surface    TEXT,
    color_text       TEXT,
    color_accent     TEXT,
    footer_text      TEXT,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE videos
    ADD COLUMN branding_company_name     TEXT,
    ADD COLUMN branding_color_background TEXT,
    ADD COLUMN branding_color_surface    TEXT,
    ADD COLUMN branding_color_text       TEXT,
    ADD COLUMN branding_color_accent     TEXT,
    ADD COLUMN branding_footer_text      TEXT;

All columns are nullable. This is important — null means “inherit,” not “empty.” An empty string would mean “the user explicitly set this to nothing,” which is a different intent. We use pointer types (*string) in Go to preserve the distinction.

The watch page query joins user_branding onto the video lookup:

SELECT v.*, ub.company_name, ub.logo_key, ub.color_background, ...
FROM videos v
LEFT JOIN user_branding ub ON ub.user_id = v.user_id
WHERE v.share_token = $1

One query gets the video, the user’s branding, and the video’s branding overrides. The Go code merges them with resolveBranding() before passing the result to the template.

What we didn’t build

Per-video logo upload. You can set a logo at the account level, but not per video. Most users want consistent branding across all their videos. If someone needs a different logo on one video, they can hide the logo for that video and mention the brand in the title.

Font customization. Custom fonts would require either hosting the font files or allowing external font URLs, which conflicts with our CSP and EU data residency principles. The system font stack works everywhere and loads instantly.

Full theme editor with preview. The Settings page shows a live mini-preview of how the colors look together, but there’s no “preview your actual watch page” mode. You can just share a video and open the link — the fastest way to see the real result.

Color contrast validation. We don’t warn users if their text color has poor contrast against their background. Adding WCAG contrast checking would be useful but adds complexity. Users can see the live preview and adjust if the combination looks unreadable.

Try it

SendRec is open source (AGPL-3.0) and self-hostable. Custom branding is live at app.sendrec.eu — go to Settings, upload your logo, pick your colors, and share a video to see your branded watch page. The implementation is in branding.go.