Skill v1.0.0
Trusted Publisher100/100version: "1.0.0" name: enable-shopify-menus description: Replace hardcoded nav and footer menus with Shopify-powered menus. Optionally add a megamenu component for multi-level navigation.
Enable Shopify Menus
By default, the storefront uses hardcoded navigation links and an empty footer. This skill replaces them with dynamic menus fetched from Shopify, and optionally adds a full-featured megamenu component.
Before you start
Ask the user three questions in order:
1. Which menus do you want to fetch from Shopify?
- Nav menu — replaces the hardcoded quick links in the header
- Footer menu — adds Shopify-powered footer columns
- Both
2. What are the Shopify menu handles?
Ask for each selected menu. Defaults: main-menu for nav, footer for footer.
3. Do you want to add a megamenu component?
A megamenu adds a multi-level category browser to the nav bar with:
- 3-level hierarchy (top-level items → subcategories → leaf links)
- Desktop hover interaction with mouse direction tracking
- Mobile accordion overlay via the bottom bar
- BroadcastChannel cross-tab sync
This requires a Shopify menu with up to 3 levels of nesting. The user can use the same nav menu handle or a separate one.
Wait for the user to answer all questions before proceeding.
Part A: Enable Shopify nav menu
Skip this section if the user did not select the nav menu.
A1. Update components/layout/nav/quick-links.tsx
Replace the hardcoded links array with a Shopify menu fetch. Make the component async:
import Link from "next/link";import { getMenu } from "@/lib/shopify/operations/menu";export async function QuickLinks({ locale }: { locale: string }) {const menu = await getMenu("NAV_HANDLE", locale);if (!menu || menu.items.length === 0) {return null;}return (<div className="hidden md:flex items-center gap-6">{menu.items.map((item) => {const isExternal = item.url.startsWith("http");if (isExternal) {return (<akey={item.id}href={item.url}target="_blank"rel="noopener noreferrer"className="flex items-center gap-1 text-sm hover:opacity-70 focus-visible:opacity-70 transition-opacity outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:rounded-sm">{item.title}</a>);}return (<Linkkey={item.id}href={item.url}className="flex items-center gap-1 text-sm hover:opacity-70 transition-opacity">{item.title}</Link>);})}</div>);}
Replace "NAV_HANDLE" with the handle the user provided.
A2. Update components/layout/nav/index.tsx
Since QuickLinks is now async, wrap it in <Suspense> and pass locale:
<Suspense fallback={null}><QuickLinks locale={locale} /></Suspense>
Part B: Enable Shopify footer menu
Skip this section if the user did not select the footer menu.
B1. Update components/layout/footer.tsx
Add the Shopify menu fetch back to the footer. Create a FooterContent async component:
import { getTranslations } from "next-intl/server";import Link from "next/link";import { connection } from "next/server";import { Suspense } from "react";import { getMenu } from "@/lib/shopify/operations/menu";const LINK_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground";function FooterLink({ title, url }: { title: string; url: string }) {const isExternal = url.startsWith("http");if (isExternal) {return (<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>{title}</a>);}return (<Link href={url} className={LINK_CLASS}>{title}</Link>);}function FooterHeading({ title, url }: { title: string; url: string }) {const isLinkable = url !== "/";if (isLinkable) {const isExternal = url.startsWith("http");if (isExternal) {return (<h3 className="text-sm font-semibold text-foreground"><a href={url} target="_blank" rel="noopener noreferrer" className="hover:underline">{title}</a></h3>);}return (<h3 className="text-sm font-semibold text-foreground"><Link href={url} className="hover:underline">{title}</Link></h3>);}return <h3 className="text-sm font-semibold text-foreground">{title}</h3>;}async function Copyright() {await connection();const t = await getTranslations("footer");return (<p className="text-sm text-muted-foreground">{t("copyright", { year: String(new Date().getFullYear()) })}</p>);}async function FooterContent({ locale }: { locale: string }) {const menu = await getMenu("FOOTER_HANDLE", locale);const hasMenu = menu && menu.items.length > 0;return (<footer className="bg-muted/30"><div className="mx-auto px-4 py-12 lg:px-8">{hasMenu ? (<div className="grid grid-cols-2 gap-8 md:grid-cols-3 lg:grid-cols-4">{menu.items.map((column) => (<div key={column.id}><FooterHeading title={column.title} url={column.url} />{column.items.length > 0 ? (<ul className="mt-4 space-y-3">{column.items.map((item) => (<li key={item.id}><FooterLink title={item.title} url={item.url} /></li>))}</ul>) : null}</div>))}</div>) : null}<div className={hasMenu ? "mt-12 border-t border-border/40 pt-8" : ""}><Suspense><Copyright /></Suspense></div></div></footer>);}export function Footer({ locale }: { locale: string }) {return (<Suspense fallback={null}><FooterContent locale={locale} /></Suspense>);}
Replace "FOOTER_HANDLE" with the handle the user provided.
Part C: Add megamenu component
Skip this section if the user did not want a megamenu.
Prerequisites
- A Shopify Navigation menu exists with up to 3 levels of nesting
react-remove-scrollis installed (pnpm add react-remove-scroll)
Data model
The megamenu transforms a Shopify 3-level nested menu into this hierarchy:
| Type | Level | Description | |
|---|---|---|---|
MegamenuItem | 1 | Top-level nav trigger (e.g. "Clothing") | |
MegamenuPanel | 2 | Subcategory grouping (e.g. "Tops") | |
MegamenuCategory | 3 | Leaf link (e.g. "T-Shirts") |
// MegamenuData{items: MegamenuItem[] // Top-level items shown in left column}// MegamenuItem{id: stringlabel: stringhref: string | nullpanels: MegamenuPanel[] // Subcategories shown in right column}// MegamenuPanel{id: stringheader: stringhref: string | nullcategories: MegamenuCategory[] // Leaf links}// MegamenuCategory{href: stringtitle: string}
C1. Install dependency
pnpm add react-remove-scroll
C2. Create lib/shopify/types/megamenu.ts
Define the four types (MegamenuCategory, MegamenuPanel, MegamenuItem, MegamenuData) exactly as shown in the data model above.
C3. Create lib/shopify/operations/megamenu.ts
Fetch the Shopify menu by handle and transform it into MegamenuData:
import { defaultLocale } from "@/lib/i18n";import type {MegamenuCategory,MegamenuData,MegamenuItem,MegamenuPanel,} from "../types/megamenu";import { getMenu } from "./menu";export async function getMegamenuData(locale: string = defaultLocale): Promise<MegamenuData> {const menu = await getMenu("MENU_HANDLE", locale);if (!menu || menu.items.length === 0) {return { items: [] };}const items: MegamenuItem[] = menu.items.map((topItem) => ({id: topItem.id,label: topItem.title,href: topItem.url,panels: topItem.items.map((subItem): MegamenuPanel => ({id: subItem.id,header: subItem.title,href: subItem.url || null,categories: subItem.items.map((child): MegamenuCategory => ({href: child.url,title: child.title,}),),}),),}));return { items };}
Replace "MENU_HANDLE" with the megamenu handle the user provided.
This relies on getMenu() from lib/shopify/operations/menu.ts which already exists and supports 3-level nesting with "use cache: remote", cacheLife("max"), and cacheTag("menus").
C4. Create megamenu components
Create a directory components/layout/nav/megamenu/ with the following files:
C4a. menu-trigger-icon.tsx
A simple SVG hamburger icon component:
import type { SVGProps } from "react";export function MenuTriggerIcon(props: SVGProps<SVGSVGElement>) {return (<svgdata-testid="geist-icon"height="16"width="16"viewBox="0 0 16 16"strokeLinejoin="round"style={{ color: "currentcolor" }}aria-hidden="true"{...props}><pathfillRule="evenodd"clipRule="evenodd"d="M1.75 4H1V5.5H1.75H14.25H15V4H14.25H1.75ZM1.75 10.5H1V12H1.75H14.25H15V10.5H14.25H1.75Z"fill="currentColor"/></svg>);}
C4b. mouse-safe-area.tsx
A UX utility that prevents accidental menu switches when moving diagonally toward the content panel. It creates an invisible clipped polygon between the trigger column and the panel:
"use client";import { type RefObject, useEffect, useRef } from "react";type Props = {parentRef: RefObject<HTMLDivElement | null>;};export function MouseSafeArea({ parentRef }: Props) {const ref = useRef<HTMLDivElement>(null);useEffect(() => {let rect: DOMRect | null = null;function updateRect() {rect = parentRef.current?.getBoundingClientRect() ?? null;}function handleMouseMove(e: MouseEvent) {const el = ref.current;if (!el || !rect) return;if (e.clientX >= rect.x) {el.style.display = "none";return;}const offset = e.clientX - rect.x;const mouseYPercent = ((e.clientY - rect.y) / rect.height) * 100;el.style.display = "";el.style.left = `${offset}px`;el.style.width = `${-offset}px`;el.style.height = `${rect.height}px`;el.style.clipPath = `polygon(100% 0%, 0% ${mouseYPercent}%, 100% 100%)`;}updateRect();document.addEventListener("mousemove", handleMouseMove);window.addEventListener("resize", updateRect);return () => {document.removeEventListener("mousemove", handleMouseMove);window.removeEventListener("resize", updateRect);};}, [parentRef]);return (<divref={ref}aria-hiddenstyle={{ position: "absolute", top: 0, zIndex: 10, display: "none" }}/>);}
C4c. megamenu-panel.tsx
Renders a single panel's header and category links. Supports both internal (Next.js Link) and external (<a>) links:
"use client";import Link from "next/link";import type { MegamenuPanel as MegamenuPanelType } from "@/lib/shopify/types/megamenu";type Props = {panel: MegamenuPanelType;fallbackHeader: string;onLinkClick?: () => void;};export function MegamenuPanel({ panel, fallbackHeader, onLinkClick }: Props) {return (<section className="min-w-0 space-y-5">{panel.href ? (<h4>{panel.href.startsWith("http") ? (<ahref={panel.href}target="_blank"rel="noopener noreferrer"onClick={onLinkClick}className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:rounded-sm">{panel.header || fallbackHeader}</a>) : (<Linkhref={panel.href}onClick={onLinkClick}className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:rounded-sm">{panel.header || fallbackHeader}</Link>)}</h4>) : (<h4 className="text-sm font-medium text-muted-foreground">{panel.header || fallbackHeader}</h4>)}<ul className="space-y-3">{panel.categories.map((category) => {const isExternal = category.href.startsWith("http");return (<li key={category.href}>{isExternal ? (<ahref={category.href}target="_blank"rel="noopener noreferrer"onClick={onLinkClick}className="block truncate text-base font-medium text-foreground transition-colors hover:text-foreground/80 outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:rounded-sm">{category.title}</a>) : (<Linkhref={category.href}onClick={onLinkClick}className="block truncate text-base font-medium text-foreground transition-colors hover:text-foreground/80 outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:rounded-sm">{category.title}</Link>)}</li>);})}</ul></section>);}
C4d. megamenu-client.tsx
The desktop megamenu client component. This is the largest component and includes:
- BroadcastChannel sync — cross-tab open/close/toggle coordination
- Hover intent detection — delays switching when mouse moves toward the panel (150ms), switches immediately otherwise
- Keyboard navigation — Escape to close
- RemoveScroll — prevents body scroll when menu is open
- Full-height overlay — backdrop blur with gradient
Key behavior:
- Top-level items render as
Link(internal),<a>(external), or<button>(no href) - Each item uses
data-activeattribute for styling the active indicator dot - Active item's panels render in the right column
- A "Show all {category}" link appears below the panels when the active item has an
href
The component accepts items: MegamenuItem[] and optional children (rendered in a footer below the nav list).
It uses translation keys from the nav namespace:
categories— trigger button labelexploreCategories— heading above the nav listshowAllCategory— "Show all {category}" link text (with{category}interpolation)
Export both MegamenuClient and MegamenuFallback from this file. The fallback renders a disabled-looking trigger with the hamburger icon and "Browse" label.
C4e. megamenu-desktop.tsx
A thin server component wrapper that renders MegamenuClient only when items are non-empty:
import type { MegamenuItem } from "@/lib/shopify/types/megamenu";import { MegamenuClient } from "./megamenu-client";type Props = {items: MegamenuItem[];children?: React.ReactNode;};export function MegamenuDesktop({ items, children }: Props) {if (!items.length) {return null;}return <MegamenuClient items={items}>{children}</MegamenuClient>;}
C4f. megamenu-mobile.tsx
The mobile megamenu component using the shadcn Accordion component. Key differences from desktop:
- Uses
Accordion(type="single",collapsible) for expand/collapse - Top-level items with sub-items get an accordion trigger; items with only an href get a plain link
- No hover intent — touch-only interaction
- Uses the same BroadcastChannel sync, RemoveScroll, and Escape key handling
- Panels render inline within the accordion content
The component accepts data: MegamenuData and optional children.
Export both MegamenuMobile and MegamenuMobileFallback (renders null).
C4g. index.tsx (barrel)
The main entry point. A server component that fetches data and renders both layouts:
import { Suspense } from "react";import { getMegamenuData } from "@/lib/shopify/operations/megamenu";import { MegamenuFallback } from "./megamenu-client";import { MegamenuDesktop } from "./megamenu-desktop";import { MegamenuMobile, MegamenuMobileFallback } from "./megamenu-mobile";type MegamenuProps = {locale: string;};async function MegamenuContent({ locale }: MegamenuProps) {const data = await getMegamenuData(locale);return (<><div className="hidden md:block"><MegamenuDesktop items={data.items} /></div><MegamenuMobile data={data} /></>);}function MegamenuCombinedFallback() {return (<><div className="hidden md:block"><MegamenuFallback /></div><MegamenuMobileFallback /></>);}export function Megamenu({ locale }: MegamenuProps) {return (<Suspense fallback={<MegamenuCombinedFallback />}><MegamenuContent locale={locale} /></Suspense>);}
C5. Add translation keys
Add the following keys to all locale files under lib/i18n/messages/ in the nav namespace (if not already present):
{"nav": {"categories": "Browse","exploreCategories": "Explore categories","showAllCategory": "Show all {category}"}}
C6. Wire into the nav
Import and render the Megamenu component in components/layout/nav/index.tsx, passing locale:
import { Megamenu } from "./megamenu";// Inside the nav bar, after the logo link:<Suspense fallback={null}><Megamenu locale={locale} /></Suspense>
Place it between the logo and the quick-links.
C7. Ensure the Accordion component exists
The mobile megamenu requires the shadcn Accordion component. If it doesn't exist yet:
npx shadcn@latest add accordion
C8. Add Browse toggle to the bottom bar
The bottom bar (components/layout/bottom-bar.tsx) should include a Browse button that toggles the megamenu on mobile. Add:
- Import
MenuTriggerIconfrom./nav/megamenu/menu-trigger-iconandXfromlucide-react - Add state:
const [menuOpen, setMenuOpen] = useState(false) - Add BroadcastChannel listener for
"megamenu"close events (in auseEffect) - Add a
toggleMenufunction that posts{ type: "toggle" }on the"megamenu"BroadcastChannel - Add the toggle button before the search button in the bottom bar:
<buttontype="button"className="flex md:hidden items-center gap-1.5 px-2 py-1"onClick={toggleMenu}>{menuOpen ? (<X className="size-4 text-foreground opacity-50" />) : (<MenuTriggerIcon className="size-4 text-foreground opacity-50" />)}<span className="text-xs font-medium text-foreground opacity-50">Browse</span></button><div className="w-px h-5 bg-border/50 md:hidden" />
C9. Add collection breadcrumb ancestor paths (optional)
To show rich breadcrumbs on collection pages (e.g. Home / Clothing / Tops / T-Shirts), create lib/utils/breadcrumbs.ts with:
buildCollectionAncestorPath(handle, menu)— walks the megamenu tree to find a collection by its/collections/{handle}href and returns ancestor segmentsbuildProductCategoryPath(category, menu, collectionHandles?)— finds the deepest menu path for a product by matching its collection handles against megamenu hrefs
Then update components/collections/header.tsx and components/collections/structured-data.tsx to:
- Import
getMegamenuDataandbuildCollectionAncestorPath - Add
getMegamenuData(locale)to theirPromise.allcalls - Use
buildCollectionAncestorPath(handle, menu)to render ancestor breadcrumb segments before the current collection title
Guardrails
- The
getMenu()operation fromlib/shopify/operations/menu.tsalready handles caching ("use cache: remote",cacheTag("menus")) and URL transformation. Do not duplicate that logic. - Components in
components/layout/nav/megamenu/may import from@/lib/shopify/types/megamenufor prop types, but must not call Shopify operations directly — data fetching happens in the server component barrel (index.tsx). - All user-visible strings must use
useTranslations("nav")— no hardcoded English text in components. - The BroadcastChannel name must be
"megamenu"for cross-tab sync to work. - External links (starting with
http) must use<a>withtarget="_blank"andrel="noopener noreferrer". Internal links must use Next.jsLink. - The mobile component renders on all viewports but is only visible below
md. Desktop useshidden md:block.