DEV Community

Roberto Luna
Roberto Luna

Posted on

Implementing a Global Branding Module in a NestJS + Next.js Monorepo

Implementing a Global Branding Module in a NestJS + Next.js Monorepo

TL;DR:

Added a branding_settings table and a dedicated NestJS controller to expose typography, background, banner, and footer options, then wired the settings into the Next.js admin UI. This gives the CRM a single source of truth for branding and demonstrates how to keep UI and API in sync across a monorepo.


The Problem

The existing CRM had a handful of hard‑coded brand assets (logo, colors). When the product team wanted to let users tweak typography, background images, banners, and footers, the database schema was missing the necessary columns, and the API lacked a clean endpoint to update these values. The symptom was a 404 on /api/branding and a 500 when attempting to PATCH settings that didn't exist in the DB.


What I Tried First

  1. Patch the existing branding.controller.ts – I added a PATCH route but it failed at compile time because the controller was missing the zod schema and the db.query helper.
  2. Add fields directly to the UI page – I inserted form controls into page.tsx but the POST request hit a 404 because the backend route was never registered.
  3. Modify the migration in place – I appended new columns to 20260728_branding_settings.sql but the migration order broke the CI pipeline; the table was already created with the old schema.

Each attempt exposed a deeper coupling between the API layer, the database schema, and the UI, making incremental fixes brittle.


The Implementation

1. Database Migration – 20260728_branding_settings.sql & 20260729_branding_settings_extended.sql

-- 20260728_branding_settings.sql
CREATE TABLE branding_settings (
  id SERIAL PRIMARY KEY,
  theme_name VARCHAR(64) NOT NULL DEFAULT 'default'
);

-- 20260729_branding_settings_extended.sql
ALTER TABLE branding_settings
ADD COLUMN typography JSONB DEFAULT '{}'::jsonb,
ADD COLUMN background_url VARCHAR(255),
ADD COLUMN banner_url VARCHAR(255),
ADD COLUMN footer_text TEXT;
Enter fullscreen mode Exit fullscreen mode

Why two migrations?

The first migration created the base table used by earlier features. The second migration extended it to hold the new UI controls without breaking existing deployments.

2. API Layer – apps/api/src/branding/branding.controller.ts

import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
import { z } from 'zod';
import { query as db } from '../db/db.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { RequirePerm } from '../auth/perm.decorator.js';

const BrandingSchema = z.object({
  typography: z.object({}).optional(),
  background_url: z.string().url().optional(),
  banner_url: z.string().url().optional(),
  footer_text: z.string().optional(),
});

@Controller('branding')
@UseGuards(AuthGuard)
export class BrandingController {
  @Get()
  async get() {
    const [row] = await db('SELECT * FROM branding_settings WHERE id = 1');
    return row;
  }

  @Patch()
  @RequirePerm('branding:manage')
  async patch(@Body() body: unknown) {
    const data = BrandingSchema.parse(body);
    await db`
      UPDATE branding_settings
      SET typography = ${JSON.stringify(data.typography)},
          background_url = ${data.background_url},
          banner_url = ${data.banner_url},
          footer_text = ${data.footer_text}
      WHERE id = 1
    `;
    return { success: true };
  }
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Single row (id = 1) keeps the module isolated from other data.
  • Zod provides runtime validation and clear error messages.
  • AuthGuard + RequirePerm enforce that only privileged users can mutate branding.

3. Registering the Controller – apps/api/src/app.module.ts

import { BrandingController } from './branding/branding.controller.js';

@Module({
  imports: [...],
  controllers: [
    ...,
    BrandingController,
  ],
  providers: [...],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Adding the controller here makes the route available in the API.

4. Frontend – apps/web/src/app/settings/branding/page.tsx

"use client";
import { useEffect, useState } from "react";
import { AuthGuard } from "../../_components/AuthGuard";
import { CrmShell } from "../../_components/CrmShell";

export default function BrandingPage() {
  const [settings, setSettings] = useState<Branding | null>(null);
  const [form, setForm] = useState<BrandingForm>({});

  useEffect(() => {
    fetch("/api/branding")
      .then(r => r.json())
      .then(setSettings);
  }, []);

  const handleSubmit = async () => {
    await fetch("/api/branding", {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(form),
    });
    // refetch to update UI
    const updated = await fetch("/api/branding").then(r => r.json());
    setSettings(updated);
  };

  if (!settings) return <div>Loading...</div>;

  return (
    <AuthGuard>
      <CrmShell>
        <h1>Branding Settings</h1>
        <form onSubmit={e => { e.preventDefault(); handleSubmit(); }}>
          {/* Typography */}
          <label>
            Typography (JSON)
            <textarea
              value={JSON.stringify(form.typography, null, 2)}
              onChange={e => setForm({ ...form, typography: JSON.parse(e.target.value) })}
            />
          </label>
          {/* Background */}
          <label>
            Background URL
            <input
              type="url"
              value={form.background_url || ""}
              onChange={e => setForm({ ...form, background_url: e.target.value })}
            />
          </label>
          {/* Banner */}
          <label>
            Banner URL
            <input
              type="url"
              value={form.banner_url || ""}
              onChange={e => setForm({ ...form, banner_url: e.target.value })}
            />
          </label>
          {/* Footer */}
          <label>
            Footer Text
            <input
              type="text"
              value={form.footer_text || ""}
              onChange={e => setForm({ ...form, footer_text: e.target.value })}
            />
          </label>
          <button type="submit">Save</button>
        </form>
      </CrmShell>
    </AuthGuard>
  );
}
Enter fullscreen mode Exit fullscreen mode

Highlights:

  • Uses React hooks to fetch and mutate data.
  • Wraps the page in AuthGuard and CrmShell for consistent layout and permissions.
  • The form is intentionally simple; validation is handled server‑side via Zod.

5. UI Shell Updates – CrmShell.tsx & CrmShellCondos.tsx

Both components now import a NAV_BRANDING_GROUP constant that includes the /settings/branding route. This ensures the new page appears in the sidebar for users with the branding:manage permission.

const NAV_BRANDING_GROUP: NavGroup = {
  group: "Branding",
  icon: "🖌️",
  items: [
    { href: "/settings/branding", label: "Branding", icon: "🖼️", perm: "branding:manage" },
  ],
};
Enter fullscreen mode Exit fullscreen mode

Key Takeaway

Keep configuration in a single, well‑typed table and expose it through a dedicated controller that validates input with Zod.

This pattern isolates branding from other modules, reduces coupling, and gives a clean API surface for the UI to consume. It also scales: adding more branding fields later is just a migration + a few form fields.


What's Next

1.


Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.

Repo: zaerohell/VS · 2026-07-29

#playadev #buildinpublic

Top comments (0)