DEV Community

Nathan Denier
Nathan Denier

Posted on

How We Built an SEO-First Agency Directory with Next.js App Router

I've spent the last few months building Pick an Agency — a directory to find and compare advertising agencies worldwide. Here's what I learned about building a high-performance, SEO-optimized Next.js App Router application at scale.

The Core Challenge

An agency directory has unique SEO requirements:

  • Thousands of individual agency pages (each needs unique meta, structured data, canonical)
  • Dynamic listing pages by location, service, platform, industry (combinatorial explosion)
  • Real-time filtering without losing crawlability
  • Schema.org markup for rich snippets (LocalBusiness, AggregateRating, Reviews)

Architecture Decisions

ISR Over SSR for Listing Pages

For the agency listing pages (/best-google-ads-agencies, /best-ad-agencies/united-states), we use ISR with revalidate = 3600. This gives us:

  • Fast TTFB (cached at the edge)
  • Fresh data every hour without rebuilding
  • No cold start issues
export const revalidate = 3600;

export default async function ServicePage({ params, searchParams }: Props) {
  const agencies = await cachedAgencies(JSON.stringify(filters));
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Unstable Cache for DB Queries

We use unstable_cache from Next.js to cache expensive DB queries with tagged invalidation:

export const cachedAgencies = unstable_cache(
  (filtersJson: string) => getAgencies(JSON.parse(filtersJson)),
  ["agencies-list"],
  { revalidate: 300, tags: ["agencies"] }
);
Enter fullscreen mode Exit fullscreen mode

This lets us invalidate all agency caches on data updates without a full redeploy.

Schema.org for Every Page Type

Each page type gets its own structured data:

  • Agency pages: LocalBusiness + ProfessionalService with AggregateRating and Review arrays
  • Listing pages: ItemList with the top 10 agencies
  • Location pages: Place with containedInPlace for city → country hierarchy
  • All pages: BreadcrumbList
export function AgencyStructuredData({ agency, reviews }) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": ["LocalBusiness", "ProfessionalService"],
    aggregateRating: {
      "@type": "AggregateRating",
      ratingValue: agency.overallRating,
      reviewCount: agency.totalReviews,
    },
    review: reviews.slice(0, 5).map(reviewToSchema),
  };
  return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />;
}
Enter fullscreen mode Exit fullscreen mode

Dynamic Intro Paragraphs (No Duplicate Content)

With thousands of listing pages, duplicate content is a real risk. We solved this with a hash-based sentence pool system:

function serviceIntro(serviceName: string, data: IntroData): string {
  const s = hash(serviceName);
  const opener = openers[(s) % openers.length];
  const middle = middles[(s + 7) % middles.length];
  const closer = closers[(s + 13) % closers.length];
  return `${opener} ${middle} ${closer}`;
}
Enter fullscreen mode Exit fullscreen mode

Each page gets a unique combination of sentences seeded by the page's own content — no two pages are identical, and the text includes real data points (agency count, average rating, top agency names).

Sitemap Strategy

We split sitemaps by type to keep each file manageable:

  • sitemap-agencies.xml — individual agency pages
  • sitemap-locations.xml — country and city pages
  • sitemap-services.xml — service, service+country, service+city combos
  • sitemap-platforms.xml — platform pages
  • sitemap-blog.xml — blog articles with dynamic priority based on age

All referenced from a sitemap.xml index.

Results

Early signs are positive — Pick an Agency is indexing well across its long-tail pages. The combination of ISR, rich structured data, unique intro paragraphs, and aggressive internal linking seems to be working.

If you're building a directory-style site with Next.js, happy to answer questions in the comments.

Top comments (0)