DEV Community

CodeHunterz
CodeHunterz

Posted on

Adding LocalBusiness Schema to a Next.js App Router Site Without Breaking Anything

If you build websites for plumbers, dentists, law firms or any other local service business, structured data is one of the cheapest technical wins you can ship. A few lines of JSON-LD tell search engines exactly who the business is, where it operates, when it is open and how to reach it. Done well, it supports rich results and helps Google connect the website to the business behind it. Done badly, it gets ignored or flagged in Search Console.

This post walks through a clean, maintainable way to add LocalBusiness schema to a Next.js App Router project. We will keep the business data in one typed file, render JSON-LD on the server, generate per-page schema for service and location pages, and validate everything before it goes live.

Why structured data matters for local sites

Search engines are good at reading pages, but they still guess. Is that phone number in the footer the main line or a fax? Is the address a storefront or a mailing address? Which city does the business actually serve? Schema markup removes that guesswork by describing the business in a format machines read without interpretation.

For local service businesses, the useful pieces are usually:

  • Business name, logo and main URL
  • Phone number and contact options
  • Address, or service area if the business has no public storefront
  • Opening hours
  • Links to official profiles (the sameAs property)
  • The services the business offers

If you want the non-technical background on what each property means and which ones matter most, the CodeHunterz guide on schema markup for a local business covers it in plain language. Here we focus on the implementation.

Step 1: Keep business data in one typed source

The most common schema bug is drift. The phone number in the header changes, but the JSON-LD still shows the old one. Fix that by storing business details in a single file and reading from it everywhere: header, footer, contact page and schema.

// lib/business.ts
export type OpeningHours = {
  days: string[];
  opens: string;
  closes: string;
};

export const business = {
  name: "Example Plumbing Co",
  url: "https://www.example.com",
  logo: "https://www.example.com/logo.png",
  phone: "+1-555-010-0000",
  email: "hello@example.com",
  priceRange: "$$",
  serviceAreas: ["Austin", "Round Rock", "Cedar Park"],
  hours: [
    { days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], opens: "08:00", closes: "18:00" },
    { days: ["Saturday"], opens: "09:00", closes: "14:00" },
  ] as OpeningHours[],
  sameAs: [
    "https://www.facebook.com/exampleplumbing",
    "https://www.yelp.com/biz/example-plumbing",
  ],
};
Enter fullscreen mode Exit fullscreen mode

The values above are placeholders. In a real project they come from the client, and they must match the Google Business Profile exactly. Mismatched names or phone numbers between the site and the profile are a classic local SEO problem, and schema will not fix that for you.

Step 2: Build the JSON-LD object with a helper

Instead of hand writing JSON in a component, build it with a function. That keeps it testable and lets you reuse parts for other schema types.

// lib/schema.ts
import { business } from "./business";

export function localBusinessSchema() {
  return {
    "@context": "https://schema.org",
    "@type": "Plumber",
    "@id": `${business.url}/#business`,
    name: business.name,
    url: business.url,
    logo: business.logo,
    image: business.logo,
    telephone: business.phone,
    email: business.email,
    priceRange: business.priceRange,
    areaServed: business.serviceAreas.map((city) => ({
      "@type": "City",
      name: city,
    })),
    openingHoursSpecification: business.hours.map((h) => ({
      "@type": "OpeningHoursSpecification",
      dayOfWeek: h.days,
      opens: h.opens,
      closes: h.closes,
    })),
    sameAs: business.sameAs,
  };
}
Enter fullscreen mode Exit fullscreen mode

A few details worth calling out:

Use the most specific type you can. Plumber, Dentist, Attorney, HVACBusiness and many others are subtypes of LocalBusiness. A specific type gives search engines more context than the generic parent.

Give the entity a stable @id. Using something like https://www.example.com/#business lets other schema blocks on other pages reference the same business instead of declaring a new one each time.

Service area businesses can skip the street address. If the client works from home and does not serve customers at that location, leave address out or include only the city and region, and rely on areaServed. Publishing a home address that the business keeps hidden on its Google profile creates a conflict.

Step 3: Render JSON-LD on the server

In the App Router, the simplest approach is a small server component that outputs a script tag. No client JavaScript is needed, and the markup is in the initial HTML that crawlers fetch.

// components/JsonLd.tsx
type Props = { data: Record<string, unknown> };

export function JsonLd({ data }: Props) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(data).replace(/</g, "\\u003c"),
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The replace call escapes any < characters so that a stray string in your data cannot close the script tag early. It is a small safety habit that matters when content comes from a CMS.

Now add it to the root layout so the business entity appears on every page:

