DEV Community

Cover image for How to Build a Local-Service Site That Can Answer ‘Can You Fix My RV Today?’
Alex Shev
Alex Shev

Posted on

How to Build a Local-Service Site That Can Answer ‘Can You Fix My RV Today?’

An RV repair business does not lose a service call because a visitor failed to read a clever headline.

It loses the call when a person with a broken slide-out, roof leak, or electrical issue cannot answer four basic questions quickly:

  1. Do you handle this exact problem?
  2. Do you serve where I am?
  3. Are you available and credible?
  4. What do I do next?

That sounds like marketing. It is mostly a systems-design problem.

The implementation goal is not “make more city pages.” It is to make the business's real-world facts available, consistent, crawlable, and usable across the website, Google Business Profile, analytics, and the conversion flow.

This post turns SEOG’s RV repair checklist into an implementation pattern a developer can apply to any local-service site.

The model: one source of truth, many decision surfaces

Local customers do not encounter a business in one place. They may see a Google result, a Maps profile, a service page, a review, or a call button before they ever submit a form.

Treat the site as one consumer of a small, canonical business data model rather than a collection of independently written pages.

business facts ─┬─> server-rendered service pages
                ├─> JSON-LD
                ├─> XML sitemap + canonical URLs
                ├─> GBP sync/review queue (with human approval)
                ├─> call/form events
                └─> audit and change history
Enter fullscreen mode Exit fullscreen mode

The important part is the left side. If a mobile RV technician's phone number, service coverage, repair categories, and hours live in five unrelated CMS fields, a mismatch is inevitable.

Start with an explicit domain object.

type BusinessLocation = {
  id: string;
  legalName: string;
  publicName: string;
  phoneE164: string;
  website: string;
  address?: {
    streetAddress: string;
    addressLocality: string;
    addressRegion: string;
    postalCode: string;
    addressCountry: "US";
  };
  geo?: { latitude: number; longitude: number };
  serviceAreas: Array<{ name: string; state: string; proof: string[] }>;
  hours: Array<{ dayOfWeek: string[]; opens: string; closes: string }>;
  services: Array<{
    slug: string;
    name: string;
    customerProblem: string;
    evidence: string[];
    emergency: boolean;
  }>;
};
Enter fullscreen mode Exit fullscreen mode

proof and evidence are deliberate fields. They prevent the familiar anti-pattern where a generator creates a page merely because a city name and a service name exist in a spreadsheet. A service-area claim should be backed by something real: a dispatch region, technician coverage, case work, a physical facility, or a documented operating policy.

Build pages from real combinations, not every possible combination

A useful URL is not a matrix cell.

/rv-roof-repair/mesa-az should exist only if the business actually serves Mesa for roof repair and the page can provide information a customer cannot get from a generic page.

For a Next.js application, make the eligibility rule executable instead of leaving it to editorial memory:

function canPublishServiceAreaPage(
  service: BusinessLocation["services"][number],
  area: BusinessLocation["serviceAreas"][number],
) {
  return service.evidence.length > 0 && area.proof.length > 0;
}

export async function generateStaticParams() {
  const business = await getBusinessLocation();

  return business.services.flatMap((service) =>
    business.serviceAreas
      .filter((area) => canPublishServiceAreaPage(service, area))
      .map((area) => ({ service: service.slug, area: slugify(area.name) })),
  );
}
Enter fullscreen mode Exit fullscreen mode

This is a product guardrail, not an SEO trick. Google’s spam policies explicitly call out substantially similar regional pages that funnel users toward one destination as doorway abuse. A page needs its own user value, not just a different token in the title. Google’s policy is worth reading before automating local landing pages.

Render the decision-critical facts in the initial HTML

Do not make a potential customer — or a crawler — wait for a client-side API call to learn whether you repair RV electrical systems or whether emergency dispatch is available.

Use server rendering or static generation for the core page content:

// app/[service]/[area]/page.tsx
export default async function ServiceAreaPage({ params }: Props) {
  const { business, service, area } = await resolveServiceArea(params);

  return (
    <main>
      <h1>{service.name} in {area.name}</h1>
      <p>{service.customerProblem}</p>

      <section aria-labelledby="coverage">
        <h2 id="coverage">Coverage and dispatch</h2>
        <p>{area.proof.join(" ")}</p>
      </section>

      <section aria-labelledby="proof">
        <h2 id="proof">What we repair</h2>
        <ul>{service.evidence.map((item) => <li key={item}>{item}</li>)}</ul>
      </section>

      <a href={`tel:${business.phoneE164}`} data-event="service_call_click">
        Call for {service.name}
      </a>
      <RequestServiceForm serviceId={service.slug} areaId={area.name} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Google can render JavaScript, but it processes JavaScript in crawling, rendering, and indexing stages. Server-rendering the essential content improves the experience for people and for crawlers, and helps other bots that do not execute the full application. See Google’s JavaScript SEO guidance.

The page should answer the real query, not manufacture confidence. If dispatch availability is unknown, say how the customer gets an answer. Never render “same-day service” because a marketing field was left enabled.

Generate structured data from the same object

Schema should be a projection of visible, maintained facts — never a second source of truth.

For a fixed physical location, use the most specific appropriate LocalBusiness subtype. For a mobile-only service, do not invent a customer-facing address. Keep the markup aligned with the content a person can see on the page.

function localBusinessJsonLd(business: BusinessLocation) {
  return {
    "@context": "https://schema.org",
    "@type": ["AutoRepair", "LocalBusiness"],
    "@id": `${business.website}/#location-${business.id}`,
    name: business.publicName,
    url: business.website,
    telephone: business.phoneE164,
    ...(business.address && { address: { "@type": "PostalAddress", ...business.address } }),
    ...(business.geo && { geo: { "@type": "GeoCoordinates", ...business.geo } }),
    openingHoursSpecification: business.hours.map((hours) => ({
      "@type": "OpeningHoursSpecification",
      ...hours,
    })),
    areaServed: business.serviceAreas.map((area) => ({
      "@type": "AdministrativeArea",
      name: `${area.name}, ${area.state}`,
    })),
    hasOfferCatalog: {
      "@type": "OfferCatalog",
      name: "RV repair services",
      itemListElement: business.services.map((service) => ({
        "@type": "Offer",
        itemOffered: { "@type": "Service", name: service.name },
      })),
    },
  };
}
Enter fullscreen mode Exit fullscreen mode
<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(localBusinessJsonLd(business)) }}
/>
Enter fullscreen mode Exit fullscreen mode

