A restaurant website needs to answer three questions fast: what's on the menu, when are you open, and how do I get a table. Next.js gives you server-side rendering for local SEO, image optimization for food photography, and server actions for handling reservation requests. This guide builds a complete restaurant site with an interactive menu, business hours, a reservation form, and an embedded Google Map.
Prerequisites
- →Node.js 20+
Next.js 15 requires Node.js 20 or later for the App Router and server components.
- →Google Maps API Key
A Google Cloud project with the Maps JavaScript API enabled. The free tier covers 28,000 map loads per month.
- →Restaurant Content Ready
Have your menu items, prices, photos, business hours, and address prepared before starting. Content is always the bottleneck.
- →Basic TypeScript Knowledge
Familiarity with TypeScript types, interfaces, and React component props.
Scaffold the Project and Define Data Types
Create a new Next.js app and define TypeScript types for your menu items, business hours, and restaurant info. Keeping all restaurant data in a single typed file makes it easy to update without touching components. This approach also makes it simple to switch to a CMS later.
// src/lib/restaurant.ts
export interface MenuItem {
name: string;
description: string;
price: number;
image?: string;
tags: ("vegetarian" | "vegan" | "gluten-free" | "spicy" | "popular")[];
category: string;
}
export interface BusinessHours {
day: string;
open: string;
close: string;
isClosed?: boolean;
}
export const restaurantInfo = {
name: "Bella Cucina",
tagline: "Authentic Italian cuisine in the heart of downtown",
address: "123 Main Street, Portland, OR 97201",
phone: "(503) 555-0123",
email: "hello@bellacucina.com",
coordinates: { lat: 45.5155, lng: -122.6789 },
};
export const hours: BusinessHours[] = [
{ day: "Monday", open: "11:00 AM", close: "9:00 PM" },
{ day: "Tuesday", open: "11:00 AM", close: "9:00 PM" },
{ day: "Wednesday", open: "11:00 AM", close: "9:00 PM" },
{ day: "Thursday", open: "11:00 AM", close: "10:00 PM" },
{ day: "Friday", open: "11:00 AM", close: "11:00 PM" },
{ day: "Saturday", open: "10:00 AM", close: "11:00 PM" },
{ day: "Sunday", open: "10:00 AM", close: "9:00 PM" },
];
export const menuItems: MenuItem[] = [
{
name: "Margherita Pizza",
description: "San Marzano tomatoes, fresh mozzarella, basil, extra virgin olive oil",
price: 16,
image: "/menu/margherita.jpg",
tags: ["vegetarian", "popular"],
category: "Pizza",
},
{
name: "Truffle Risotto",
description: "Arborio rice, black truffle, parmigiano-reggiano, white wine",
price: 24,
image: "/menu/risotto.jpg",
tags: ["vegetarian", "gluten-free"],
category: "Pasta & Risotto",
},
{
name: "Grilled Branzino",
description: "Mediterranean sea bass, lemon, capers, roasted vegetables",
price: 28,
image: "/menu/branzino.jpg",
tags: ["gluten-free"],
category: "Seafood",
},
];Tip: Group menu items by category in your data file — this maps directly to the sectioned menu layout.
Tip: Use descriptive tags like 'vegetarian' and 'gluten-free' so customers can filter the menu with dietary restrictions.
Build the Interactive Menu with Category Filters
Create a menu page that groups items by category with optional dietary filters. Each menu item shows the name, description, price, and dietary tags. Use the Next.js Image component for food photos — optimized images are critical for restaurant sites where visuals drive decisions.
// src/components/Menu.tsx
"use client";
import { useState, useMemo } from "react";
import Image from "next/image";
import { menuItems, type MenuItem } from "@/lib/restaurant";
const dietaryFilters = ["vegetarian", "vegan", "gluten-free", "spicy"] as const;
export default function Menu() {
const [activeFilter, setActiveFilter] = useState<string | null>(null);
const filteredItems = useMemo(() => {
if (!activeFilter) return menuItems;
return menuItems.filter((item) =>
item.tags.includes(activeFilter as MenuItem["tags"][number])
);
}, [activeFilter]);
const categories = useMemo(() => {
const grouped = new Map<string, MenuItem[]>();
for (const item of filteredItems) {
const existing = grouped.get(item.category) ?? [];
grouped.set(item.category, [...existing, item]);
}
return grouped;
}, [filteredItems]);
return (
<section className="px-6 py-16 md:px-16">
<h2 className="text-4xl font-bold text-white">Our Menu</h2>
<div className="mt-6 flex flex-wrap gap-2">
<button
onClick={() => setActiveFilter(null)}
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
!activeFilter
? "bg-amber-500 text-black"
: "border border-gray-600 text-gray-300 hover:border-amber-500"
}`}
>
All
</button>
{dietaryFilters.map((filter) => (
<button
key={filter}
onClick={() =>
setActiveFilter(activeFilter === filter ? null : filter)
}
className={`rounded-full px-4 py-2 text-sm font-medium capitalize transition-colors ${
activeFilter === filter
? "bg-amber-500 text-black"
: "border border-gray-600 text-gray-300 hover:border-amber-500"
}`}
>
{filter}
</button>
))}
</div>
{Array.from(categories).map(([category, items]) => (
<div key={category} className="mt-12">
<h3 className="text-2xl font-semibold text-amber-400">{category}</h3>
<div className="mt-6 grid gap-6 md:grid-cols-2">
{items.map((item) => (
<div
key={item.name}
className="flex gap-4 rounded-lg border border-gray-800 bg-gray-900/50 p-4"
>
{item.image && (
<Image
src={item.image}
alt={item.name}
width={100}
height={100}
className="h-24 w-24 rounded-lg object-cover"
/>
)}
<div className="flex-1">
<div className="flex items-start justify-between">
<h4 className="font-semibold text-white">{item.name}</h4>
<span className="font-mono text-amber-400">
${item.price}
</span>
</div>
<p className="mt-1 text-sm text-gray-400">
{item.description}
</p>
<div className="mt-2 flex gap-1">
{item.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-gray-800 px-2 py-0.5 text-xs text-gray-400"
>
{tag}
</span>
))}
</div>
</div>
</div>
))}
</div>
</div>
))}
</section>
);
}Tip: Compress food photos to WebP format before adding them — unoptimized images are the number one performance killer on restaurant sites.
Tip: Show prices without cents ($16 not $16.00) unless your menu has items with non-zero cents.
Add Business Hours with Open/Closed Status
Create a business hours component that shows the weekly schedule and highlights the current day. A helper function compares the current time against today's hours to display whether the restaurant is currently open or closed. This real-time status is one of the most-visited pieces of information on any restaurant site.
// src/components/Hours.tsx
"use client";
import { useMemo } from "react";
import { hours } from "@/lib/restaurant";
function isOpenNow(): { isOpen: boolean; closesAt?: string } {
const now = new Date();
const dayName = now.toLocaleDateString("en-US", { weekday: "long" });
const today = hours.find((h) => h.day === dayName);
if (!today || today.isClosed) return { isOpen: false };
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const parseTime = (time: string) => {
const [rawTime, period] = time.split(" ");
const [h, m] = rawTime.split(":").map(Number);
const hours24 = period === "PM" && h !== 12 ? h + 12 : period === "AM" && h === 12 ? 0 : h;
return hours24 * 60 + m;
};
const openMin = parseTime(today.open);
const closeMin = parseTime(today.close);
if (currentMinutes >= openMin && currentMinutes < closeMin) {
return { isOpen: true, closesAt: today.close };
}
return { isOpen: false };
}
export default function Hours() {
const status = useMemo(() => isOpenNow(), []);
const today = new Date().toLocaleDateString("en-US", { weekday: "long" });
return (
<section className="px-6 py-16 md:px-16">
<div className="flex items-center gap-3">
<h2 className="text-3xl font-bold text-white">Hours</h2>
<span
className={`rounded-full px-3 py-1 text-sm font-medium ${
status.isOpen
? "bg-green-500/10 text-green-400 border border-green-500/30"
: "bg-red-500/10 text-red-400 border border-red-500/30"
}`}
>
{status.isOpen ? `Open until ${status.closesAt}` : "Closed"}
</span>
</div>
<div className="mt-6 space-y-2">
{hours.map((h) => (
<div
key={h.day}
className={`flex justify-between rounded-lg px-4 py-2 ${
h.day === today ? "bg-amber-500/10 text-amber-400" : "text-gray-400"
}`}
>
<span className="font-medium">{h.day}</span>
<span className="font-mono text-sm">
{h.isClosed ? "Closed" : `${h.open} – ${h.close}`}
</span>
</div>
))}
</div>
</section>
);
}Tip: Use the restaurant's timezone, not the visitor's, when calculating open/closed status. Consider using Intl.DateTimeFormat with a fixed timezone.
Tip: Highlight the current day's row so visitors can find today's hours at a glance.
Build the Reservation Form with Server Actions
Create a reservation form using Next.js Server Actions. The form collects the guest name, email, phone, party size, date, and time. Server Actions handle validation and submission on the server without needing a separate API route. For production, connect this to a service like Resend for email confirmations.
// src/app/reservations/actions.ts
"use server";
export interface ReservationData {
name: string;
email: string;
phone: string;
partySize: number;
date: string;
time: string;
notes?: string;
}
export async function submitReservation(formData: FormData) {
const data: ReservationData = {
name: formData.get("name") as string,
email: formData.get("email") as string,
phone: formData.get("phone") as string,
partySize: parseInt(formData.get("partySize") as string, 10),
date: formData.get("date") as string,
time: formData.get("time") as string,
notes: (formData.get("notes") as string) || undefined,
};
if (!data.name || !data.email || !data.phone || !data.date || !data.time) {
return { error: "All required fields must be filled out." };
}
if (data.partySize < 1 || data.partySize > 20) {
return { error: "Party size must be between 1 and 20." };
}
const reservationDate = new Date(data.date);
if (reservationDate < new Date()) {
return { error: "Reservation date must be in the future." };
}
// In production, save to database and send confirmation email
console.log("Reservation received:", data);
return {
success: true,
message: `Table for ${data.partySize} confirmed on ${data.date} at ${data.time}. Confirmation sent to ${data.email}.`,
};
}Tip: Limit available reservation times to actual business hours — don't let someone book a 2 AM table.
Tip: Add a party size cap and show a message like 'For parties over 8, please call us directly' to handle large groups.
Embed Google Maps with Restaurant Location
Add an interactive Google Map showing the restaurant's location with a custom marker. Use the @vis.gl/react-google-maps package for a React-friendly wrapper around the Google Maps JavaScript API. The map includes the restaurant pin, address overlay, and a link to get directions.
// src/components/LocationMap.tsx
"use client";
import { APIProvider, Map, AdvancedMarker } from "@vis.gl/react-google-maps";
import { restaurantInfo } from "@/lib/restaurant";
export default function LocationMap() {
const { coordinates, name, address } = restaurantInfo;
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(address)}`;
return (
<section className="px-6 py-16 md:px-16">
<h2 className="text-3xl font-bold text-white">Find Us</h2>
<p className="mt-2 text-gray-400">{address}</p>
<div className="mt-6 h-[400px] overflow-hidden rounded-lg border border-gray-800">
<APIProvider apiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!}>
<Map
defaultCenter={coordinates}
defaultZoom={15}
mapId="restaurant-map"
gestureHandling="cooperative"
disableDefaultUI={false}
>
<AdvancedMarker position={coordinates} title={name} />
</Map>
</APIProvider>
</div>
<a
href={directionsUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-block rounded-lg bg-amber-500 px-6 py-3 font-medium text-black hover:bg-amber-400 transition-colors"
>
Get Directions
</a>
</section>
);
}Tip: Set gestureHandling to 'cooperative' so the map doesn't hijack scroll on mobile — users must two-finger scroll to interact with it.
Tip: Restrict your Google Maps API key to your domain in the Google Cloud Console to prevent unauthorized usage.
Add Restaurant JSON-LD for Local SEO
Add structured data using JSON-LD so Google displays your restaurant's hours, address, menu, and reviews directly in search results. The Restaurant schema type tells search engines exactly what your business is, where it's located, and when it's open. This is the single highest-impact SEO optimization for a local business.
// src/components/RestaurantJsonLd.tsx
import { restaurantInfo, hours } from "@/lib/restaurant";
export default function RestaurantJsonLd() {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Restaurant",
name: restaurantInfo.name,
description: restaurantInfo.tagline,
address: {
"@type": "PostalAddress",
streetAddress: "123 Main Street",
addressLocality: "Portland",
addressRegion: "OR",
postalCode: "97201",
addressCountry: "US",
},
telephone: restaurantInfo.phone,
email: restaurantInfo.email,
url: "https://bellacucina.com",
servesCuisine: "Italian",
priceRange: "$$",
geo: {
"@type": "GeoCoordinates",
latitude: restaurantInfo.coordinates.lat,
longitude: restaurantInfo.coordinates.lng,
},
openingHoursSpecification: hours
.filter((h) => !h.isClosed)
.map((h) => ({
"@type": "OpeningHoursSpecification",
dayOfWeek: h.day,
opens: h.open,
closes: h.close,
})),
hasMenu: {
"@type": "Menu",
url: "https://bellacucina.com/menu",
},
acceptsReservations: true,
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}Tip: Test your JSON-LD with Google's Rich Results Test tool before deploying.
Tip: Keep the priceRange field accurate — Google may show it directly in search results and wrong info erodes trust.
Deploy and Configure Environment Variables
Deploy to Vercel and set the Google Maps API key as an environment variable. Test the reservation form, map integration, and open/closed status indicator on the live site. Verify that JSON-LD structured data is picked up by Google's testing tools.
# Push to GitHub
git init
git add .
git commit -m "Restaurant website with menu, reservations, and map"
gh repo create bella-cucina --public --push
# Deploy to Vercel
npm install -g vercel
vercel
# Set environment variables
vercel env add NEXT_PUBLIC_GOOGLE_MAPS_KEY
vercel env add RESEND_API_KEY
# Deploy to production
vercel --prodTip: Test your site on Google PageSpeed Insights — restaurant customers are often on mobile with slow connections.
Tip: Submit your sitemap to Google Search Console to speed up indexing of your restaurant pages.
Next Steps
- →Add an online ordering system with a cart and Stripe payments for takeout orders.
- →Integrate with a CMS like Sanity so the restaurant owner can update the menu without developer help.
- →Add a photo gallery with a lightbox component showcasing the restaurant interior and dishes.
- →Set up Google Business Profile and link it to your website for maximum local search visibility.