Most local business websites are slow, template-bloated, and invisible to search. I recently built one that scores 100 on Lighthouse SEO and Best Practices, ships zero client-side CMS weight, and generates its own sitemap, structured data, and AI-crawler manifest from a single data file. Here's how, with code you can lift into your own project.
The finished result is live at browardacguys.com if you want to view-source along the way.
- One data file, many pages
The trick to a maintainable local site is to treat pages as dataities live in plain arrays, and dynamic routes generate a pageper entry.
// lib/services.js
export const services = [
{
slug: "emergency-ac-repair",
name: "Emergency AC Repair",
title: "24/7 Emergency AC Repair",
metaDescription: "AC out at 2 a.m.? Same-day emergency repai
summary: "When your AC quits, waiting is not an option.",
faqs: [{ q: "Do you answer at night?", a: "Yes, 24/7 dispatc
},
// ...
];
export const getService = (slug) => services.find((s) => s.slug === slug);
// app/services/[slug]/page.js
import { services, getService } from "@/lib/services";
export function generateStaticParams() {
return services.map((s) => ({ slug: s.slug }));
}
Add a service to the array and you get a new statically-rendered page, a sitemap entry, and internal links, all for free.
- Per-page metadata with the Metadata API
Next's Metadata API makes unique title tags and descriptions trivial. generateMetadata runs per route:
export async function generateMetadata({ params }) {
const { slug } = await params;
const service = getService(slug);
return {
title: { absolute: service.title },
description: service.metaDescription,
alternates: { canonical: `/services/${service.slug}` },
openGraph: { title: service.title, images: ["/og.jpg"] },
};
}
Note title: { absolute } to bypass a site-wide title template complete, so you don't blow past the ~60 character SERP limit.
- Structured data that actually validates
Rich results need JSON-LD. I use a single @graph with the busi, FAQPage, and BreadcrumbList nodes. The key detail for aservice-area business: do NOT emit a LocalBusiness per city (that creates duplicate NAP entities). Use one business node and reference it.
const graph = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "HVACBusiness",
"@id": "https://example.com/#business",
name: "Broward AC Guys",
telephone: "+17868371512",
areaServed: [{ "@type": "AdministrativeArea", name: "Browa
openingHoursSpecification: {
"@type": "OpeningHoursSpecification",
opens: "00:00", closes: "23:59",
},
},
],
};
A FAQPage node on pages with FAQs is a cheap win, since Googleme it.
- Sitemap and robots as code
No plugins. Next turns these functions into /sitemap.xml and `
jshttps://example.com/services/${s.slug}`,
// app/sitemap.js
export default function sitemap() {
const lastModified = new Date();
return services.map((s) => ({
url:
lastModified,
changeFrequency: "monthly",
}));
}
`
- An llms.txt for AI crawlers
As people ask ChatGPT and Perplexity for local recommendations, p of your site is worth the five minutes. The llms.txtspec is just markdown. I generate it from the same data so it never drifts:
js# Broward AC Guys\n\n> 24/7 AC repair...\n\n## S
// app/llms.txt/route.js
export function GET() {
const body =
services.map((s) => - [${s.name}](/services/${s.slug})).join("\n");
return new Response(body, { headers: { "Content-Type": "text/p
}
`
You can see the [live llms.txt here](https://browardacguys.com/l
6. Images without the LCP penalty
next/image plus AVIF cut the hero image roughly in half:
js
// next.config.mjs
export default { images: { formats: ["image/avif", "image/webp"]
Set priority on the LCP image so it preloads, and give every image a fixed aspect-ratio container so your CLS stays at 0.
7. Analytics events without hurting Core Web Vitals
I load GA4 via @next/third-parties (after-interactive, no CWV hit), then track phone-call clicks with one delegated listener instead of wiring every button:
`js
"use client";
import { useEffect } from "react";
export default function AnalyticsEvents() {
useEffect(() => {
const onClick = (e) => {
const a = e.target.closest?.("a");
if (a?.getAttribute("href")?.startsWith("tel:")) {
window.gtag?.("event", "phone_call", { page_path: locati
}
};
document.addEventListener("click", onClick);
return () => document.removeEventListener("click", onClick);
}, []);
return null;
}
`
The results
On mobile PageSpeed (throttled Slow 4G, emulated budget phone):
- Performance 85, Accessibility 96, Best Practices 100, SEO 100
- CLS 0, Total Blocking Time 20ms
- Structured data validates with zero errors
Static-first rendering plus a data-driven architecture gets you full build (schema, sitemap, llms.txt, event tracking) is running on the live site (https://browardacguys.com).
If you're building something similar, the biggest lever is the boring one: keep your content in data, render it statically, and let the framework generate the SEO plumbing for you.
Top comments (0)