Migrating a 62000-Page Multisite from Next.js to Astro
Redesigned a university platform with over 62000 content entries by replacing a React-heavy Next.js architecture with Astro SSR, centralized GraphQL queries and aggressive caching. The result was a simpler architecture, faster perceived performance, and significantly lower frontend overhead.
Earlier this year, my colleagues and I at Kaliop worked on a redesign of the Pomeranian University in Słupsk website.
The platform consists of the main university website and multiple unit websites. Each unit uses a separate domain, but they all share the same design system. Content delivery is powered by Ibexa, with a separate siteaccess for each website. There is no business logic in the frontend layer; it serves content only.
Sounds simple, right? Now add more than 62000 content entries to the mix. And the number keeps growing every day. All of that content is handled by only 13 page mappers, some of which can render multiple content types.
The content model does not use a block editor. Everything is built with structured content fields.
When we planned the redesign, we knew Ibexa was there to stay. We could update the content structure and modify existing content types, but we had to maintain backward compatibility. The new version would be released gradually: first the main university website, then the individual unit websites.
Baseline Architecture
The previous version of the platform was built with Next.js and Ibexa. It ran as a self-hosted, SSR-only application because the university required it to stay on their infrastructure.
Data fetching was implemented through small GraphQL queries distributed across components. There was no type safety and no schema-driven development. As a result, it was difficult to understand what data was being fetched and where it was being used.
The pages themselves were not highly interactive. The most dynamic elements were dropdowns, collapsible sections, and a single hero carousel on the main website.
Core Problems
As a former student of the university, I interacted with the website frequently. Instead of relying only on analytics, I also had first-hand experience and feedback from classmates.
Poor Perceived Speed
This is where real-world usage matters.
Next.js delivered the initial page shell quickly. However, several GraphQL queries were executed afterward, resulting in loading spinners. This created the illusion of a fast website, even though users still had to wait for content.
GraphQL Inefficiency
The main data layer, the GraphQL API, was heavily fragmented and lacked type and schema safety. Queries were scattered across components, frequently duplicated, and often split into multiple requests. As a result, data was sometimes fetched without ever being used.
There were also backend inefficiencies. Expensive nested queries, such as child-node resolution in Ibexa, were executed repeatedly instead of being handled through dedicated CMS resolvers.
Result:
- High latency.
- Redundant data fetching.
- No type safety.
Architectural Mismatch
As mentioned earlier, this is a content-heavy platform. Most pages are static. Yet the entire application was built on top of Next.js and React.
Rich text content from Ibexa is already delivered as pre-rendered HTML. There is no reactivity involved – just dangerouslySetInnerHTML.
Result:
- Unnecessary hydration.
- Increased complexity.
- Little real benefit from reactivity.
- Large JavaScript bundles.
Static HTML Design
A key turning point came when we received the design implementation as static HTML with its own JavaScript.
We explored two approaches:
- Rebuild everything as React components. This worked initially until we encountered scripts that updated ARIA attributes and other values across the entire page. We could have introduced additional contexts, but that felt like solving a problem we had created ourselves. Then came the navigation menu, with deep nesting, viewport detection, and accessibility logic. Rebuilding it would have taken significantly longer than reusing the existing implementation.
- Attach the provided scripts to React-rendered components.
Both approaches introduced a problem: we would lose a single source of truth. Reimplementation would require constant synchronization between the delivered JavaScript and our React components. At that point, React no longer provided enough value to justify the additional complexity.
Next.js Rework Attempts
We initially started the redesign in Next.js. We created a fresh codebase, rewrote the GraphQL layer using code generation (not to be confused with generative AI) and type safety, and began implementing components.
After a few days, we realized the core limitations remained:
- Difficult integration of design-provided JavaScript.
- Fighting React in order to implement the designs.
- Middleware workarounds for request-based configuration.
Move to Astro SSR
When I join Astro discussions, most conversations focus on static site generation. That makes sense – Astro is excellent at it.
Our situation was different. With more than 62000 content entries, a full build would take a long time. Add editors creating new content every day, and the maintenance cost becomes difficult to justify.
The decision was simple: use SSR.
We already had a Node.js environment running on the server, so Astro SSR was a natural fit.
Astro Architecture
Request Flow
- Astro Server.
- Unit detection and assignment to
Astro.localsbased on:- Environment variables,
- Hostname matching,
- Fallback to the main university website.
- GraphQL request on catch-all route.
- Cached GraphQL response.
- HTML rendering.
- Response with cache headers.
GraphQL Strategy
We completely changed how we approached GraphQL.
Instead of relying on fragmented component-level queries, we moved to a centralized model built around fragments and a small number of core queries.
Core Queries
- find-content-by-url – Fetches content from Ibexa based on the request path. It uses content-type-specific fragments, allowing us to control data requirements centrally.
- find-section-by-id – One of our biggest performance improvements. Initially, section data was fetched together with page content. However, many pages reused the same sections. We changed the content query to fetch only the section ID and moved section loading into a dedicated query. This allowed independent caching and reuse across pages.
- site-config – A master query for global layout data, including menus, footers, and social links.
Supporting Queries
- embeds – Rich text embed resolution
- events – Calendar island data
- search – Search results
Backend Optimization
By default, Ibexa exposes child nodes through GraphQL, making it possible to fetch nested content. Unfortunately, this can be expensive and may introduce N+1 query problems.
To address this, we introduced custom GraphQL resolvers and query types.
For example, a news_container content type originally required fetching the container, resolving its child nodes, filtering news_item entries, and then resolving each item’s children. All of this happened within a single GraphQL query. Ibexa processed these nested relationships separately for every node.
Custom resolvers allowed us to flatten and optimize these operations significantly.
Caching Strategy
We did not need anything sophisticated.
The platform is not behind a global CDN, and we did not want to introduce Varnish. Browser cache TTLs therefore needed to remain relatively short.
Instead, we implemented a lightweight in-memory GraphQL cache.
GraphQL Cache
- In-memory cache implemented as an Apollo Client Link.
- Per-query caching based on operation name and variables.
- TTL-based expiration:
- Pages and content: hours.
- Site configuration: days.
- Manual invalidation from the CMS.
The first request after a deployment or cache invalidation is slower because it must fetch fresh data from Ibexa.
After that, most GraphQL requests are served directly from memory, making server-side rendering inexpensive.
It feels a bit like rediscovering ISR, except without caching rendered HTML.
Transformed Images Cache
This required a bit more experimentation.
Initially Ibexa generated image variants, but both GD and Imagick were relatively slow. Every variant had to be generated before the response could be completed, resulting in long response times.
Astro includes built-in image transformation support. During static builds, transformed images are stored in node_modules/.astro directory. However, when using the Node adapter, images are transformed on demand and are not persisted at all.
That meant every request triggered a new transformation.
So we turned to Nginx.
Nginx can cache responses directly to disk. Once Astro’s /_image endpoint returns a successful response, Nginx stores the generated image. Subsequent requests are served directly from disk instead of triggering another transformation.
proxy_cache_path /var/cache/nginx/upsl-astro-images
levels=1:2
keys_zone=upsl_astro_images:100m
max_size=10g
inactive=30d;
server {
...
location ^~ /_image {
proxy_pass http://upsl_astro;
proxy_cache upsl_astro_images;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 30d;
proxy_cache_valid 404 1m;
proxy_cache_lock on;
proxy_cache_lock_timeout 10s;
proxy_ignore_headers Set-Cookie;
proxy_no_cache $http_cookie;
proxy_cache_bypass $http_cookie;
add_header X-Cache $upstream_cache_status always;
}
} That single change eliminated repeated image-processing overhead.
Islands and JS Reduction
Before – Next.js
- Full React hydration.
- JavaScript required for entire page.
After – Astro
- Accessibility-focused vanilla JavaScript provided by the design system
- One non-critical React island – Events calendar
Result
Loading spinners disappeared.
Once cached, pages feel almost instant. We also observed significant reductions in:
- JavaScript payload size.
- Hydration cost.
- Main-thread blocking.
Developer Experience Improvements
Clear Execution Boundaries
Many developers struggle with understanding server/client boundaries in Next.js and the implications of the use client directive.
Astro provides a simpler model with explicit server and client execution boundaries. Lifecycle behavior is also easier to reason about: a request arrives, a response is generated, and the request ends.
There is no streaming complexity and no need for workarounds to generate response headers. The framework embraces a straightforward request-response model.
Component Architecture
We introduced our own conventions and divided components into three namespaces.
This pattern originated in previous React and Next.js projects and has proven effective for navigation, debugging, and maintenance.
Astro Pages and Layouts
The pages/ directory handles routing.
The layouts/ directory contains page shells, layouts, and metadata partials such as GTM snippets, favicon definitions, and meta tags.
Most content API communication happens here.
Content Components
These components build on top of design components.
They understand CMS schemas, use project-specific helpers, and contain the content mappers responsible for rendering structured content.
Design Components
These are pure design components.
They have no GraphQL awareness, no CMS dependencies, and no content-type imports. Each component defines its own types and interfaces.
In theory, I could copy these components into another project and use them immediately with minimal modification.
They may depend only on other design components and Astro-provided features such as Astro's Image component.
Centralized GraphQL queries
All GraphQL queries, fragments, and transformers are colocated with Apollo Client.
We also established a strict rule: GraphQL access is allowed only in page and API routes.
The only exception is the embeds query, which remains inside the RichText component because embed references must be extracted from HTML generated by Ibexa.
Team Familiarity with Astro
There was one additional challenge.
At the start of the project, I was the only team member familiar with Astro. The rest of the developers were primarily PHP developers or had experience with Next.js and React.
Fortunately, Astro’s programming model is simple enough that most team members became productive within hours. A quick review of the documentation and a look at a few existing .astro files were enough to get started.
Results
For content-heavy, low-interactivity platforms, Astro’s SSR and islands architecture proved to be a better fit than a fully reactive frontend.
Performance
The migration resulted in a noticeably faster platform.
We reduced asset downloads by approximately 40% and lowered perceived load times from roughly 600–1000 ms with loading spinners to under 400 ms.
The only client-side re-rendering remaining is the calendar component.
The rendering pipeline became significantly simpler, and server resource usage dropped considerably. CPU and memory consumption are both lower than before.
Architectural Outcomes
- Reduced complexity.
- Predictable data flow.
- Elimination of unnecessary reactivity.
Trade-offs
Astro is not a silver bullet.
It works extremely well for content-heavy, low-interactivity platforms like this one. It also powers this blog at the time of writing.
That said, it has limitations:
- A smaller ecosystem compared to Next.js.
- Fewer SSR-focused solutions and integrations.
- More architectural decisions left to the developer.
- Giving up a fully React-driven development model.
I still enjoy working with React components, but once a project reaches the point where server and client boundaries become unclear, Astro starts looking very attractive.