Metadata and SEO for a storefront that must rank
A shop nobody can find does not sell anything. For a kirana store competing against large sites, organic search is the realistic route to customers — and most of what matters is decided by how the pages are built.
Why server rendering matters here
Google renders JavaScript, eventually. It queues pages for a second pass, which can take days, and other crawlers — social previews, WhatsApp link previews, Bing — often do not run JavaScript at all.
A product page rendered on the server arrives as complete HTML in the first response. That is the single biggest technical SEO decision in this course, and it was made back in the server components lesson.
Check yours: load a product page with JavaScript disabled. If the content is there, you are fine.
Static metadata
// src/app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://kirana.example"),
title: {
default: "Kirana Store — groceries delivered in Pune",
template: "%s | Kirana Store",
},
description:
"Order groceries, staples and household essentials from your neighbourhood kirana shop. Same-day delivery across Pune.",
};
template means a page setting title: "Atta" produces "Atta | Kirana Store"
automatically. Set it once.
metadataBase lets relative URLs in Open Graph images resolve. Without it you
get a build warning and broken social previews.
Per-page metadata
// src/app/products/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) {
return { title: "Product not found" };
}
return {
title: product.name,
description: `${product.name} — ₹${(product.pricePaise / 100).toFixed(2)} per ${product.unit}. Order online for same-day delivery in Pune.`,
alternates: { canonical: `/products/${product.slug}` },
openGraph: {
title: product.name,
description: product.shortDescription,
images: [{ url: product.imageUrl, width: 1200, height: 630 }],
type: "website",
},
};
}
generateMetadata runs on the server before rendering. It can fetch, and that
fetch is deduplicated against the one in the page — so this costs no extra
request.
Write descriptions for people, not for keywords. The description is what appears under your link in results, so it is advertising copy. "Aashirvaad Atta 5kg — ₹285, delivered today" earns clicks; a list of keywords does not, and Google often replaces bad descriptions with its own text anyway.
Canonical URLs
alternates: { canonical: `/products/${product.slug}` }
A shop generates the same content at many URLs — ?sort=price,
?utm_source=whatsapp, a product reachable through two categories. Without a
canonical, those compete with each other and the ranking signal splits.
Set a canonical on every indexable page. It is one line and it prevents a whole class of problem.
Structured data
Product markup is one of the few things that visibly changes your search result, adding price and availability:
export default async function ProductPage({ params }: Props) {
const product = await getProduct((await params).slug);
if (!product) notFound();
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
image: product.imageUrl,
description: product.shortDescription,
offers: {
"@type": "Offer",
priceCurrency: "INR",
price: (product.pricePaise / 100).toFixed(2),
availability: product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProductDetail product={product} />
</>
);
}
dangerouslySetInnerHTML is required for a script tag and is safe here because
the content is JSON.stringify of your own data, not user input.
The markup must match the visible page. Claiming a price the page does not show is a manual-action risk, not a clever trick.
Test with Google's Rich Results Test before assuming it works.
Sitemap and robots
// src/app/sitemap.ts
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const products = await getProducts();
return [
{ url: "https://kirana.example", priority: 1 },
{ url: "https://kirana.example/products", priority: 0.9 },
...products.map((p) => ({
url: `https://kirana.example/products/${p.slug}`,
lastModified: p.updatedAt,
priority: 0.8,
})),
];
}
Generate it from the data, never by hand. A hand-written sitemap is wrong the first time somebody adds a product.
// src/app/robots.ts
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/admin/", "/api/", "/checkout/", "/cart"],
},
sitemap: "https://kirana.example/sitemap.xml",
};
}
Blocking /cart and /checkout keeps pages that are useless in search out of
the index.
Images
import Image from "next/image";
<Image
src={product.imageUrl}
alt={`${product.name} — ${product.unit}`}
width={400}
height={400}
className="rounded-lg object-cover"
/>
next/image resizes, converts to modern formats, and reserves space so the
layout does not shift when it loads. Layout shift is both a ranking factor and
genuinely irritating on a phone.
Write real alt text. It is what a screen reader announces and what search
uses to understand the image. alt="product" helps nobody; alt="" is correct
only for purely decorative images.
Use priority on the one image visible at the top of the page, and nothing
else — marking everything priority defeats the purpose.
Things that quietly cost you
- One
<h1>per page, saying what the page is about. - Real URLs.
/products/aashirvaad-atta-5kgbeats/products/1842. - No
noindexleft on. It happens, usually copied from a staging config, and it removes you from search entirely. - A fast page. Mobile performance is a ranking factor and most of your traffic is a mid-range Android on mobile data.
- Working links. A
Linkto a route that 404s wastes crawl budget.
Check your work
Why server rendering matters for SEO: the HTML arrives complete in the first response. Crawlers that do not run JavaScript — including most social preview bots — see the content.
How to check: load the page with JavaScript disabled, or view source rather than DevTools.
What title.template does: a page setting title: "Atta" becomes
"Atta | Kirana Store" automatically.
Why metadataBase: it lets relative Open Graph image URLs resolve.
What a canonical URL prevents: the same content at several URLs competing with itself and splitting the ranking signal.
Why structured data must match the page: claiming a price the page does not show risks a manual action.
Why generate the sitemap from data: a hand-written one is wrong the first time somebody adds a product.
What next/image gives you: resizing, modern formats, and reserved space so
the layout does not shift — which is both a ranking factor and an irritation on
a phone.
Practice
- Set a root title template and description. Confirm a page title becomes "Page | Kirana Store".
- Add
generateMetadatato the product page pulling the real name and price. - Load a product page with JavaScript disabled. Confirm the content is present.
- View source — not DevTools — and find your title, description and Open Graph tags in the raw HTML.
- Add a canonical URL and explain what problem it solves.
- Add Product structured data and validate it with Google's Rich Results Test.
- Generate a sitemap from your product data. Visit
/sitemap.xml. - Add robots rules blocking
/adminand/cart. - Replace an
<img>withnext/imageand compare the Network tab and any layout shift.
That is module three. You can route, decide what runs where, fetch and cache, handle loading and errors, submit forms without an API, and build pages that can actually be found.
Next module: making it look right.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship