DEV Community

CodeHunterz
CodeHunterz

Posted on

Building Google Ads Landing Pages in Next.js for Local Service Businesses

Paid search for plumbers, HVAC shops, dentists, and similar trades has a simple failure mode: the ad promises a job, and the page the click opens is the homepage. The visitor has to hunt for the service, the phone number, and the service area. Many of them bounce before they call. A dedicated landing route in Next.js fixes that mismatch without turning your marketing site into a pile of thin city clones.

This post covers how we structure Google Ads landing pages with the App Router for local service businesses: route layout, matching the ad keyword to the H1, keeping forms interactive without wrecking Interaction to Next Paint (INP), light conversion tracking, and A/B-friendly structure without thin city clones.

Why a dedicated landing route beats the homepage

Local service ads are intent-heavy. Someone typing "AC repair near me" or "emergency plumber [city]" is not browsing. They want confirmation that you do that job, that you serve their area, and that they can reach a person now.

Your homepage usually has to do more: brand story, all services, blog teaser, trust badges, maybe a map. That is fine for organic visitors. It is a poor destination for a paid click that already named a specific job.

A dedicated landing route lets you repeat the exact job in the H1, put the phone number and a short form above the fold on mobile, drop navigation that pulls people into unrelated pages, and keep tracking tied to one campaign.

If you already do Next.js development for marketing sites, campaign routes are a natural extra slice of the same App Router tree. They should not share the full product menu or a bloated client layout.

App Router layout for campaign pages

A pattern that stays maintainable:

app/
  (marketing)/
    layout.tsx          // full nav, footer, blog chrome
    page.tsx
    services/...
  (ads)/
    layout.tsx          // slim chrome: logo, phone, privacy link
    lp/
      [slug]/
        page.tsx
        opengraph-image.tsx
Enter fullscreen mode Exit fullscreen mode

The (ads) route group gets its own layout so ad pages do not inherit the marketing mega-menu. A sticky call bar and a short footer are usually enough. Privacy and terms links stay, because ads that collect phone numbers still need a clear disclosure path.

Keep the slug meaningful and stable. Examples:

  • /lp/ac-repair
  • /lp/furnace-replacement
  • /lp/emergency-plumbing

Avoid generating a unique URL per neighborhood unless the offer and copy are genuinely different. Thin city clones (same body, swapped city name) hurt SEO when those URLs get indexed, and they create a maintenance tax you feel every time pricing or hours change. Prefer one strong landing page per intent, and use ad copy plus geo targeting in Google Ads to handle the city. If you need city proof, add a short honest service-area section, not twenty near-duplicate pages.

Match the ad keyword to the H1

Quality Score and conversion rates both suffer when the ad headline and the page headline disagree. If the ad says "Same-day furnace repair," the H1 should say that, not "Trusted heating experts since 1998."

In the App Router you can keep copy in a typed map and render a server component:

// app/(ads)/lp/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { LeadForm } from '@/components/ads/LeadForm'
import { CallButton } from '@/components/ads/CallButton'

const pages = {
  'ac-repair': {
    h1: 'Same-day AC repair',
    sub: 'Licensed technicians. Honest service area. Call now or request a callback.',
    bullets: [
      'Diagnostic visits for no-cool and weak airflow',
      'Clear pricing before work starts',
      'Serving the cities listed below',
    ],
  },
  // add furnace-replacement, emergency-plumbing, etc. the same way
} as const

type Slug = keyof typeof pages

export function generateStaticParams() {
  return Object.keys(pages).map((slug) => ({ slug }))
}