Validate the final URL with Google’s Rich Results Test and URL Inspection — not only the JSON object in a unit test. Google recommends JSON-LD, but valid markup is an eligibility signal, not a promise of a rich result. Its LocalBusiness documentation is precise about both the implementation and that distinction.

Make canonicalization and discovery part of the build

The system should publish only the URLs it wants search engines to treat as canonical.

// app/sitemap.ts
export default async function sitemap() {
  const pages = await getPublishedServiceAreaPages();

  return pages.map((page) => ({
    url: `https://example.com/${page.service}/${page.area}`,
    lastModified: page.updatedAt,
  }));
}
Enter fullscreen mode Exit fullscreen mode

Each route also needs a self-referencing canonical in its metadata. Do not put filtered, preview, parameterized, or unpublished pages in the sitemap.

Google recommends listing the canonical URLs you prefer in a sitemap; for larger sites, generate that sitemap from the same data store that controls publishing. See its sitemap guide and canonicalization guide.

Instrument the conversion path, not only page views

If the business says calls are down, “organic sessions are flat” is not enough diagnosis.

Record the transition from discovery to action without collecting more personal data than the business needs:

type LocalIntentEvent = {
  name: "service_call_click" | "quote_form_started" | "quote_form_submitted";
  service: string;
  area: string;
  pageType: "service" | "service_area";
  referrerClass: "organic" | "maps" | "paid" | "direct" | "other";
};

export function trackLocalIntent(event: LocalIntentEvent) {
  analytics.track(event.name, event);
}
Enter fullscreen mode Exit fullscreen mode

Track the call click, form start, form submission, and any booking completion that the business can measure. Send only the fields needed for analysis; do not put phone numbers, issue descriptions, or other sensitive request content into generic analytics events.

Then join those events with a small operational audit:

service + area + page version
  -> rendered page checks
  -> profile/review freshness check
  -> call/form events
  -> prioritized human-approved change
  -> re-check window
Enter fullscreen mode Exit fullscreen mode

This makes it possible to distinguish “we need more demand” from “people are reaching us but the service, coverage, trust, or contact path is unclear.”

Add tests where local sites usually drift

The highest-value tests are boring:

it("does not publish a service-area route without service and coverage proof", () => {
  expect(canPublishServiceAreaPage(noEvidenceService, coveredArea)).toBe(false);
  expect(canPublishServiceAreaPage(realService, noProofArea)).toBe(false);
});

it("keeps the phone number consistent across page and JSON-LD", async () => {
  const html = await renderPage("rv-electrical-repair", "mesa-az");
  expect(html).toContain("+14805550199");
  expect(extractJsonLd(html).telephone).toBe("+14805550199");
});
Enter fullscreen mode Exit fullscreen mode

Also run an integration check in CI for:

  • 200 responses for published pages and 404 for ineligible combinations;
  • canonical URL matches the route;
  • sitemap contains only published canonical URLs;
  • no noindex leaks from preview/staging configuration;
  • JSON-LD reflects visible service, hours, phone, and location data;
  • call/form controls exist and are keyboard accessible;
  • content changes have a reviewed audit record.

The operating rule: suggest changes, do not silently mutate trust surfaces

A developer can automate discovery, validation, and draft generation. That does not mean an integration should silently rewrite a Google Business Profile, alter service areas, or publish review responses.

Use an approval queue:

observed fact -> proposed correction -> evidence -> risk -> human approval -> apply -> verify
Enter fullscreen mode Exit fullscreen mode

This keeps automation useful without treating a business identity as an unattended configuration file.

Closing thought

The durable local-service implementation is not a pile of keywords and location templates.

It is a small, tested facts system that makes the same answer available everywhere a customer needs it:

We fix this problem. We serve this area. Here is the proof. Here is what to do next.

That is a better foundation for search visibility, Maps presence, AI-assisted discovery, and — most importantly — a service call that actually reaches the business.

Original business-facing guide: RV Repair SEO Checklist: What to Fix When Service Calls Slow Down

Build the audit, or start with the evidence

If you are building this workflow for a local-service team, SEOG turns the same inputs — Google Business Profile completeness, map visibility, competitor context, reviews, local pages, and conversion gaps — into a prioritized, human-approved action plan.

Explore the platform: seog.ai

Top comments (0)