Shopify Menus

Replace hardcoded nav and footer menus with Shopify-powered menus, and optionally add a megamenu component.

How to use

bash
/vercel-shop:enable-shopify-menus

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:

tsx
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 (
            <a
              key={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 (
          <Link
            key={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:

tsx
<Suspense fallback={null}>
  <QuickLinks locale={locale} />
</Suspense>

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:

tsx
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-scroll is installed (pnpm add react-remove-scroll)

Data model

The megamenu transforms a Shopify 3-level nested menu into this hierarchy:

TypeLevelDescription
MegamenuItem1Top-level nav trigger (e.g. "Clothing")
MegamenuPanel2Subcategory grouping (e.g. "Tops")
MegamenuCategory3Leaf link (e.g. "T-Shirts")
ts
// MegamenuData
{
  items: MegamenuItem[] // Top-level items shown in left column
}

// MegamenuItem
{
  id: string
  label: string
  href: string | null
  panels: MegamenuPanel[] // Subcategories shown in right column
}

// MegamenuPanel
{
  id: string
  header: string
  href: string | null
  categories: MegamenuCategory[] // Leaf links
}

// MegamenuCategory
{
  href: string
  title: string
}

C1. Install dependency

bash
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:

ts
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:

tsx
import type { SVGProps } from "react";

export function MenuTriggerIcon(props: SVGProps<SVGSVGElement>) {
  return (
    <svg
      data-testid="geist-icon"
      height="16"
      width="16"
      viewBox="0 0 16 16"
      strokeLinejoin="round"
      style={{ color: "currentcolor" }}
      aria-hidden="true"
      {...props}
    >
      <path
        fillRule="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:

tsx
"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 (
    <div
      ref={ref}
      aria-hidden
      style={{ 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:

tsx
"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") ? (
            <a
              href={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>
          ) : (
            <Link
              href={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 ? (
                <a
                  href={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>
              ) : (
                <Link
                  href={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-active attribute for styling the active indicator dot
  • Active item's panels render in the right column
  • A "Show all " 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 label
  • exploreCategories — heading above the nav list
  • showAllCategory — "Show all " 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:

tsx
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:

tsx
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):

json
{
  "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:

tsx
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:

bash
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:

  1. Import MenuTriggerIcon from ./nav/megamenu/menu-trigger-icon and X from lucide-react
  2. Add state: const [menuOpen, setMenuOpen] = useState(false)
  3. Add BroadcastChannel listener for "megamenu" close events (in a useEffect)
  4. Add a toggleMenu function that posts { type: "toggle" } on the "megamenu" BroadcastChannel
  5. Add the toggle button before the search button in the bottom bar:
tsx
<button
  type="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 segments
  • buildProductCategoryPath(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:

  1. Import getMegamenuData and buildCollectionAncestorPath
  2. Add getMegamenuData(locale) to their Promise.all calls
  3. Use buildCollectionAncestorPath(handle, menu) to render ancestor breadcrumb segments before the current collection title

Guardrails

  • The getMenu() operation from lib/shopify/operations/menu.ts already 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/megamenu for 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> with target="_blank" and rel="noopener noreferrer". Internal links must use Next.js Link.
  • The mobile component renders on all viewports but is only visible below md. Desktop uses hidden md:block.

Chat

Tip: You can open and close chat with I

0 / 1000