SEO for Next.js Apps: How I Optimized a Car Listing Site for Search Engines
You can build the most beautiful Next.js app in the world, but if Google can’t understand it, nobody will find it. I recently went through a full SEO overhaul on a car listing site I’m building, and the difference in how search engines interact with the site was immediate.
Here’s everything I did — with code — so you can do the same for your Next.js project.
Why Next.js SEO Needs Intentional Work#
Next.js gives you server-side rendering out of the box, which is already a huge win over client-side-only React apps. But SSR alone doesn’t mean your SEO is sorted. You still need:
- A proper sitemap for crawlers
- Structured data (JSON-LD) so Google understands your content
- Open Graph tags for social sharing
- Canonical URLs to avoid duplicate content penalties
- Programmatic landing pages for long-tail keywords
Let’s tackle each one.
1. Dynamic Sitemap Generation#
A sitemap tells search engines what pages exist and how often they change. For a dynamic site with hundreds of car listings, you can’t write this by hand.
The Route Handler Approach#
Create app/sitemap.xml/route.ts:
import { NextResponse } from "next/server";
// Your data fetching function
async function getAllCars() {
// Fetch from your database
const cars = await db.collection("cars").find({}).toArray();
return cars;
}
export async function GET() {
const cars = await getAllCars();
const baseUrl = "https://yoursite.com";
const staticPages = [
{ url: "/", changefreq: "daily", priority: "1.0" },
{ url: "/cars", changefreq: "daily", priority: "0.9" },
{ url: "/compare", changefreq: "weekly", priority: "0.7" },
];
const carPages = cars.map((car) => ({
url: `/cars/${car.slug}`,
changefreq: "weekly",
priority: "0.8",
lastmod: car.updatedAt?.toISOString(),
}));
const allPages = [...staticPages, ...carPages];
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${allPages
.map(
(page) => ` <url>
<loc>${baseUrl}${page.url}</loc>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
${page.lastmod ? `<lastmod>${page.lastmod}</lastmod>` : ""}
</url>`
)
.join("\n")}
</urlset>`;
return new NextResponse(sitemap, {
headers: {
"Content-Type": "application/xml",
},
});
}
robots.txt#
Create app/robots.txt/route.ts:
import { NextResponse } from "next/server";
export async function GET() {
const robotsTxt = `User-agent: *
Allow: /
Disallow: /api/
Disallow: /admin/
Sitemap: https://yoursite.com/sitemap.xml`;
return new NextResponse(robotsTxt, {
headers: { "Content-Type": "text/plain" },
});
}
Pro tip: Submit your sitemap to Google Search Console right after deploying. Don’t wait for Google to discover it naturally — that can take weeks.
2. JSON-LD Structured Data#
This is where most developers stop at “I added meta tags” and call it done. JSON-LD is how you tell Google exactly what your page is about in a machine-readable format. It’s what powers those rich snippets in search results — the ones with star ratings, price ranges, and images.
For a Car Listing Page#
// components/CarJsonLd.tsx
interface CarJsonLdProps {
car: {
name: string;
brand: string;
model: string;
description: string;
price: number;
currency: string;
image: string;
fuelType: string;
url: string;
};
}
export function CarJsonLd({ car }: CarJsonLdProps) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Car",
name: car.name,
brand: {
"@type": "Brand",
name: car.brand,
},
model: car.model,
description: car.description,
image: car.image,
fuelType: car.fuelType,
offers: {
"@type": "Offer",
price: car.price,
priceCurrency: car.currency,
availability: "https://schema.org/InStock",
url: car.url,
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
Drop this into your car detail page’s <head>:
// app/cars/[slug]/page.tsx
export default async function CarPage({ params }: { params: { slug: string } }) {
const car = await getCarBySlug(params.slug);
return (
<>
<CarJsonLd car={car} />
{/* Rest of your page */}
</>
);
}
For the Homepage#
Use WebSite and Organization schemas:
const homeJsonLd = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "Your Car Site",
url: "https://yoursite.com",
description: "Nepal's complete car guide — specs, prices, comparisons",
potentialAction: {
"@type": "SearchAction",
target: "https://yoursite.com/cars?q={search_term_string}",
"query-input": "required name=search_term_string",
},
};
Validate your structured data using Google’s Rich Results Test before deploying. Broken JSON-LD is worse than no JSON-LD.
3. Open Graph & Twitter Cards#
These control how your pages look when shared on social media. Without them, sharing a link on Facebook or Twitter shows a blank preview — and nobody clicks blank previews.
Using Next.js Metadata API#
// app/cars/[slug]/page.tsx
import { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const car = await getCarBySlug(params.slug);
return {
title: `${car.name} — Price, Specs & Features | YourSite`,
description: `${car.name} price in Nepal starts at Rs. ${car.price?.toLocaleString()}. Check specs, features, mileage, and compare with alternatives.`,
alternates: {
canonical: `https://yoursite.com/cars/${car.slug}`,
},
openGraph: {
title: `${car.name} — Price & Specs in Nepal`,
description: `Starting at Rs. ${car.price?.toLocaleString()}. Full specs, gallery, and comparison.`,
url: `https://yoursite.com/cars/${car.slug}`,
siteName: "YourSite",
images: [
{
url: car.image || "https://yoursite.com/og-default.png",
width: 1200,
height: 630,
},
],
type: "website",
},
twitter: {
card: "summary_large_image",
title: `${car.name} — Price & Specs`,
description: `Rs. ${car.price?.toLocaleString()} onwards. Full details.`,
images: [car.image || "https://yoursite.com/og-default.png"],
},
};
}
The alternates.canonical field is critical. If you have filter pages, paginated pages, or multiple URLs that show similar content, canonical URLs tell Google which one is the “real” page.
4. Programmatic Landing Pages for Long-Tail SEO#
This is the strategy that most people miss entirely. Instead of hoping someone searches for your exact car name, create landing pages that target how people actually search:
- “electric cars in Nepal”
- “cars under 50 lakhs in Nepal”
- “hybrid SUVs Nepal”
Creating Type-Based Pages#
// app/cars/type/[type]/page.tsx
const typeConfig: Record<string, { title: string; description: string }> = {
ev: {
title: "Electric Cars in Nepal — Prices, Range & Specs",
description:
"Complete list of electric vehicles available in Nepal with prices, battery range, charging time, and detailed specifications.",
},
hybrid: {
title: "Hybrid Cars in Nepal — Best Fuel-Efficient Options",
description:
"Explore hybrid cars available in Nepal. Compare mileage, prices, and features of the latest hybrid vehicles.",
},
ice: {
title: "Petrol & Diesel Cars in Nepal — Full Price List",
description:
"Browse all petrol and diesel cars available in Nepal with updated prices, specs, and features.",
},
};
export async function generateMetadata({ params }: { params: { type: string } }) {
const config = typeConfig[params.type];
return {
title: config?.title,
description: config?.description,
};
}
export default async function TypePage({ params }: { params: { type: string } }) {
const cars = await getCarsByFuelType(params.type);
return (
<div>
<h1>{typeConfig[params.type]?.title}</h1>
<p>{typeConfig[params.type]?.description}</p>
<CarGrid cars={cars} />
</div>
);
}
Budget-Based Pages#
Same idea, different angle:
// app/cars/budget/[range]/page.tsx
const budgetConfig: Record<string, { min: number; max: number; title: string }> = {
"under-30-lakhs": { min: 0, max: 3000000, title: "Cars Under 30 Lakhs in Nepal" },
"30-50-lakhs": { min: 3000000, max: 5000000, title: "Cars Between 30-50 Lakhs in Nepal" },
"50-80-lakhs": { min: 5000000, max: 8000000, title: "Cars Between 50-80 Lakhs in Nepal" },
"above-1-crore": { min: 10000000, max: Infinity, title: "Premium Cars Above 1 Crore in Nepal" },
};
Each of these pages targets a specific search intent. Someone Googling “electric cars Nepal price” lands on your /cars/type/ev page. Someone searching “cars under 50 lakhs Nepal” hits your budget page. Free, targeted traffic.
5. Performance as SEO#
Google’s Core Web Vitals directly affect rankings. A few Next.js-specific optimizations:
Image Optimization#
import Image from "next/image";
// Always use next/image — it handles lazy loading, WebP/AVIF conversion, and sizing
<Image
src={car.image}
alt={`${car.name} - ${car.variant}`}
width={800}
height={450}
priority={isAboveFold} // Only for images visible without scrolling
/>
ISR for Dynamic Content#
// In your page or layout
export const revalidate = 3600; // Revalidate every hour
ISR (Incremental Static Regeneration) gives you the speed of static pages with the freshness of dynamic data. Car prices don’t change every minute — revalidating hourly is plenty.
Metadata for the Whole App#
Set defaults in your root layout so every page has baseline SEO:
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL("https://yoursite.com"),
title: {
default: "YourSite — Nepal's Complete Car Guide",
template: "%s | YourSite",
},
description: "Compare prices, specs, and features of cars available in Nepal.",
robots: {
index: true,
follow: true,
},
};
The Checklist#
Before you ship, verify:
-
/sitemap.xmlreturns valid XML with all your pages -
/robots.txtexists and references your sitemap - Every page has unique
<title>and<meta description> - JSON-LD validates on Google’s Rich Results Test
- OG images are 1200×630 and load fast
- Canonical URLs are set on all pages
- No duplicate content issues (check with
site:yoursite.comon Google) - Submitted sitemap to Google Search Console
- Core Web Vitals pass on PageSpeed Insights
Results#
After implementing all of this, Google Search Console showed indexed pages jumping from just the homepage to 15+ pages within a week. The programmatic landing pages started picking up impressions for long-tail keywords almost immediately.
SEO isn’t magic — it’s just making sure search engines can read what you already built. If you’re running a Next.js app with dynamic content and haven’t done this yet, you’re leaving traffic on the table.
Have questions or want to see the structured data setup for a different type of site? Drop a comment or reach out on Twitter.