export default async function LandingPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const page = pages[slug as Slug]
  if (!page) notFound()

  return (
    <main className="mx-auto max-w-3xl px-4 py-8">
      <h1 className="text-3xl font-semibold tracking-tight">{page.h1}</h1>
      <p className="mt-3 text-lg text-neutral-700">{page.sub}</p>
      <CallButton className="mt-6" />
      <ul className="mt-6 list-disc space-y-2 pl-5">
        {page.bullets.map((b) => (
          <li key={b}>{b}</li>
        ))}
      </ul>
      <section className="mt-10 rounded-lg border p-4">
        <h2 className="text-xl font-medium">Request a callback</h2>
        <LeadForm intent={slug} />
      </section>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

The page shell is a Server Component. Only the form and call button need client interactivity. That keeps the first HTML response meaningful for phones on slow networks, which matters for paid traffic as much as it does for organic.

Server Components vs client for forms

Forms are the usual place people slap 'use client' on an entire page. Resist that. Keep validation UX, input state, and submit handlers in a small client island. Leave headings, bullets, FAQs, and service-area copy on the server.

// components/ads/LeadForm.tsx
'use client'

import { useState, FormEvent } from 'react'

export function LeadForm({ intent }: { intent: string }) {
  const [status, setStatus] = useState<'idle' | 'sending' | 'ok' | 'error'>('idle')

  async function onSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault()
    setStatus('sending')
    const form = e.currentTarget
    const data = new FormData(form)
    data.set('intent', intent)

    try {
      const res = await fetch('/api/leads', { method: 'POST', body: data })
      setStatus(res.ok ? 'ok' : 'error')
      if (res.ok) form.reset()
    } catch {
      setStatus('error')
    }
  }

  return (
    <form onSubmit={onSubmit} className="mt-4 space-y-3">
      <input name="name" required placeholder="Name" className="w-full border p-2" />
      <input name="phone" required type="tel" placeholder="Phone" className="w-full border p-2" />
      <textarea name="message" rows={3} placeholder="What do you need?" className="w-full border p-2" />
      <button type="submit" disabled={status === 'sending'} className="w-full bg-black py-3 text-white">
        {status === 'sending' ? 'Sending…' : 'Send request'}
      </button>
      {status === 'ok' && <p>Thanks. We will call you back during business hours.</p>}
      {status === 'error' && <p>Something went wrong. Please call the number above.</p>}
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Keep fields short. Name, phone, and a short message are enough for most repair and quote intents.

Tracking without wrecking INP

Conversion tags, chat widgets, and heatmap scripts are common INP killers on landing pages. Paid traffic already pays for the click. Do not punish the tap with a main-thread traffic jam.

Practical rules: load gtag with afterInteractive or a consent-aware delay, fire conversions after a successful /api/leads response (not on every keystroke), prefer a light submit handler, and skip autoplay video, heavy carousels, and chat widgets on the ads layout unless the client truly needs them.

A thin client helper for a successful form convert:

'use client'

export function reportLeadConvert(intent: string) {
  if (typeof window === 'undefined') return
  // gtag is loaded elsewhere with afterInteractive
  const gtag = (window as unknown as { gtag?: (...args: unknown[]) => void }).gtag
  gtag?.('event', 'generate_lead', {
    event_category: 'ads_landing',
    event_label: intent,
  })
}
Enter fullscreen mode Exit fullscreen mode

Call that only after the API returns OK. Pair it with call tracking numbers that swap by campaign when the primary conversion is a phone call. Form fills matter for replacement and quote intents. They are a weak proxy for emergency repair, where the visitor is standing next to a thermostat and will tap to dial.

For broader campaign structure and negatives outside the page itself, see how we approach Google Ads management for service businesses. The page and the account have to agree on the job name, or you end up optimizing the wrong thing.

A/B friendly structure without a chaos of branches

A/B tests on landing pages fail when every variant forks the whole route tree. Prefer one route per intent, shared server sections for trust and service area, and change only the hero, CTA label, or form length. Use search params (?v=a / ?v=b) or a sticky cookie for assignment.

Example of a variant switch that stays readable:

const variant = searchParams.v === 'b' ? 'b' : 'a'

const cta =
  variant === 'b' ? 'Get a same-day callback' : 'Call now for AC repair'
Enter fullscreen mode Exit fullscreen mode

Log the variant with the lead payload so you can compare booked jobs later, not just form submits. A higher form rate with worse show-up rates is not a win.

You can assign a sticky variant with middleware and a short-lived cookie if you need even traffic. Keep experiments short and decide from call quality, not vanity metrics. When a winner is clear, bake it into the default copy and remove the branch.

Avoiding thin city clones

Agencies sometimes ship /lp/ac-repair-austin, /lp/ac-repair-round-rock, and /lp/ac-repair-cedar-park with identical body copy. That creates index bloat if the routes are crawlable, conflicting local signals, and a content debt every time hours or offers change.

Better options: one intent page with geo targets in Google Ads and an honest "Cities we serve" list; truly distinct pages only when licensing, offers, or crews differ by market; and noindex on pure paid landing URLs that should never compete with organic service pages.

If you do not drive to a town, do not list it to win the click. Angry callers cost more than that traffic looked like it was worth.

Metadata, OG, and what not to inherit

Ad landing pages still need titles and descriptions, but they should not inherit blog chrome or soft CTAs that compete with the primary action.

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const page = pages[slug as Slug]
  if (!page) return {}
  return {
    title: page.h1,
    description: page.sub,
    robots: { index: false, follow: false }, // often correct for pure paid LPs
  }
}
Enter fullscreen mode Exit fullscreen mode

Whether you noindex is a product decision. Pure paid routes often should not rank. Organic service pages that also receive ads usually deserve indexation plus careful canonicals.

A checklist before you raise spend

Before you scale a campaign that points at a Next.js landing page:

  • H1 matches the ad headline intent, not a vague brand line.
  • Phone number is tappable above the fold on a real phone.
  • Form is a small client island; the rest is server-rendered HTML.
  • Conversion tag fires after success, not on page load alone.
  • Chat and heatmaps are off or delayed on the ads layout.
  • Service area text matches what dispatch will actually accept.
  • No neighborhood clone farm unless the offers are truly different.

If the existing site is slow, cluttered, or built as a client-only shell, fix that foundation before pouring more budget into it. A free website audit is a useful first pass when you are not sure whether the page or the account is the bottleneck. Paid clicks amplify whatever is already there.

Keep the HTML honest, the client JS small, and the copy tied to one job. That is how Next.js earns its place under a Google Ads URL for a service business.

CodeHunterz Team

Top comments (1)

Collapse
 
steven_mock_afbb801174fc3 profile image
Steven Mock •

This is a useful, grounded checklist. The two points I'd underline are keeping the form as a small client island while the rest stays server-rendered, and logging the A/B variant with the lead payload so you're comparing booked jobs, not just form submits. The thin-city-clone warning is the one I see agencies skip most often — one intent page plus an honest service-area list is a much cleaner trade than a farm of near-identical routes.