DEV Community

Cover image for Building a Fast, SEO-First Local Business Site with Next.js App Router
Randy Rodriguez
Randy Rodriguez

Posted on

Building a Fast, SEO-First Local Business Site with Next.js App Router

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.

  1. 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);
Enter fullscreen mode Exit fullscreen mode
// app/services/[slug]/page.js
import { services, getService } from "@/lib/services";

export function generateStaticParams() {
  return services.map((s) => ({ slug: s.slug }));
}
Enter fullscreen mode Exit fullscreen mode

Add a service to the array and you get a new statically-rendered page, a sitemap entry, and internal links, all for free.

  1. 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"] },
  };
}
Enter fullscreen mode Exit fullscreen mode

Note title: { absolute } to bypass a site-wide title template complete, so you don't blow past the ~60 character SERP limit.

  1. 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",
      },
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

A FAQPage node on pages with FAQs is a cheap win, since Googleme it.

  1. Sitemap and robots as code

No plugins. Next turns these functions into /sitemap.xml and `

js
// app/sitemap.js
export default function sitemap() {
const lastModified = new Date();
return services.map((s) => ({
url:
https://example.com/services/${s.slug}`,
lastModified,
changeFrequency: "monthly",
}));
}
`

  1. 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
// app/llms.txt/route.js
export function GET() {
const body =
# Broward AC Guys\n\n> 24/7 AC repair...\n\n## S
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)