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
-
Patch the existing
branding.controller.ts– I added a PATCH route but it failed at compile time because the controller was missing thezodschema and thedb.queryhelper. -
Add fields directly to the UI page – I inserted form controls into
page.tsxbut the POST request hit a 404 because the backend route was never registered. -
Modify the migration in place – I appended new columns to
20260728_branding_settings.sqlbut 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;
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 };
}
}
Key points:
-
Single row (
id = 1) keeps the module isolated from other data. - Zod provides runtime validation and clear error messages.
-
AuthGuard +
RequirePermenforce 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 {}
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>
);
}
Highlights:
- Uses React hooks to fetch and mutate data.
- Wraps the page in
AuthGuardandCrmShellfor 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" },
],
};
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)