I have been working on an enterprise project where speed is critical. That pushed me toward static generation, aggressive caching, and a solid cache invalidation strategy. Next.js is a great fit for this because you can cache both the full route and the data layer.
Because I’m using Payload CMS, the tricky part was the initial fetch for each statically generated page. After some iteration, I landed on a single initial fetch that gathers everything needed to build a page in one go. After that, Next.js can render, cache, and serve efficiently.
This approach worked well in production builds. The first real trouble showed up in Live Preview, where static generation is bypassed and pages fetch fresh data on each request. I started seeing lag, and after investigating I noticed the network response exceeded 10 MB. Another sign was that Next.js caching stopped working because the response was too large.
That led me to a more targeted approach: fetch lean data by default, then enrich only what each block needs, in batches, while keeping payloads small enough for caching to stay reliable.
How-To Guide: Data Enrichment Approach for Payload CMS
A guide to implementing targeted data enrichment to keep payloads under 2 MB, avoid N+1 queries, and preserve performance.
Payload CMS lets you fetch related data via a depth parameter. It looks like an easy solution:
typescript
// Fetch everything by setting a high depthconst pages = await payload.find({
collection: 'pages',
depth: 5,
...
});
The problem is payload size. In the client project, a single page query with depth: 3-5 produced 20 MB or more. When Next.js tries to cache this with unstable_cache, caching can fail because there is a hard limit of 2 MB per cached value. When that happens:
Caching stops working
Every page load requires a full database query
Performance degrades
Server costs go up
The hidden cost of deep fetching
Payload size is only part of it. Deep fetching also tends to introduce performance issues that are easy to miss at first.
1. N+1 query patterns
Deeply nested relationships, especially media, can lead to query fan-out:
code
Database Query 1: Get projects
→ queries to fetch media relationships individually
→ queries for industries
→ queries for division logos
In the Featured Projects gallery, this showed up as tab switching lag because each interaction triggered extra relationship work.
2. Memory and serialization overhead
Large responses use more memory during processing, increase bandwidth, and slow down serialization and deserialization. Even if the database is fast, the request can still feel slow.
3. Cache invalidation cascades
When you cache a page built from deeply nested data, small changes can invalidate large caches. You lose precision and you pay the cost more often than you need to.
Results from this project
These were the outcomes after switching to targeted enrichment:
Metric
Before
After
Improvement
Page payload size
20 MB+
Under 2 MB
About 10x smaller
Tab switching
500 to 1000 ms
Under 50 ms
About 20x faster
Database queries
15 to 24 per page
2 to 3 plus one batch
N+1 removed
Initial page load
3 to 5 s
Under 500 ms
6 to 10x faster
Cache hits
Near zero
Consistently high
Caching works
What changed:
Reduced global page depth from 3 to 5 down to 1
Added block-specific enrichment for the relationships that actually matter
Used batch fetching for relationships
Kept Next.js caching within practical limits
Reduced repeated relationship work during interactions
Core Concepts & Mental Model
The three-step enrichment pattern
Data enrichment is a consistent three-step process:
code
Step 1: Fetch lean
- Page at depth 0 to 1 (minimal data)
- Relationships are IDs
- Typical payload: 50 to 200 KB
Step 2: Extract and deduplicate IDs
- Collect ID references from a block
- Remove duplicates with a Set
- Prepare IDs for a batch fetch
Step 3: Batch enrich
- One query for all missing data
- Build an ID to object map for fast lookup
- Replace ID references with full objects
- Keep payload comfortably below 2 MB
Do I need a deep relationship?
YES:
Does it affect more than 3 blocks?
NO: Enrich inside the block
YES: Enrich in the fetcher layer
NO:
Keep it as an ID (do not enrich)
What type of relationship?
- Media: use Pattern 1
- Direct ref: use Pattern 2 or 5 if combined
- Polymorphic: use Pattern 3
Enrichment vs alternatives
Approach
Payload size
Query time
Caching
Best for
Global depth
Very large
Medium
Often fails
Avoid
Targeted enrichment
Under 2 MB
Low
Works
Default
IDs only
Small
Very low
Works
Simple lists
Pre-compute at build
Medium
None at runtime
Works
Fully static content
Implementation Patterns
This section covers the patterns used across the codebase, with code examples and explanations.
Pattern 1: simple media enrichment
Use when a block has one or a few media fields per item, such as logo, image, or featuredImage.
In the end, this approach is less about clever fetching and more about control. Keep the initial page query lean so your caching stays reliable, then enrich only what each block needs, in batches, at the point where you actually need it. That keeps payloads predictable, avoids accidental N+1 patterns, and makes performance issues easier to spot and fix as the project grows.