From vercel-shop
Replaces hardcoded nav and footer menus with dynamic Shopify-powered menus. Optionally adds multi-level megamenu for desktop hover and mobile accordion navigation.
npx claudepluginhub vercel/shop --plugin vercel-shopThis skill uses the workspace's default tool permissions.
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.
Provides best practices, UI/UX patterns, and guidance for ecommerce storefronts: checkout, cart, products, navigation, homepage. Integrates Medusa backend; framework-agnostic (Next.js, React, Vue).
Provides C# models and patterns for menu hierarchies, breadcrumbs, mega-menus, footer nav, mobile patterns, and headless CMS navigation APIs.
Builds custom functionality installable at defined points on Shopify customer account Order index, Order status, and Profile pages using UI extensions and Shopify CLI scaffolding.
Share bugs, ideas, or general feedback.
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.
Ask the user three questions in order:
Ask for each selected menu. Defaults: main-menu for nav, footer for footer.
A megamenu adds a multi-level category browser to the nav bar with:
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.
Skip this section if the user did not select the nav menu.
components/layout/nav/quick-links.tsxReplace 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 (
<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.
components/layout/nav/index.tsxSince QuickLinks is now async, wrap it in <Suspense> and pass locale:
<Suspense fallback={null}>
<QuickLinks locale={locale} />
</Suspense>
Skip this section if the user did not select the footer menu.
components/layout/footer.tsxAdd 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.
Skip this section if the user did not want a megamenu.
react-remove-scroll is installed (pnpm add react-remove-scroll)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: 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
}
pnpm add react-remove-scroll
lib/shopify/types/megamenu.tsDefine the four types (MegamenuCategory, MegamenuPanel, MegamenuItem, MegamenuData) exactly as shown in the data model above.
lib/shopify/operations/megamenu.tsFetch 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").
Create a directory components/layout/nav/megamenu/ with the following files:
menu-trigger-icon.tsxA simple SVG hamburger icon component:
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>
);
}
mouse-safe-area.tsxA 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 (
<div
ref={ref}
aria-hidden
style={{ position: "absolute", top: 0, zIndex: 10, display: "none" }}
/>
);
}
megamenu-panel.tsxRenders 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") ? (
<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>
);
}
megamenu-client.tsxThe desktop megamenu client component. This is the largest component and includes:
Key behavior:
Link (internal), <a> (external), or <button> (no href)data-active attribute for styling the active indicator dothrefThe 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.
megamenu-desktop.tsxA 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>;
}
megamenu-mobile.tsxThe mobile megamenu component using the shadcn Accordion component. Key differences from desktop:
Accordion (type="single", collapsible) for expand/collapseThe component accepts data: MegamenuData and optional children.
Export both MegamenuMobile and MegamenuMobileFallback (renders null).
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>
);
}
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}"
}
}
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.
The mobile megamenu requires the shadcn Accordion component. If it doesn't exist yet:
npx shadcn@latest add accordion
The bottom bar (components/layout/bottom-bar.tsx) should include a Browse button that toggles the megamenu on mobile. Add:
MenuTriggerIcon from ./nav/megamenu/menu-trigger-icon and X from lucide-reactconst [menuOpen, setMenuOpen] = useState(false)"megamenu" close events (in a useEffect)toggleMenu function that posts { type: "toggle" } on the "megamenu" BroadcastChannel<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" />
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 hrefsThen update components/collections/header.tsx and components/collections/structured-data.tsx to:
getMegamenuData and buildCollectionAncestorPathgetMegamenuData(locale) to their Promise.all callsbuildCollectionAncestorPath(handle, menu) to render ancestor breadcrumb segments before the current collection titlegetMenu() operation from lib/shopify/operations/menu.ts already handles caching ("use cache: remote", cacheTag("menus")) and URL transformation. Do not duplicate that logic.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).useTranslations("nav") — no hardcoded English text in components."megamenu" for cross-tab sync to work.http) must use <a> with target="_blank" and rel="noopener noreferrer". Internal links must use Next.js Link.md. Desktop uses hidden md:block.