Project case study

Mom's Kitchen Restaurant

A fast, mobile-first website for a home-style restaurant, built to turn visitors into phone and WhatsApp orders.

React 18 + TypeScriptVite (SWC)React Router 6Tailwind CSSshadcn/ui (Radix UI)Framer MotionReact Helmet AsyncSonnerReact Icons, LucideNetlify
Mom's Kitchen Restaurant screenshot

Objective

What this project sets out to do

Mom's Kitchen is a real, family-run restaurant that takes most of its orders by phone and WhatsApp. It needed a simple online presence that customers can find on Google, browse on their phones, and use to order in one tap - without the cost and complexity of a full ordering platform. The site presents the menu with prices and photos, the restaurant's story and services, and makes calling or messaging the kitchen the obvious next step on every page.

  • Show the full menu with photos and prices, filterable by category, so customers can decide before they call.
  • Make ordering effortless: one tap to WhatsApp with a pre-filled message, or to call.
  • Load fast on mid-range phones and mobile data, and rank for local searches with per-page SEO.
  • Present the restaurant warmly: its story, catering and event services, testimonials and location.

Features

What you can do in the app

  • Home page: hero with an "Order on WhatsApp" button, menu categories, features, services (catering, birthdays, weddings, events), an about preview and testimonials.
  • Menu page: 20+ dishes with photos, prices in ₹ and descriptions, filtered by All, Starters, Fast Food and Main Dishes with animated layout transitions.
  • One-tap ordering: a WhatsApp deep link with a pre-filled greeting, plus click-to-call buttons.
  • About and Contact pages: the restaurant's story, contact details, opening hours and an embedded Google Map.
  • Responsive navigation: a top contact bar on desktop and an animated slide-down menu on mobile.
  • Per-page SEO: unique titles and meta descriptions for every page, plus a sitemap, robots.txt and llms.txt.
  • Performance: lazy-loaded routes, WebP images with explicit dimensions and lazy loading, and year-long caching for static assets.

Tech stack

What it's built with, and why

Frontend

React 18 + TypeScript
Typed components, and a typed MenuItem model for the menu data.
Vite (SWC)
Fast builds and route-level code splitting.
React Router 6
Home, About, Menu and Contact routes, lazy-loaded with Suspense.
Tailwind CSS
Mobile-first layout with custom theme colors and serif headings for a warm look.
shadcn/ui (Radix UI)
Accessible, unstyled primitives for buttons, inputs and toasts.
Framer Motion
Entrance animations, the animated mobile menu and smooth menu filtering.

SEO and UX

React Helmet Async
Per-page <title> and meta description for search and sharing.
Sonner
Lightweight toast notifications.
React Icons, Lucide
WhatsApp, social and UI icons.

Hosting

Netlify
Custom domain (moms-kitchen.in), SPA fallback, and immutable cache headers for assets and images.

State management

How state is managed with local React state

A restaurant website has very little that changes while you use it, so the state is deliberately minimal and kept as close to where it is used as possible: no global store, no context for app data. The menu lives in a typed static module, each page owns its own small piece of UI state with useState, and anything that can be calculated - like the filtered menu - is derived on render rather than stored. Page-level concerns (SEO tags, scroll position) are handled by providers and small components instead of shared state.

The store

// main.tsx - app-wide providers, no data store
createRoot(document.getElementById("root")!).render(
  <HelmetProvider>          {/* per-page <title> and meta tags */}
    <App />
  </HelmetProvider>
);

// App.tsx - routes are code-split; ScrollToTop resets scroll per page
const Menu = lazy(() => import("./pages/Menu"));

<BrowserRouter>
  <ScrollToTop />
  <Suspense fallback={null}>
    <Routes>
      <Route path="/" element={<Index />} />
      <Route path="/menu" element={<Menu />} />
      {/* ...about, contact, 404 */}
    </Routes>
  </Suspense>
</BrowserRouter>

The slices

Menu data (static module)

A typed menuItems array: id, name, description, price, image and category.

import
Written by:
Developers, in data/menuItems.ts - updating the menu is a code change and a redeploy.
Read by:
The Menu page and the home page categories.

Menu filter (Menu page)

activeCategory: all, starters, main or fastfoods.

setActiveCategory
Written by:
The category buttons.
Read by:
The derived filteredItems list and the active button style.

Mobile menu (Header)

isMenuOpen - whether the mobile navigation is open.

setIsMenuOpen
Written by:
The menu toggle, and every link (closes the menu after navigating).
Read by:
AnimatePresence, which animates the menu in and out.

Route (React Router)

The current page and location.pathname.

