Replacing a Static Sidebar with a Dynamic Top Navigation + Mega Menu in VibeCoding CRM
TL;DR:
We swapped the legacy sidebar for a responsive top nav with a mega‑menu in the VibeCoding CRM. The new structure pulls navigation data from a single source, tracks user clicks to surface a “Más usados” section, and cleans up duplicated items. This change improves UX, reduces bundle size, and aligns the UI with modern design patterns.
The Problem
The original CRM layout used a left‑hand sidebar (CrmShell.tsx) that was hard‑coded, duplicated across pages, and didn’t adapt to mobile. It also lacked any dynamic ordering of frequently used items. The UI felt stale, and we were shipping the same navigation code twice (once in CrmShell.tsx and again in CrmShellCondos.tsx). The symptom was a sluggish menu that didn’t reflect real user behavior.
Error messages? None. The issue was purely UX: the sidebar was a static list, and users couldn’t easily discover the most relevant sections.
What I Tried First
I started by adding a simple toggle to collapse the sidebar into a hamburger menu. This required minimal changes to the existing component:
// apps/web/src/app/_components/CrmShell.tsx
const SIDEBAR_W = 2; // kept for backward compatibility
But the toggle broke the layout on mobile, and we still had duplicated navigation data. I then experimented with a separate component for the mega‑menu, but it was still tightly coupled to the sidebar logic.
The first attempt failed because it didn’t remove the underlying duplication or introduce a data‑driven navigation source. The menu still looked like a copy‑paste of the sidebar.
The Implementation
1. Removing the Sidebar and Introducing a Top Nav
We removed the SIDEBAR_W constant and the entire sidebar rendering logic. Instead, we added a <TopNav> component that renders a horizontal list of groups and items. The new component pulls its data from a single NAV_* definition.
// apps/web/src/app/_components/CrmShell.tsx
// Removed: const SIDEBAR_W = 2
// New top navigation
export function CrmShell({ children, context = "rentas" }: { children: React.ReactNode; context?: string }) {
// ... existing imports and helper functions
// Navigation groups
const NAV_GROUPS: NavGroup[] = [
{ group: "Ventas", icon: "💰", items: NAV_VENTAS_GROUPS },
{ group: "Rentas", icon: "🏠", items: NAV_RENTAS_GROUPS },
// Condominios group is now in CrmShellCondos.tsx
];
// Render top nav
return (
<div className="crm-shell">
<nav className="top-nav">
{NAV_GROUPS.map((g) => (
<MegaMenu key={g.group} group={g} />
))}
</nav>
<main>{children}</main>
</div>
);
}
The MegaMenu component receives a NavGroup and renders a dropdown with its items. This keeps the UI logic separate from data.
2. Consolidating Navigation Data
Both CrmShell.tsx and CrmShellCondos.tsx now reference the same NAV_* arrays. The NAV_RENTAS_MAIN and NAV_RENTAS_GROUPS definitions were moved into a shared file (nav.ts), eliminating duplication.
// apps/web/src/app/_components/nav.ts
export const NAV_RENTAS_MAIN: NavItem[] = [
{ href: "/dashboard/hoy", label: "Hoy", icon: "◉" },
{ href: "/dashboard/mes", label: "Mes", icon: "📆" },
// ...
];
export const NAV_RENTAS_GROUPS: NavItem[] = [
// ... same items but grouped
];
3. Dynamic “Más usados” Section
We added a click counter that tracks how often each menu item is selected. The counter is persisted in localStorage and re‑used to order the “Más usados” group.
// apps/web/src/app/_components/CrmShell.tsx
const [clickCounts, setClickCounts] = useState<{ [key: string]: number }>(() => {
const stored = localStorage.getItem("navClicks");
return stored ? JSON.parse(stored) : {};
});
function handleItemClick(item: NavItem) {
const key = item.href;
const newCounts = { ...clickCounts, [key]: (clickCounts[key] || 0) + 1 };
setClickCounts(newCounts);
localStorage.setItem("navClicks", JSON.stringify(newCounts));
router.push(item.href);
}
The “Más usados” group is now generated at runtime:
// apps/web/src/app/_components/CrmShell.tsx
const mostUsed = Object.entries(clickCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([href]) => NAV_RENTAS_MAIN.find((i) => i.href === href))
.filter(Boolean);
const NAV_GROUPS = [
{ group: "Más usados", icon: "⭐", items: mostUsed },
// other groups...
];
4. CSS Enhancements
The sidebar used a hover‑only star icon for favorites. We moved that style to the top nav and made it visible on hover or if the item is marked as a favorite.
/* apps/web/src/app/typography.css */
.sidebar .star {
opacity: 0;
transition: opacity 0.2s;
}
.sidebar:hover .star,
.sidebar .favorite {
opacity: 1;
}
We kept the CSS minimal and scoped to the new component class names.
5. Removing Duplicates and Hierarchy Cleanup
The refactor commit 732a028b removed duplicated items from the NAV_MAIN array and established a clear parent‑child hierarchy for the mega menu.
// apps/web/src/app/_components/CrmShell.tsx
const NAV_MAIN: NavItem[] = [
{ href: "/dashboard/hoy", label: "Hoy", icon: "◉" },
{ href: "/dashboard/mes", label: "Mes", icon: "📆" },
// Removed duplicates like "/dashboard/hoy" appearing twice
];
The canSee helper now checks user permissions once per group instead of per item, reducing rendering overhead.
Key Takeaway
Centralize navigation data and render UI from that single source.
By moving all nav definitions into a shared module and feeding them into a top‑nav component, we eliminated duplication, made the menu data‑driven, and opened the door for dynamic features like click‑counting. This pattern scales: you can add new groups or items without touching the UI logic, and the UI will automatically adapt.
What's Next
-
Persist click counts on the server – Move the
navClicksstate to a lightweight GraphQL mutation so that usage data is shared across devices. - Add a dark mode toggle – Extend the top nav to include a theme switch that updates CSS variables.
- Accessibility audit – Ensure the mega‑menu is fully keyboard‑navigable and screen‑reader friendly.
Tags: #vibecoding #buildinpublic #react #typescript #nextjs #frontend #ui #engineering
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
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-31
#playadev #buildinpublic
Top comments (0)