Every web application I deploy at Commsult Indonesia sits behind Cloudflare. Not just for DDoS protection and the free SSL certificate — primarily for the CDN layer that reduces latency for users in Indonesia and eliminates a significant fraction of origin server load. But Cloudflare's default caching behavior is conservative: it only caches static file extensions (CSS, JS, images) and ignores HTML and API responses. Getting Cloudflare to actually cache 70-90% of your traffic requires deliberate configuration with Cache Rules. This guide covers exactly how I configure Cloudflare for static-heavy Next.js and React applications.
By default, Cloudflare caches a specific list of file extensions: images (jpg, jpeg, png, gif, webp, svg, ico), stylesheets (css), scripts (js), fonts (woff, woff2, ttf), and a few others. Anything not on this list — HTML, JSON, XML, API responses — passes through to your origin on every request. The default browser cache TTL for cached assets is 4 hours. The default edge cache TTL honors your origin's Cache-Control header. If your origin doesn't set Cache-Control: max-age=xxx, Cloudflare uses its own default (varies by plan). The free plan's default edge TTL for content without Cache-Control headers is 4 hours.
Cache Rules (formerly Page Rules — Cloudflare migrated all Page Rules to Cache Rules in 2024) let you specify which resources get cached, for how long, and with what behavior. A Cache Rule for static assets matches requests to /static/*, /_next/static/*, or /images/* and sets Edge Cache TTL to 1 year, Browser Cache TTL to 1 month, and Cache Level to Standard. These long TTLs work because Next.js content-hashes its static asset filenames — when a JS bundle changes, the filename changes, busting the cache automatically. The rule is safe to set aggressively.
A common issue I see is cache hit rates stuck at 30-40% even with rules configured. The causes: query strings creating unique cache keys for the same asset, cookies preventing caching, and vary headers forcing separate cache entries per Accept-Encoding value. To diagnose, check the CF-Cache-Status response header — MISS means the resource wasn't cached, HIT means it was served from cache, BYPASS means a rule or setting prevented caching. Use Cloudflare's Cache Analytics (available on free plan) to see hit rates by URL pattern and identify which resources are contributing most to cache misses.
From my experience: the single highest-impact Cloudflare configuration for a Next.js app is enabling Tiered Cache under Caching → Tiered Cache → Smart Tiered Cache Topology. Tiered Cache creates a hierarchy where edge PoPs (points of presence) check an upper-tier PoP before going to your origin. For popular assets, one upper-tier PoP fetches from your origin, and all lower-tier PoPs in the region fetch from the upper-tier PoP. For a site with users in Jakarta and Surabaya (served by different Cloudflare PoPs), Tiered Cache ensures the Surabaya PoP fetches from Cloudflare's Singapore hub rather than hitting your Singapore-region origin repeatedly.
A Next.js application has predictable asset patterns. Static assets under /_next/static/ are content-addressed and safe to cache for 1 year. The Next.js Image Optimization API at /_next/image generates optimized images on first request and caches them — configure Cloudflare to cache these with a 24-hour edge TTL to avoid redundant optimization runs. HTML pages require careful decisions: Next.js ISR pages have their own revalidation logic, and aggressive Cloudflare HTML caching can interfere. I recommend caching HTML only for truly static pages (blog posts, landing pages) and bypassing cache for authenticated or personalized pages.
┌──────────────────────────────────────────────────────┐
│ Cloudflare Tiered Cache Flow │
├──────────────────────────────────────────────────────┤
│ │
│ User (Jakarta) → CF Jakarta PoP │
│ ↓ MISS │
│ CF Singapore Upper Tier │
│ ↓ MISS (first time only) │
│ Origin Server (Singapore) │
│ ↑ Response cached at Singapore upper tier │
│ │
│ User (Surabaya) → CF Surabaya PoP │
│ ↓ MISS │
│ CF Singapore Upper Tier → HIT! (no origin hit) │
│ │
│ Result: origin only hit once per unique asset │
└──────────────────────────────────────────────────────┘Query strings break caching when the same content is requested with different query parameters. A JavaScript file requested as bundle.js?v=1 and bundle.js?v=2 creates two separate cache entries. Configure Cache Rules to ignore query strings for static assets using the 'Cache Key' setting — set Query String to 'Ignore'. For pages where query strings change content (search results, filtered lists), keep query strings in the cache key or bypass cache entirely. For API endpoints, bypass cache entirely unless the response is explicitly public and identical for all users with the same parameters.
I once configured a Cloudflare Cache Rule that matched /api/* with a 5-minute edge cache TTL to reduce load on a Node.js API. The rule worked — cache hit rate jumped from 0% to 60%. But I hadn't accounted for user-specific responses: the user profile API at /api/user/profile was now returning cached responses from other users' requests. Cloudflare was caching responses that contained private data because the origin wasn't setting Cache-Control: private. Always set Cache-Control: private, no-store on any response that contains user-specific data. A Cloudflare Cache Rule should never override private responses.
For truly static assets (images, fonts, downloadable files), hosting on Cloudflare R2 rather than GCP Cloud Storage or DigitalOcean Spaces eliminates egress costs entirely — R2 has no egress fees. Assets served from R2 through Cloudflare's network are already at the edge, so they serve from Cloudflare's cache without a round trip to an origin in a specific region. For media files that don't change frequently, R2 + Cloudflare CDN is both faster and cheaper than GCS + Cloudflare CDN. I migrated our client's product image catalog (80GB) from GCS to R2 and eliminated $6.80/month in GCS egress costs.
# Cloudflare Cache Rule for Next.js static assets
# Rule: Cache /_next/static/* aggressively (content-hashed filenames)
#
# Expression:
# (http.request.uri.path matches "^/_next/static/")
#
# Then:
# Cache Level: Standard
# Edge Cache TTL: 1 year
# Browser Cache TTL: 1 month
# Cache Key > Query String: Ignore
# Check cache status via response header
curl -I https://mysite.com/_next/static/chunks/main.js | grep cf-cache-status
# cf-cache-status: HIT
# Purge cache via API on deployment
curl -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/purge_cache" -H "Authorization: Bearer API_TOKEN" -H "Content-Type: application/json" --data '{"purge_everything": true}'Cloudflare provides built-in analytics showing cache hit rates, bandwidth saved, and request counts by cache status. Set a goal of 70%+ cache hit rate for a static-heavy site. Below 60% usually indicates a configuration issue worth investigating. The CF-Cache-Status header in responses tells you exactly what happened for each request: HIT (served from cache), MISS (not in cache, fetched from origin), BYPASS (caching skipped due to a rule or cookie), EXPIRED (in cache but TTL expired, re-fetched from origin), and DYNAMIC (Cloudflare determined the content is dynamic and skipped caching).
I put every project behind Cloudflare from day one, even personal side projects. The free plan includes unlimited CDN bandwidth, free SSL via Let's Encrypt, DDoS protection, and basic cache rules. For the Commsult Indonesia client projects, we're on the Cloudflare Pro plan ($20/month) which adds image optimization, more Cache Rule capacity, and Web Application Firewall. The ROI is immediate: reduced origin server load means smaller VM sizes, lower GCP egress bills, and faster response times for Indonesian users who are geographically close to Cloudflare's Singapore and Jakarta edge nodes.
Sources & Further Reading