2025/06/12
Rearchitecting Moneyfarm's website using Next.js, RSC, and Edge Caching
- The Setup: Next.js in Standalone Mode with Docker
- Leveraging RSC for Smart Server-Side Rendering
- Orchestrating cache at the edge level with AWS CloudFront
When I redesigned the architecture of Moneyfarm’s site, I aimed for something that would be highly performant, cost-effective, and simple to maintain — without compromising flexibility. Here’s how I built it using Next.js, React Server Components (RSC), Docker, and AWS CloudFront. My goal throughout was to keep the setup as cost-efficient as possible, without relying on anything that would introduce vendor lock-in or require complex in-house caching solutions. I also had the requirement to let content editors update the site rapidly, whereas our prior solution required to redeploy the entire site for every content change (and we’re talking ~10 minutes deploy time 🫠).
This, however, didn’t come without some gotchas, so let’s dive into the details.
The Setup: Next.js in Standalone Mode with Docker
We use Next.js running in standalone mode, packaged in a lightweight Docker
container. I specifically chose experimental-build-mode=compile, a Next.js flag
that enables the app to compile and serve pages on the fly without pre-exporting
or static generation.
Gotcha #1: This mode is experimental, and it comes with its own quirks and headaches. Vercel is known for breaking stuff pretty often, and this mode is the epitome of fighting against the grain. The biggest issue was that client-side environment variables don’t get inlined in the compiled output, so I had to manually inject them isomorphically into the HTML response by using a custom script tag in the main layout component. By inspecting the output code from the build step, I found that the
NEXT_PUBLIC_environment variables are referenced in the client-side code aswindow.NEXT_PUBLIC_XY, whereXYis the variable name. So I had to inject a script tag that sets this variable in the globalwindowobject. It looks like this:<Script type="text/javascript" strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: `window.NEXT_PUBLIC_XY = "${process.env.XY}";`, }} />
This means every request is dynamically rendered from scratch — no build-time HTML, no getStaticProps, no ISR.
This also allows us to build Docker images that are small and fast to deploy, since we only include the necessary files and dependencies. The build step lasts less than a minute, and the resulting image is pretty lean, containing only the compiled Next.js app’s core and its dependencies.
We’re deploying 2x2 containers (1 for each region, IT and UK, and 1 for each environment, pre-preoduction and production), which gives us a total of 4 containers running in production. Of course we also have a staging environment, but that’s not relevant here.
The production deployment is going to get its traffic cached at the edge by AWS CloudFront, while the private, pre-production environment is not cached, so each request is rendered from scratch and lets it get used to check the latest changes before they go live.
Why not cache at the application level?
Caching at the application level for public websites makes absolutely no sense. CDNs are designed to handle this, and they do it better than any in-house solution. Here’s why:
- No need to bloat server memory with cached data.
- Leveraging edge caching means faster response times and lower latency by serving content closer to users.
- It scales better than any internal cache layer, especially for high-traffic sites. You can keep one server running, and let the CDN serve the cached content globally.
That’s why I architected our solution to offload all caching to the edge, using AWS CloudFront. It’s faster (closer to users), cheaper (Amazon bills less for edge compute than EC2 or containers), and scales better than any internal cache layer.
Gotcha #2: Next.js, when all pages are set to be rendered from scratch on every request (
export const dynamic = "force-dynamic";), can be a bit of a pain to work with. It automatically sets theCache-Controlheader tono-store, which means CloudFront won’t cache the HTML responses by default. To fix this, I patched Next.js’ code usingpnpm patchto set theCache-Controlheader to my desired value, regardless of thedynamicsetting.
Leveraging RSC for Smart Server-Side Rendering
React Server Components (RSC) are a game-changer. They let me:
- Build complex, data-rich UI on the server without bloating the client bundle.
- Keep responses deterministic and cache-friendly.
- Avoid hydration penalties and unnecessary re-renders on the client.
Every page request is handled server-side, built using RSC logic, and returned as HTML + partial hydration. Since pages don’t change per user it’s a public website, they’re perfect candidates for full-page edge caching.
This sometimes requires some creative thinking, especially when dealing with interactive components. This has been a really fun challenge, where I’ve sharpened my CSS skills to do as much as possible on the client side, while keeping the server-side logic clean and focused on data fetching and rendering.
Orchestrating cache at the edge level with AWS CloudFront
We configure CloudFront to:
- Cache entire HTML responses based on pathname and query string.
- Use long TTLs (hours or days), depending on page volatility.
- Invalidate aggressively when we deploy or detect backend data changes.
This results in blazing-fast response times (tens of milliseconds), zero load on the app container for most traffic, and the flexibility to purge or bypass cache when needed.
The best part? Our app container stays lean. It doesn’t worry about cache control or heavy internal logic. It just serves HTML, fast and statelessly.
Gotcha #3: CloudFront invalidations need to be triggered from somewhere, right? I implemented a custom Strapi middleware that listens for content changes and triggers the invalidation via AWS SDK. When repeatable content is updated, it automatically invalidates the cache for the related slug, ensuring that the next request fetches the latest version. When sitewide content changes occur (e.g.: banners, footers), it can invalidate all relevant cache entries in one go.
Summary
Here’s what this architecture looks like in practice:
- Next.js app in Docker, using experimental-build-mode=compile.
- Pages are rendered on every request using React Server Components.
- No internal caching — just clean server logic and smart components.
- CloudFront caches everything: HTML and static assets.
- Result: Low infra cost, high performance, global scalability, and simple deployment.
This setup flips traditional wisdom on its head: instead of optimizing the server to cache and reuse, I made the server as dumb as possible, and pushed all optimization to the network edge.