DEV Community

Cover image for How I Built White-Label Document Sharing for Client-Facing Docs
Howard Shaw
Howard Shaw

Posted on

How I Built White-Label Document Sharing for Client-Facing Docs

When people send proposals, pitch decks, NDAs, and other client-facing documents, they usually want the recipient focused on their brand, not the software powering the experience.

That was the motivation behind a feature I recently shipped in DocBeacon: white-label document sharing for Pro users.

The idea sounds simple:

Let users attach a custom brand name, logo, and website URL to shared documents.

But once I got into implementation, it turned into an interesting mix of product design, access control, storage, validation, and fallback behavior.

This post is a short breakdown of how I approached it.

The problem

Before this feature, a shared document was still visually branded as DocBeacon throughout the viewing flow.

That was fine for some use cases, but not ideal for teams sending:

consulting proposals
investor decks
sales documents
sensitive client materials

In those workflows, brand consistency matters. If someone is sharing a high-value proposal, they usually want the experience to reinforce their credibility, not mine.

So the requirement became:

Let eligible users show their own branding across the document viewing experience, while keeping the system safe and predictable.

What the feature needed to support

At a high level, I wanted users to be able to configure:

a display name
a logo
a website URL
an on/off toggle for branding

And once enabled, that branding should appear in places like:

the document viewer header
attribution text
shared document metadata
other client-facing viewer touchpoints

I also wanted a clean fallback path. If branding data was incomplete, invalid, or the user’s plan didn’t allow the feature, the system should quietly fall back to the default DocBeacon brand.

The core design decision: resolve branding once

One of the first decisions I made was to avoid scattering branding logic across multiple rendering layers.

Instead of asking every page or component to figure out branding on its own, I used a single resolution step:

Check whether the user is allowed to use the feature
Check whether branding is enabled
Check whether enough valid branding data exists
Return either:
the user’s brand config, or
the default platform brand

In pseudocode:

**function resolveBranding(user):
    canBrand = planAllowsBranding(user.plan)
    hasIdentity = user.displayName OR user.logoPath
    enabled = user.brandingEnabled AND canBrand AND hasIdentity

    if enabled:
        return {
            name: user.displayName,
            logoUrl: buildAssetUrl(user.logoPath),
            websiteUrl: normalizeUrl(user.websiteUrl)
        }

    return defaultBrand**
Enter fullscreen mode Exit fullscreen mode

This helped keep the rest of the system simple. Viewer pages didn’t need to know all the rules. They just consumed a resolved branding object.

Access control

This feature is only available on Pro+ plans, so access control had to be built into the feature itself, not just the UI.

That means even if someone somehow submits branding settings through an API call or stale client state, the backend still needs to enforce eligibility.

The check is conceptually simple:

canUseBranding(user):
    return user.plan in ["pro", "business", "enterprise"]
Enter fullscreen mode Exit fullscreen mode

I treated this as a server-side rule first, and a UI rule second.

That’s important because frontend gating alone is never enough for plan-restricted features.

Asset storage for logos

For logo uploads, I wanted a storage layer that was simple, cheap, and fast to serve publicly.

I used Cloudflare R2 for uploaded branding assets.

The flow looks roughly like this:

User uploads a PNG or JPG
Backend validates file type and size
File is stored under a user-scoped path
The app stores only the storage path in the user record
A helper builds the public asset URL when needed

In pseudocode:

uploadLogo(file, user):
    assert file.type in ["image/png", "image/jpeg"]
    assert file.size <= MAX_LOGO_SIZE

    path = "branding/" + user.id + "/" + generateFileName(file)
    storage.put(path, file)

    saveUserBranding(user.id, { logoPath: path })
Enter fullscreen mode Exit fullscreen mode

And later:

buildAssetUrl(path):
    return CDN_BASE_URL + "/" + path
Enter fullscreen mode Exit fullscreen mode

I preferred storing the path rather than a fully expanded URL in the database, because it keeps storage concerns separate from delivery concerns.

URL validation was the trickiest part

The hardest part of this feature was not rendering a logo.

It was letting users attach a website URL safely.

On the surface, “enter your website” sounds trivial. In practice, it needs guardrails.

I did not want to allow:

localhost
private network IPs
credential-based URLs like user:pass@example.com
unsafe schemes like javascript: or file:

So I treated URL handling as a normalization + validation pipeline.

The job of that pipeline is:

parse the input
normalize format
require http or https
reject unsafe hosts
reject credentials
return either a safe URL or null

In pseudocode:

normalizeUrl(input):
    if input is empty:
        return null

    parsed = parseUrl(input)

    if parsed.scheme not in ["http", "https"]:
        return null

    if parsed.hasCredentials:
        return null

    if isLocalhost(parsed.host) or isPrivateIp(parsed.host):
        return null

    return parsed.normalizedHref
Enter fullscreen mode Exit fullscreen mode

This kind of defensive validation matters because branding fields are user-controlled input, and user-controlled input always deserves scrutiny.

Fallback behavior matters more than people think

A lot of polish in a feature like this comes from handling invalid states well.

For example:

branding is toggled on, but no logo is uploaded yet
the user entered an invalid URL
the user downgraded from Pro to Free
an asset was deleted or unavailable
branding data is partially filled out

In all of those cases, I wanted the document experience to remain stable.

So instead of letting the UI break or showing half-configured branding, I default to the platform brand whenever the custom brand can’t be resolved safely.

That gives the system a predictable contract:

Either show a fully valid custom brand, or show the default brand.

No broken middle state.

Why I like this implementation

What I like about this feature is that it makes the product less visible in the right places.

For this use case, that’s the goal.

The product should support the sender’s identity, not compete with it.

From an engineering point of view, I also like that the system is built around a few clear principles:

central branding resolution
server-side plan enforcement
explicit URL normalization
safe asset handling
reliable fallback behavior

The final result feels simple to the user, which usually means the internal rules are doing their job.

What I’d improve next

A few things I’d likely add next:

custom domains for shared links
better branding previews in settings
image processing for uploaded logos
audit logging for branding changes
more granular per-document branding controls

That would take it from “account-level branding” to a more flexible identity layer.

Final thought

This started as a branding feature, but it ended up being a good reminder that “simple UX” often sits on top of a lot of backend discipline.

If you’re building anything user-configurable and client-facing, the hard part usually isn’t just rendering the settings.

It’s defining the rules for what’s allowed, what’s safe, and what happens when the input is incomplete.

If you’re curious, the product is DocBeacon: https://docbeacon.io

Top comments (0)