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));
// ...
}
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"] }
);
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 + ProfessionalServicewithAggregateRatingandReviewarrays - Listing pages:
ItemListwith the top 10 agencies - Location pages:
PlacewithcontainedInPlacefor 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) }} />;
}
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}`;
}
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)