<Link>navigate
Written by:
Navigation links.
Read by:
The active nav link style, and ScrollToTop, which scrolls up on every page change.

Document head (Helmet)

Each page's <title> and meta description.

<Helmet>
Written by:
Every page component.
Read by:
The browser tab, search engines and link previews.

How data flows

1. Menu filtering: one piece of state, everything else derived

The menu page stores only which category is selected. The visible dishes are calculated from the static menu on every render, so there is no second copy of the list to keep in sync. Framer Motion's layout animation makes cards slide into place as the filter changes.

  1. Click "Starters"

    setActiveCategory

  2. Re-render

    activeCategory changed

  3. Derive

    menuItems.filter(...)

  4. Animate

    motion layout reflows the grid

pages/Menu.tsx - store the choice, derive the list
type Category = "all" | "starters" | "main" | "fastfoods";

const [activeCategory, setActiveCategory] = useState<Category>("all");

// Derived on every render - never stored, so it can't go stale
const filteredItems =
  activeCategory === "all"
    ? menuItems
    : menuItems.filter((item) => item.category === activeCategory);

<motion.div layout className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
  {filteredItems.map((item) => <MenuCard key={item.id} item={item} />)}
</motion.div>

2. Menu as typed data, not a database

With about twenty dishes that change rarely, a backend would add cost and moving parts for no benefit. The menu is a typed TypeScript module: the compiler catches a missing price or a misspelled category, the images are bundled and optimized by Vite, and the whole menu ships with the page - no loading spinner.

  1. menuItems.ts

    typed MenuItem[]

  2. Vite build

    type-checked, images hashed

  3. Static bundle

    served from Netlify

  4. Menu page

    renders instantly

data/menuItems.ts - the shape every dish must have
export interface MenuItem {
  id: number;
  name: string;
  description: string;
  price: number;
  image: string;
  category: "starters" | "main" | "drinks" | "desserts" | "fastfoods";
}

export const menuItems: MenuItem[] = [
  {
    id: 11,
    name: "Chicken Pakoda",
    description: "Crispy deep-fried chicken bites with Indian spices.",
    price: 99,
    image: chickenPakoda,
    category: "starters",
  },
  // ...
];

3. Ordering: hand off to WhatsApp instead of building a cart

Instead of cart state, checkout and payments, the site hands the customer to the channel the restaurant already uses. The order button builds a WhatsApp deep link with a pre-filled, URL-encoded greeting and opens it in a new tab - one tap from the menu to a conversation with the kitchen.

  1. Order on WhatsApp

    button on the hero

  2. Build link

    wa.me/<number>?text=...

  3. Open WhatsApp

    new tab / app

  4. Chat with kitchen

    order confirmed by phone

components/home/Hero.tsx
const handleChatOrder = () => {
  const phoneNumber = "91XXXXXXXXXX"; // restaurant's WhatsApp number
  const message = "Hi Mom's Kitchen! 👋 I'd like to place a food order.";

  const whatsappUrl = `https://wa.me/${phoneNumber}?text=${encodeURIComponent(message)}`;
  window.open(whatsappUrl, "_blank", "noopener,noreferrer");
};

4. Page changes: SEO tags and scroll handled per route

Each page declares its own title and description with <Helmet>, which HelmetProvider writes into the document head - so every page has its own search snippet. A tiny ScrollToTop component watches the route and scrolls back to the top on every navigation, which a single-page app does not do by default.

  1. Click a nav link

    React Router

  2. Lazy page loads

    Suspense

  3. <Helmet>

    title + meta updated

  4. ScrollToTop

    smooth scroll to top

components/ScrollToTop.tsx
const ScrollToTop: React.FC = () => {
  const location = useLocation();

  useEffect(() => {
    window.scrollTo({ top: 0, left: 0, behavior: "smooth" });
  }, [location.pathname]);

  return null;
};

Design decisions

  • Why no Redux, Context or React Query for data? Nothing is shared between pages and nothing comes from a server at runtime. Adding a store would mean more code to maintain with no benefit - local useState keeps each piece of state next to the component that uses it.
  • Derive, don't duplicate. The filtered menu, the active nav link and the active category style are all computed from one source value, so they can never disagree.
  • Static, typed content. The menu is TypeScript data, which makes it safe to edit (the compiler checks every dish) and instant to load (no API call).
  • Use the channel customers already use. WhatsApp and phone links replace a cart and checkout, which keeps the site simple and matches how the restaurant actually takes orders.
  • Performance is part of the design. Code-split routes, WebP images with fixed dimensions (no layout shift) and long-lived cache headers make the site fast on mobile data.

See it in action

Try the live app or browse the full source on GitHub.