// app/layout.tsx
import { JsonLd } from "@/components/JsonLd";
import { localBusinessSchema } from "@/lib/schema";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <JsonLd data={localBusinessSchema()} />
        {children}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Some teams prefer to place the main business schema only on the homepage and contact page. Either works. What matters is that the data is consistent and that you do not declare conflicting versions of the same business on different pages.

Step 4: Add Service schema to service pages

Most local sites have pages like "Drain Cleaning" or "Water Heater Repair". Each of these can carry a Service block that points back to the business through its @id.

// lib/schema.ts (continued)
export function serviceSchema(opts: {
  name: string;
  description: string;
  path: string;
}) {
  return {
    "@context": "https://schema.org",
    "@type": "Service",
    name: opts.name,
    description: opts.description,
    url: `${business.url}${opts.path}`,
    provider: { "@id": `${business.url}/#business` },
    areaServed: business.serviceAreas.map((city) => ({
      "@type": "City",
      name: city,
    })),
  };
}
Enter fullscreen mode Exit fullscreen mode

Then use it in a dynamic route:

// app/services/[slug]/page.tsx
import { JsonLd } from "@/components/JsonLd";
import { serviceSchema } from "@/lib/schema";
import { getService } from "@/lib/services";

export default async function ServicePage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const service = await getService(slug);

  return (
    <main>
      <JsonLd
        data={serviceSchema({
          name: service.title,
          description: service.summary,
          path: `/services/${slug}`,
        })}
      />
      <h1>{service.title}</h1>
      {/* page content */}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Because the description comes from the same content that renders on the page, the schema always matches what visitors see. That alignment is important. Structured data that describes content not visible on the page goes against Google's guidelines.

Step 5: Breadcrumbs for deeper pages

Location and service pages often sit two or three levels deep. A BreadcrumbList helps search engines understand the hierarchy and can show a cleaner path in results.

export function breadcrumbSchema(items: { name: string; path: string }[]) {
  return {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    itemListElement: items.map((item, i) => ({
      "@type": "ListItem",
      position: i + 1,
      name: item.name,
      item: `${business.url}${item.path}`,
    })),
  };
}
Enter fullscreen mode Exit fullscreen mode

You can pass an array of schema objects to a single JsonLd component, or render one component per object. Both are valid. I usually render separate tags because it keeps each block easy to inspect in the browser.

Step 6: Things to avoid

A few patterns cause more trouble than they are worth:

Self-serving review markup. Adding aggregateRating to your own LocalBusiness schema with reviews you collected yourself is not eligible for review rich results. Leave ratings out unless you have a clear, guideline-compliant reason to include them.

Inventing data. Do not add hours, prices or service areas the business has not confirmed. Schema should mirror reality.

Client-side injection only. If you add schema with a useEffect hook, it may still be picked up, but you are adding a rendering dependency for no benefit. Server rendering is simpler and more reliable.

Duplicate entities from plugins. If the project mixes Next.js with a headless CMS or third-party widget that injects its own schema, check the final HTML. Two different LocalBusiness blocks with different names or phone numbers send mixed signals.

Step 7: Validate before and after deploy

Never ship schema blind. A quick validation routine:

  1. Run the page through the Schema Markup Validator at validator.schema.org to catch syntax and type errors.
  2. Use Google's Rich Results Test to see which rich result types the page is eligible for.
  3. View the page source in production and confirm the script tag is present in the server HTML, not only after hydration.
  4. After deploy, watch the Enhancements and structured data reports in Google Search Console for warnings over the following weeks.

You can also add a simple test that parses your schema helpers and checks required fields, so a missing phone number fails CI instead of silently shipping.

// __tests__/schema.test.ts
import { localBusinessSchema } from "@/lib/schema";

test("local business schema has required fields", () => {
  const s = localBusinessSchema();
  expect(s.name).toBeTruthy();
  expect(s.telephone).toMatch(/^\+1/);
  expect(s.url.startsWith("https://")).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

If you also want a quick look at how a live site handles status codes, HTTPS and meta tags, the free website tools on CodeHunterz are handy for spot checks before handing a project over.

Where schema fits in the bigger picture

Structured data is a supporting signal, not a ranking shortcut. It works best on a site that is already fast, crawlable and clear about what the business does and where. For local businesses, that also means a well maintained Google Business Profile, consistent citations and real reviews earned from real customers. Schema simply makes sure the technical layer tells the same story as everything else.

For developers, the takeaway is to treat schema like any other part of the codebase: one source of truth, typed helpers, server rendering, tests and validation. That is how we approach it in our own Next.js development work, and it makes handoffs and later edits painless.

If you are building for local clients and want to understand the marketing side that sits on top of this technical foundation, the CodeHunterz page on local SEO services explains how the pieces fit together from the business owner's point of view.

Happy shipping.

CodeHunterz Team

Top comments (0)