We moved this site from Next.js 16 to Astro 7 because it is a content site: nearly every page was already static, yet each one loaded a client runtime it barely used. JavaScript per page fell from 215.6 KB to 13.5 KB gzip, about 94%. The move was also a full redesign, and our new video hero made the homepage more than three times heavier on mobile.
It also introduced two regressions we only caught by measuring: a slower Largest Contentful Paint and lost language detection. We fixed both. This note covers what the framework changed, what the redesign changed, what we broke, and when we would still choose Next.js. Next.js served this site well, and we keep offering it, because the right front layer depends on the project.
Why we moved
The site’s shape had stopped matching the framework’s strengths. Reading the old code, 35 files generated their pages statically through generateStaticParams. None used force-dynamic or revalidate. Only two parts ran at request time: the contact route, and a proxy.ts that handled locale routing through next-intl.
Interactivity was modest. framer-motion was imported in two files. React was there for 28 client-component files, across navigation, forms, providers and animated sections.
We already build content sites on Astro. Astro is the official front layer of Wordex Agentic for content sites, and we used it to migrate Buena Vida’s website from WordPress. Running our own site on it means we live with the stack we recommend, as our engineering page describes.
A redesign was already planned. With every page being rebuilt, the marginal cost of changing framework was at its lowest.
Staying on Next.js with a static export was not a drop-in alternative. That mode does not support Proxy, redirects, or Route Handlers that read the request, and the site used all three.
What changed, and what caused it
This comparison does not isolate the framework. The two versions differ in framework, design, motion, media, fonts and copy. To get one number that does isolate it, we built a control: the same static page, with no client component or island, on each framework at the versions we use. Astro shipped 0 bytes of JavaScript. Next.js 16.1.1 with React 19.2.3 shipped 129.5 KB gzip, a floor equal to 60% of what the old site loaded.
| Mobile lab median | Next.js site | Astro site | Mostly caused by |
|---|---|---|---|
| JavaScript, gzip (every page) | 215.6 KB | 13.5 KB | Framework, plus our own code |
| JavaScript, decoded (every page) | 682.6 KB | 46.0 KB | Framework, plus our own code |
| HTML, gzip, content page | 32.0 KB | 8.3 KB | Framework and copy |
| Total Blocking Time, home | 129 ms | 0 ms | Framework and motion |
| Main-thread work, home | 3.00 s | 1.33 s | Mixed |
| Page weight, service page | 388 KB | 158 KB | Mixed |
| Page weight, home | 421 KB | 1,427 KB | Redesign: video |
| Largest Contentful Paint, home | 1.06 s | 0.90 s | After the fix below |
| Lighthouse Performance, home | 99 | 100 | Too close to call |
Conditions are in How we measured. KB here means 1,000 bytes.
What the framework changed
Astro strips client-side JavaScript by default and asks you to opt in, an approach rooted in the islands architecture that Jason Miller described and credits to Katie Sylor-Miller. Our one remaining script is our own motion engine.
Next.js does more on the client by design. Its docs explain that the RSC Payload and JavaScript reconcile and hydrate the page after the HTML arrives. On our old home, inline scripts in the HTML came to 119.1 KB raw, against 2.8 KB now.
That work pays for something. Next.js prefetches and caches route payloads for fast navigation. We gave that up for full page loads, and we did not measure click-to-next-page speed, where Next.js may well win.
What the redesign changed
The hero video is 1.2 MB on mobile and 3.7 MB on desktop. It took the desktop home from 542 KB to 3.9 MB. Without it, the home weighs 268 KB, lighter than before. We treat it as a deliberate cost: it is absent from other pages, and it does not load for visitors with reduced motion or Save-Data on.
The motion script (46.0 KB) and heavier fonts (62 KB to 90 KB) also belong to the redesign.
What is still worse
- Page weight on the home, as above, and the Wordex Agentic page, where a large product screenshot sets mobile LCP at 2.55 s in Lighthouse and 2.13 s under real throttling. We have not optimised that page yet, so the LCP result in the next section applies to the home.
- Production packages went up, from 162 to 262. Disk use fell from 714 MB to 248 MB and direct dependencies from 28 to 9, but part of that is tooling the redesign dropped. In the controls, Astro pulls 260 lockfile packages against 52 for Next.js, in 146 MB against 300 MB.
Scores did not decide any of this. Lighthouse Performance stayed at 97 to 100 on both sites, on localhost with simulated throttling, and its docs recommend reading scores as a distribution. Builds got faster, from a median 11.7 s to 4.4 s, but the Next.js figure includes its type check and Astro’s does not.
The LCP regression we introduced
Our first measurement showed mobile home LCP going from 1.06 s to 2.40 s. We suspected the video. Blocking it left the number at 2.40 s, so we stopped guessing and traced the page under real network and CPU throttling.
The trace told a different story. The LCP element was the lead paragraph under the headline, painting at 1,624 ms against 992 ms on the old site, while first paint came at about 800 ms. That paragraph sat inside a block with a delayed fade:
.hero__side { animation: fadeUp 1s var(--ease) .55s both; }
Chromium excludes elements with an opacity of 0 from LCP, so a fade delays the metric by exactly as long as your largest text is hidden, and readers wait with it. The hero image does not rescue it: the same guidance excludes elements that cover the full viewport.
We kept the movement and removed the hiding. The lead now settles into place from first paint, and only the call to action fades:
.hero__lead { animation: settle 1s var(--ease) both; }
.hero__cta { animation: fadeUp 1s var(--ease) .55s both; }
@keyframes settle { from { transform: translateY(14px); } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(14px); } }
We also gave screens up to 810 px a 960 px poster, the same width as the video served there. It weighs 53 KB against 108 KB.
| Mobile home, median of 5 | Before the fix | After the fix |
|---|---|---|
| Real throttling, observed LCP | 1,624 ms | 784 ms |
| Lighthouse, simulated LCP | 2,403 ms | 903 ms |
| Lighthouse Performance | 98 | 100 |
Lighthouse overstated the problem by about 780 ms. Its simulation named the header logo as the LCP element and estimated from there. Once the lead painted first, its estimate and the trace came close. We can’t fully explain the gap, so we treat the trace as the evidence and the score as the alarm that sent us looking.
The lesson we took: animate your largest text with transform, never with a delayed opacity. Keep fades for what readers don’t need first. And when a lab number moves, trace it before you believe a cause, including your own first guess.
Language detection: lost and restored
The old proxy sent Accept-Language: es visitors from / to /es. A static root doesn’t negotiate, so the move dropped that behaviour, and nothing failed. We only noticed while comparing the two sites during measurement. Our URL check could not catch it, because every URL still answered.
We restored it without a function, as declarative routes in the build output:
const LOCALE_COOKIE = 'cx_lang';
// First language is Spanish at full quality: es, es-MX or es;q=1, but not en,es or es;q=0.8.
const ACCEPT_LANGUAGE_PREFERS_ES = '^\\s*(?:[eE][sS])(?:-[a-zA-Z0-9]+)*\\s*(?:;\\s*[qQ]=1(?:\\.0{0,3})?\\s*)?(?:,|$)';
const VARY = 'Accept-Language, Cookie, Sec-Fetch-Site';
export function localeRoutes() {
return [
{ src: '^/$', headers: { Vary: VARY }, continue: true },
{
src: '^/$',
status: 302,
headers: { Location: '/es', Vary: VARY },
has: [{ type: 'header', key: 'accept-language', value: { re: ACCEPT_LANGUAGE_PREFERS_ES } }],
missing: [
{ type: 'cookie', key: LOCALE_COOKIE, value: 'en' },
{ type: 'header', key: 'sec-fetch-site', value: 'same-origin' },
],
},
];
}
The trade-offs we accepted:
- A 302, not a 301. The answer depends on the visitor, so it must not be remembered as permanent.
Varyon both responses. The same value should appear on every response for a URL, so the English page declares it too. Listing these headers can split shared caches into more entries. We have not measured that effect.- A deliberately narrow rule. Spanish must come first at full quality, so
en-US,en;q=0.9,es;q=0.5stays in English. A rarees;q=0.8alone also stays in English. A regular expression can’t compare quality values, and English is the safe answer. - A cookie, the site’s first. The language switch sets
cx_langfor a year, so a visitor who picks English is never sent back. It holds only that choice, and our privacy notice now says so. Clicks from inside the site are also exempt throughSec-Fetch-Site, which covers visitors without JavaScript.
A crawler sending no Accept-Language gets English, and canonical and hreflang tags did not change. We confirmed the behaviour on production on the deployed routes: es-MX and es-ES get the 302, query strings such as ?utm_source=x survive it, and an English-first header, no header, cx_lang=en and same-origin requests all get a 200.
How we migrated
- Freeze the URLs first. We saved the 102 sitemap URLs from the Next.js site, plus two older article URLs, as a plain text file. It became the contract.
- Write the redirect map. Eight retired products get a 301 to
/productsin both languages. Astro’sredirectsconfig defaults to 301, and supported adapters write the rules into the host’s configuration. Google treats a permanent redirect as a signal that the target should be canonical. - Keep the language URLs. English stays at the root and Spanish under
/es, using Astro’s i18n routing withprefixDefaultLocale: false, the same shape next-intl’sas-neededgave us. - Move content into collections. A 600-line content module built on gray-matter and remark gave way to content collections with Zod schemas. A page missing a field in either language now fails the build.
- Port the contact endpoint with tests first, and rebuild motion without React.
- Gate every change.
npm run release:checkruns the type check, tests, build and URL comparison.
Verifying URL preservation
The check reads the build output, so it tests what the platform will receive. Each old URL gets exactly one result:
export function checkUrl(url, { isPage, redirects }) {
if (isPage(url)) return { kind: 'page' };
const redirect = redirects.get(url);
if (!redirect) return { kind: 'problem', reason: 'no page, no redirect' };
if (redirect.status !== 301) return { kind: 'problem', reason: `status ${redirect.status}` };
const destination = redirect.headers.Location;
if (redirects.has(destination)) return { kind: 'problem', reason: `chain through ${destination}` };
if (!isPage(destination)) return { kind: 'problem', reason: `destination ${destination} is not a page` };
return { kind: 'redirect' };
}
Result: 104 old URLs, 86 pages, 18 single redirects, 0 problems. Another step fails if the new sitemap lists a redirect or a missing page.
The contact function
Astro prerenders everything by default. One endpoint opts out, and it needs an adapter. This is the whole route:
import type { APIRoute } from 'astro';
import { Resend } from 'resend';
import { createContactHandler, type ContactEnv, type SendMail } from '@/lib/contact';
export const prerender = false;
const env = process.env as ContactEnv;
const send: SendMail = async (mail) => {
const { error } = await new Resend(env.RESEND_API_KEY).emails.send(mail);
return { error };
};
const handle = createContactHandler({ env, send });
export const POST: APIRoute = ({ request }) => handle(request);
The origin check, in-memory rate limit, honeypot, Zod validation and HTML escaping moved across, into a handler free of framework and email provider, which is what made every protection testable. The rate limit stays best effort, per serverless instance, as it was before.
Motion: a script that runs once
The new motion engine is one classic deferred script of about 900 lines, with ordinary page navigations. Each click loads a new document, so the browser discards listeners, observers and animation frames. The script has no teardown code, because it never outlives its page. It also only ever takes things away and animates them back, so if it fails, the whole page stays visible.
In a client-routed React app, the document survives navigation, so each effect needs a cleanup that mirrors its setup. Our old smooth-scroll provider did exactly this:
useEffect(() => {
const query = window.matchMedia(REDUCED_MOTION);
let lenis: Lenis | null = null;
let frame = 0;
const raf = (time: number) => {
lenis?.raf(time);
frame = requestAnimationFrame(raf);
};
const start = () => {
if (lenis || query.matches) return;
lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
frame = requestAnimationFrame(raf);
};
const stop = () => {
cancelAnimationFrame(frame);
lenis?.destroy();
lenis = null;
};
const onChange = () => (query.matches ? stop() : start());
start();
query.addEventListener("change", onChange);
return () => {
query.removeEventListener("change", onChange);
stop();
};
}, []);
That’s correct code, and porting an engine of this size would have meant writing a lot of it.
Pitfalls to avoid
Vercel’s framework preset is project-wide. Changing it would also have changed how production, still on Next.js, was built while we worked on a branch. A vercel.json overrides the preset per deployment, so only the branch carrying the file builds as Astro:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "astro",
"buildCommand": "astro build"
}
Previews can email real people. Every push to a branch creates a preview with a working form. Our endpoint resolves the recipient from the environment. Production uses the real inbox. Everywhere else it can only reach a dedicated test address:
export function recipientFor(env: ContactEnv): string | null {
if (env.VERCEL_ENV === 'production') {
return env.CONTACT_TO_EMAIL || env.NEXT_PUBLIC_CONTACT_EMAIL || FALLBACK_RECIPIENT;
}
return env.CONTACT_PREVIEW_TO_EMAIL || null;
}
When that returns null, the handler sends nothing and answers { ok: true, demo: true }, so the form states it is a demo.
robots.txt doesn’t know its hostname. A static file serves the same rules on every host, and Vercel’s automatic noindex covers previews, not your current production deployment. A small build integration adds X-Robots-Tag: noindex for *.vercel.app hosts.
Test browsers aren’t phones. On our setup, the Playwright WebKit build crashed on any video source, even on a bare test page. It and headless Firefox also lacked an H.264 decoder. So the hero now checks canPlayType before it loads the file, which is a better fallback for real browsers too. The cost is a testing limit: those runs exercised the poster, not playback, and a real iPhone had to be checked by hand.
When Next.js is still the better choice
We would pick Next.js again for:
- App-like products, where screens share client state and navigation should feel continuous.
- Authenticated, per-user pages, where most responses depend on the session.
- Heavy server logic close to the UI, using Server Functions, Proxy and Cache Components as one coherent system.
- Teams invested in React, whose components, tests and hiring all assume it. Astro can host React islands, but a mostly interactive app inside islands works against the grain.
A decision framework you can reuse
Ask these in order, on your actual pages rather than your preferences:
- What share of your pages changes per request or per user? High: stay on a server-first application framework. Near zero: continue.
- What needs client state across navigations? A persistent player, cart or editor points to client routing. Isolated widgets point to islands.
- What does your framework do at the edge without you noticing? List proxy rules, language negotiation and redirects, and decide what to rebuild. We missed language detection on the first pass.
- What is your framework floor? Build the one-page control we describe. It takes minutes and shows what you pay before your own code.
- Can you separate the migration from any redesign? If you want attributable numbers, migrate first and redesign later. We did both together, so ours are only partly attributable.
How we measured
Measured on 2 November 2026 from commits 2988c6d (Next.js 16.1.1, React 19.2.3) and 74a590b (Astro 7.4.4), each from a clean checkout.
- Machine: AMD Ryzen 7 8700F, 16 GB RAM, Ubuntu 26.04 on WSL2, Node 24.19.0.
- Serving: localhost over HTTP/1.1 with gzip;
next startfor Next.js and a small static server for Astro. No CDN, so Vercel’s edge is not included. - Lighthouse: 13.6.0 on Chrome for Testing 156, simulated throttling, mobile and desktop presets, five alternating runs per page with the median reported.
- Traces: the same mobile profile applied as real throttling in Chromium (150 ms latency, 1.6 Mbps, CPU 4x), five runs, reading
PerformanceObserverLCP entries. - Pages: home,
/services/ai-automationand/insights/elixir-rust-vector-tile-engine, plus/es,/products/wordex-agenticand/contact. Their content differs between versions. - Builds: five cold runs each, median of wall time.
- Not measured: real users, real networks and INP, because field data is still pending (lab and field data measure different things), and the site went live on 2 November 2026. Also navigation between pages, Vercel’s edge, the cache effect of
Vary, and the same design on both frameworks. Language routing was checked on production by request; the other figures are lab only.
Weighing the same move?
If you run a content site on Next.js, or a WordPress site you’d like to leave, bring us your pages and what they must keep doing. We’ll talk through what a move would preserve, what it would cost, and whether it’s worth doing. You can also ask for a demo of Wordex Agentic.
