{"schemaVersion":1,"id":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections","title":"Content Layer API, Server Islands, and View Transitions in Astro 5","description":"An analysis of Astro 5's content layer API, server islands architecture, and view transitions, with production benchmarks comparing performance against Next.js for content-heavy sites.","language":"en","datePublished":"2026-02-21","dateModified":null,"contentFormat":"text/markdown","content":"Frontend\n\n# Content Layer API, Server Islands, and View Transitions in Astro 5\n\n2026-02-219 min read\n\n[Lucio Durán](<https://lucioduran.com/>)\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/astro-5-view-transitions-content-collections>)\n\n## The Content Layer API: Loader-Based Data Pipeline\n\nThe content collections in [Astro](<https://astro.build/>) 4 worked adequately for basic use cases. Markdown files went into ` src/content/ `, a schema was defined in ` config.ts `, and Astro handled the rest. The limitation was that \"the rest\" was opaque. Custom sources required significant effort. Remote content required workarounds. Mixed sources were not well supported.\n\nAstro 5's content layer API rethinks this entirely. Instead of a rigid directory-based system, you define **loaders**, functions that can pull content from anywhere:\n\n```typescript\n// src/content.config.ts\nimport { defineCollection, z } from 'astro:content';\nimport { glob } from 'astro/loaders';\nimport { createNotionLoader } from './loaders/notion';\n\nconst blog = defineCollection({\n loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),\n schema: z.object({\n title: z.string(),\n date: z.coerce.date(),\n draft: z.boolean().default(false),\n tags: z.array(z.string()).default([]),\n }),\n});\n\nconst changelog = defineCollection({\n loader: createNotionLoader({\n databaseId: process.env.NOTION_DB_ID,\n filter: { property: 'Status', select: { equals: 'Published' } },\n }),\n schema: z.object({\n title: z.string(),\n version: z.string(),\n body: z.string(),\n }),\n});\n\nexport const collections = { blog, changelog };\n```\n\nThe ` glob ` loader replaces the old implicit file scanning. But the real power is writing custom loaders. Here is an example Notion loader for a changelog:\n\n```typescript\n// src/loaders/notion.ts\nimport { Client } from '@notionhq/client';\n\nexport function createNotionLoader(opts) {\n const notion = new Client({ auth: process.env.NOTION_TOKEN });\n\n return {\n name: 'notion-loader',\n async load({ store, logger }) {\n logger.info('Fetching from Notion...');\n const { results } = await notion.databases.query({\n database_id: opts.databaseId,\n filter: opts.filter,\n });\n\n store.clear();\n\n for (const page of results) {\n const blocks = await notion.blocks.children.list({\n block_id: page.id,\n });\n\n const body = blocksToMarkdown(blocks.results);\n const props = page.properties;\n\n store.set({\n id: page.id,\n data: {\n title: props.Name.title[0]?.plain_text ?? '',\n version: props.Version.rich_text[0]?.plain_text ?? '',\n body,\n },\n });\n }\n },\n };\n}\n```\n\nThe ` store ` object is the key abstraction. It's a persistent content store that Astro manages between builds. In dev mode, the loader's ` load() ` function gets called on file changes (for file-based loaders) or on server restart (for remote loaders). During build, it runs once and caches. This means incremental builds actually work, if your Notion content hasn't changed, Astro can skip the fetch entirely using the store's digest mechanism.\n\nProfiling on a docs site with 2,400 pages pulling from both Markdown files and a headless CMS shows build times dropping from 47 seconds (Astro 4, refetching everything) to 12 seconds on cache hit. That is not a typo.\n\n## Server Islands: The Partial Hydration Endgame\n\nAstro's been doing partial hydration since v1 with ` client:load `, ` client:idle `, ` client:visible `. That was about **when** to hydrate a component. Server islands are about **where** to run it.\n\nThe concept: your page is a static shell rendered at build time (or edge), with holes punched out for dynamic content that gets filled by server-rendered HTML at request time. No client-side JavaScript needed for those dynamic parts.\n\n```astro\n---\n// src/pages/product/[slug].astro\nimport ProductDetails from '../components/ProductDetails.astro';\nimport PricingPanel from '../components/PricingPanel.astro';\nimport ReviewSummary from '../components/ReviewSummary.astro';\n---\n\n<ProductDetails product={product} />\n\n<!-- This renders on the server at request time -->\n<PricingPanel server:defer productId={product.id}>\n <div slot=\"fallback\" class=\"skeleton-pricing\">\n <div class=\"skeleton-line w-32 h-8\"></div>\n <div class=\"skeleton-line w-24 h-6\"></div>\n </div>\n</PricingPanel>\n\n<ReviewSummary server:defer productId={product.id}>\n <p slot=\"fallback\">Loading reviews...</p>\n</ReviewSummary>\n```\n\nWhen the page loads, the static shell appears instantly from the CDN. Then the browser makes parallel fetch requests for each ` server:defer ` island. The server renders the component to HTML and sends it back. The browser swaps the fallback content for the real content. No hydration. No JavaScript framework runtime. Just HTML replacement.\n\nConsider an e-commerce catalog with 15,000 SKUs. The product detail pages are fully static (images, descriptions, specs) while pricing and inventory are server islands that hit the live database. The architecture looks like:\n\n```\nCDN (static shell) ──► Browser ──► Edge Function (pricing island)\n ├──► Edge Function (inventory island)\n └──► Edge Function (reviews island)\n```\n\nThe results were striking. TTFB dropped to 28ms for the static shell (CDN hit). The dynamic islands resolved in 80-120ms. Total LCP was under 200ms for the static content, with pricing appearing around 300ms. Previously, with full SSR on every request, TTFB was 400-600ms depending on database load.\n\nThere's a subtlety that the docs don't emphasize enough: server islands can have **independent cache policies**. The pricing island might be cache-busted on every request while the review summary can be cached for 5 minutes:\n\n```typescript\n// In your island's server endpoint\nexport const config = {\n cache: {\n pricing: 'no-store',\n reviews: 'public, max-age=300, stale-while-revalidate=60',\n },\n};\n```\n\nThis granularity is something you can't easily achieve with full-page SSR or ISR.\n\n## View Transitions: State Persistence and Native Navigation\n\nView transitions in Astro 5 are built on the native View Transitions API (with a fallback for Firefox/Safari), and they fundamentally change how MPAs feel. A static site navigates like an SPA without shipping a client-side router.\n\n```astro\n---\n// src/layouts/Base.astro\nimport { ViewTransitions } from 'astro:transitions';\n---\n<html>\n <head>\n <ViewTransitions />\n </head>\n <body>\n <nav transition:persist=\"main-nav\">\n <!-- Navigation persists across page transitions -->\n </nav>\n <main transition:animate=\"slide\">\n <slot />\n </main>\n </body>\n</html>\n```\n\nThe ` transition:persist ` directive is where it gets genuinely useful. Consider a music player component on a portfolio site that needs to keep playing across page navigations. In a traditional MPA, every navigation kills the audio element. With ` transition:persist `, the component's DOM node survives the transition:\n\n```astro\n<AudioPlayer\n transition:persist=\"audio-player\"\n client:load\n currentTrack={track}\n/>\n```\n\nThe player keeps playing. The audio context isn't interrupted. The visualizer animation continues. It just works. This was previously only possible with an SPA architecture, which meant shipping a router, a framework runtime, and managing client-side state.\n\nNavigation performance measurements on a 200-page documentation site show the difference clearly. Traditional MPA navigation (full page load): 180-350ms depending on page weight. With view transitions: 60-90ms perceived navigation time, because Astro prefetches the next page on hover and morphs the DOM.\n\n## Hybrid Rendering: Pick Your Poison Per Route\n\nAstro 5 lets you mix rendering modes at the route level. Your marketing pages can be fully static, your dashboard can be server-rendered, and your API endpoints can run on the edge:\n\n```typescript\n// astro.config.mjs\nimport { defineConfig } from 'astro/config';\nimport node from '@astrojs/node';\nimport vercel from '@astrojs/vercel';\n\nexport default defineConfig({\n output: 'static', // default: static\n adapter: vercel({\n imageService: true,\n }),\n});\n```\n\nThen per-page:\n\n```astro\n---\n// src/pages/dashboard.astro\n// This page opts into server rendering\nexport const prerender = false;\n\nconst session = await getSession(Astro.cookies);\nif (!session) return Astro.redirect('/login');\n---\n```\n\nEverything is static by default. You opt individual routes into SSR with ` export const prerender = false `. This is the inverse of [Next.js](<https://nextjs.org/>)'s approach where you're in SSR-land by default and opt into static generation.\n\n## Performance Comparison with Next.js\n\nThe following data comes from comparable production content sites:\n\n| Metric | Astro 5 (docs site, 800 pages) | Next.js 15 (docs site, 650 pages) |\n| --- | --- | --- |\n| Build time | 18s | 94s |\n| JS shipped (homepage) | 12KB | 87KB |\n| TTFB (CDN) | 22ms | 31ms |\n| LCP (mobile 3G) | 1.2s | 2.1s |\n| TTI (mobile 3G) | 1.4s | 3.8s |\n\nThe build time difference is largely because Astro doesn't need to bundle a client-side React runtime for every page. The JS difference is the big one, 12KB vs 87KB matters enormously on constrained networks.\n\nBut this comparison is unfair if the site is heavily interactive. The Next.js site in this comparison had interactive search, client-side filtering, and a feedback widget on every page. Astro can do all of that (with React islands), but once you have 5+ interactive components per page, the island architecture starts losing its advantage because you're shipping React anyway.\n\nA practical rule of thumb: if more than 30% of the page surface area is interactive, stick with Next.js. Below that threshold, Astro's island architecture delivers measurably better performance.\n\n## Migration from Next.js: Common Pitfalls\n\nMigrating a Next.js 14 blog (340 posts, MDX, custom components) to Astro 5 reveals several common pitfalls:\n\n**MDX component imports**: Next.js auto-imports components via ` mdx-components.tsx `. Astro needs explicit imports in each MDX file or a remark plugin to inject them. A remark plugin solves this:\n\n```javascript\n// remark-auto-import.mjs\nexport function remarkAutoImport() {\n return (tree) => {\n const imports = [\n \"import CodeBlock from '../../components/CodeBlock.astro';\",\n \"import Callout from '../../components/Callout.astro';\",\n ];\n\n tree.children.unshift({\n type: 'mdxjsEsm',\n value: imports.join('\\n'),\n data: {\n estree: {\n type: 'Program',\n body: imports.map((imp) => ({\n type: 'ImportDeclaration',\n specifiers: [/* parsed from import string */],\n source: { type: 'Literal', value: imp.match(/from '(.+)'/)[1] },\n })),\n sourceType: 'module',\n },\n },\n });\n };\n}\n```\n\n**Image optimization**: Next.js ` <Image> ` component doesn't translate 1:1. Astro's ` <Image> ` from ` astro:assets ` handles local images well but remote images need explicit ` domains ` configuration in ` astro.config.mjs `. Sites with 200+ posts using remote images need the domains allowlist populated.\n\n**RSS feed**: Next.js doesn't have built-in RSS. Astro's ` @astrojs/rss ` package is excellent but the content layer migration meant RSS generation scripts need rewriting to use the new ` getCollection() ` API.\n\nBuild time went from 94 seconds to 14 seconds. Bundle size per page dropped from ~90KB to ~8KB (only pages with interactive components ship JS). Core Web Vitals across the board improved, LCP median dropped from 1.8s to 0.9s on mobile.\n\n## What's Still Missing\n\nAstro 5 isn't perfect. The dev server HMR is slower than Next.js for large sites (500+ pages). The ecosystem of pre-built components is smaller. If you need middleware-heavy patterns (auth, A/B testing, feature flags on every request), the server island model is less ergonomic than Next.js middleware.\n\nThe TypeScript experience is also slightly behind. Next.js's type inference for ` getStaticProps ` and server components is tighter. Astro's TypeScript support has improved massively, but you'll occasionally hit ` any ` types in the content layer API that Next.js would catch.\n\nStill, for content sites, genuinely content-driven sites where the primary job is rendering text, images, and structured data, Astro 5 stands out as the strongest tool in the category. The content layer API is the data pipeline content sites have always needed, server islands solve the dynamic-content-in-static-pages problem elegantly, and view transitions make the whole thing feel like a modern SPA without the JavaScript tax.\n\nBuilding one page with the new content layer, measuring the build times, and checking Lighthouse scores provides sufficient data for an informed decision.\n\n## Frequently asked questions\n\n### Is Astro 5 a drop-in replacement for Astro 4?\n\nNot quite. The content layer API replaces the old content collections with a loader-based system. If you're using defineCollection with type: 'content', you'll need to migrate to the new loader pattern. The CLI migration tool handles about 80% of cases, but custom remark/rehype plugins may need manual adjustments.\n\n### Can Astro 5 replace Next.js for all use cases?\n\nNo. Astro excels at content-driven sites where most pages are static or lightly dynamic. If you need heavy client-side interactivity, complex auth flows, or real-time features on every page, Next.js with React Server Components is still the better fit. The sweet spot for Astro is blogs, docs, marketing sites, and portfolios with selective interactivity.\n\n### How do server islands affect caching strategies?\n\nEach server island is fetched independently, so you can set different cache policies per island. Static shells get full CDN caching while dynamic islands bypass it. Use Cache-Control headers on your island endpoints and consider stale-while-revalidate for islands that can tolerate slightly stale data.\n\n## Tips\n\n1. **Tip:** When migrating from Astro 4 content collections, don't try to rewrite your ` config.ts ` all at once. Use ` defineCollection() ` with the new ` loader ` property on one collection first, verify the build, then proceed. The old glob-based approach still works during the transition period.\n2. **Tip:** Server islands with ` server:defer ` are tempting to slap everywhere, but each one adds a network waterfall on the client. Profile your actual usage with ` performance.mark() ` before and after, More than 3 deferred islands per page tends to start hurting LCP on 3G connections.\n3. **Tip:** For view transitions, always set ` transition:persist ` on your audio/video elements and any component holding WebSocket connections. Without this, live notification badges reset on navigation because the DOM gets nuked on every transition.\n\n- astro\n- view-transitions\n- content-collections\n- server-islands\n- hybrid-rendering\n- nextjs\n- frontend-architecture\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Content Layer API, Server Islands, and View Transitions in Astro 5","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#title"},{"level":2,"text":"The Content Layer API: Loader-Based Data Pipeline","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#the-content-layer-api-loader-based-data-pipeline"},{"level":2,"text":"Server Islands: The Partial Hydration Endgame","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#server-islands-the-partial-hydration-endgame"},{"level":2,"text":"View Transitions: State Persistence and Native Navigation","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#view-transitions-state-persistence-and-native-navigation"},{"level":2,"text":"Hybrid Rendering: Pick Your Poison Per Route","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#hybrid-rendering-pick-your-poison-per-route"},{"level":2,"text":"Performance Comparison with Next.js","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#performance-comparison-with-nextjs"},{"level":2,"text":"Migration from Next.js: Common Pitfalls","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#migration-from-nextjs-common-pitfalls"},{"level":2,"text":"What's Still Missing","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#whats-still-missing"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#questions"},{"level":3,"text":"Is Astro 5 a drop-in replacement for Astro 4?","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#question-1"},{"level":3,"text":"Can Astro 5 replace Next.js for all use cases?","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#question-2"},{"level":3,"text":"How do server islands affect caching strategies?","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#article-tips"}],"sections":[{"heading":"Content Layer API, Server Islands, and View Transitions in Astro 5","level":1,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections","content":"Frontend"},{"heading":"Content Layer API, Server Islands, and View Transitions in Astro 5","level":1,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#title","content":"2026-02-219 min read\n\n[Lucio Durán](<https://lucioduran.com/>)\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/astro-5-view-transitions-content-collections>)"},{"heading":"The Content Layer API: Loader-Based Data Pipeline","level":2,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#the-content-layer-api-loader-based-data-pipeline","content":"The content collections in [Astro](<https://astro.build/>) 4 worked adequately for basic use cases. Markdown files went into ` src/content/ `, a schema was defined in ` config.ts `, and Astro handled the rest. The limitation was that \"the rest\" was opaque. Custom sources required significant effort. Remote content required workarounds. Mixed sources were not well supported.\n\nAstro 5's content layer API rethinks this entirely. Instead of a rigid directory-based system, you define **loaders**, functions that can pull content from anywhere:\n\n```typescript\n// src/content.config.ts\nimport { defineCollection, z } from 'astro:content';\nimport { glob } from 'astro/loaders';\nimport { createNotionLoader } from './loaders/notion';\n\nconst blog = defineCollection({\n loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),\n schema: z.object({\n title: z.string(),\n date: z.coerce.date(),\n draft: z.boolean().default(false),\n tags: z.array(z.string()).default([]),\n }),\n});\n\nconst changelog = defineCollection({\n loader: createNotionLoader({\n databaseId: process.env.NOTION_DB_ID,\n filter: { property: 'Status', select: { equals: 'Published' } },\n }),\n schema: z.object({\n title: z.string(),\n version: z.string(),\n body: z.string(),\n }),\n});\n\nexport const collections = { blog, changelog };\n```\n\nThe ` glob ` loader replaces the old implicit file scanning. But the real power is writing custom loaders. Here is an example Notion loader for a changelog:\n\n```typescript\n// src/loaders/notion.ts\nimport { Client } from '@notionhq/client';\n\nexport function createNotionLoader(opts) {\n const notion = new Client({ auth: process.env.NOTION_TOKEN });\n\n return {\n name: 'notion-loader',\n async load({ store, logger }) {\n logger.info('Fetching from Notion...');\n const { results } = await notion.databases.query({\n database_id: opts.databaseId,\n filter: opts.filter,\n });\n\n store.clear();\n\n for (const page of results) {\n const blocks = await notion.blocks.children.list({\n block_id: page.id,\n });\n\n const body = blocksToMarkdown(blocks.results);\n const props = page.properties;\n\n store.set({\n id: page.id,\n data: {\n title: props.Name.title[0]?.plain_text ?? '',\n version: props.Version.rich_text[0]?.plain_text ?? '',\n body,\n },\n });\n }\n },\n };\n}\n```\n\nThe ` store ` object is the key abstraction. It's a persistent content store that Astro manages between builds. In dev mode, the loader's ` load() ` function gets called on file changes (for file-based loaders) or on server restart (for remote loaders). During build, it runs once and caches. This means incremental builds actually work, if your Notion content hasn't changed, Astro can skip the fetch entirely using the store's digest mechanism.\n\nProfiling on a docs site with 2,400 pages pulling from both Markdown files and a headless CMS shows build times dropping from 47 seconds (Astro 4, refetching everything) to 12 seconds on cache hit. That is not a typo."},{"heading":"Server Islands: The Partial Hydration Endgame","level":2,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#server-islands-the-partial-hydration-endgame","content":"Astro's been doing partial hydration since v1 with ` client:load `, ` client:idle `, ` client:visible `. That was about **when** to hydrate a component. Server islands are about **where** to run it.\n\nThe concept: your page is a static shell rendered at build time (or edge), with holes punched out for dynamic content that gets filled by server-rendered HTML at request time. No client-side JavaScript needed for those dynamic parts.\n\n```astro\n---\n// src/pages/product/[slug].astro\nimport ProductDetails from '../components/ProductDetails.astro';\nimport PricingPanel from '../components/PricingPanel.astro';\nimport ReviewSummary from '../components/ReviewSummary.astro';\n---\n\n<ProductDetails product={product} />\n\n<!-- This renders on the server at request time -->\n<PricingPanel server:defer productId={product.id}>\n <div slot=\"fallback\" class=\"skeleton-pricing\">\n <div class=\"skeleton-line w-32 h-8\"></div>\n <div class=\"skeleton-line w-24 h-6\"></div>\n </div>\n</PricingPanel>\n\n<ReviewSummary server:defer productId={product.id}>\n <p slot=\"fallback\">Loading reviews...</p>\n</ReviewSummary>\n```\n\nWhen the page loads, the static shell appears instantly from the CDN. Then the browser makes parallel fetch requests for each ` server:defer ` island. The server renders the component to HTML and sends it back. The browser swaps the fallback content for the real content. No hydration. No JavaScript framework runtime. Just HTML replacement.\n\nConsider an e-commerce catalog with 15,000 SKUs. The product detail pages are fully static (images, descriptions, specs) while pricing and inventory are server islands that hit the live database. The architecture looks like:\n\n```\nCDN (static shell) ──► Browser ──► Edge Function (pricing island)\n ├──► Edge Function (inventory island)\n └──► Edge Function (reviews island)\n```\n\nThe results were striking. TTFB dropped to 28ms for the static shell (CDN hit). The dynamic islands resolved in 80-120ms. Total LCP was under 200ms for the static content, with pricing appearing around 300ms. Previously, with full SSR on every request, TTFB was 400-600ms depending on database load.\n\nThere's a subtlety that the docs don't emphasize enough: server islands can have **independent cache policies**. The pricing island might be cache-busted on every request while the review summary can be cached for 5 minutes:\n\n```typescript\n// In your island's server endpoint\nexport const config = {\n cache: {\n pricing: 'no-store',\n reviews: 'public, max-age=300, stale-while-revalidate=60',\n },\n};\n```\n\nThis granularity is something you can't easily achieve with full-page SSR or ISR."},{"heading":"View Transitions: State Persistence and Native Navigation","level":2,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#view-transitions-state-persistence-and-native-navigation","content":"View transitions in Astro 5 are built on the native View Transitions API (with a fallback for Firefox/Safari), and they fundamentally change how MPAs feel. A static site navigates like an SPA without shipping a client-side router.\n\n```astro\n---\n// src/layouts/Base.astro\nimport { ViewTransitions } from 'astro:transitions';\n---\n<html>\n <head>\n <ViewTransitions />\n </head>\n <body>\n <nav transition:persist=\"main-nav\">\n <!-- Navigation persists across page transitions -->\n </nav>\n <main transition:animate=\"slide\">\n <slot />\n </main>\n </body>\n</html>\n```\n\nThe ` transition:persist ` directive is where it gets genuinely useful. Consider a music player component on a portfolio site that needs to keep playing across page navigations. In a traditional MPA, every navigation kills the audio element. With ` transition:persist `, the component's DOM node survives the transition:\n\n```astro\n<AudioPlayer\n transition:persist=\"audio-player\"\n client:load\n currentTrack={track}\n/>\n```\n\nThe player keeps playing. The audio context isn't interrupted. The visualizer animation continues. It just works. This was previously only possible with an SPA architecture, which meant shipping a router, a framework runtime, and managing client-side state.\n\nNavigation performance measurements on a 200-page documentation site show the difference clearly. Traditional MPA navigation (full page load): 180-350ms depending on page weight. With view transitions: 60-90ms perceived navigation time, because Astro prefetches the next page on hover and morphs the DOM."},{"heading":"Hybrid Rendering: Pick Your Poison Per Route","level":2,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#hybrid-rendering-pick-your-poison-per-route","content":"Astro 5 lets you mix rendering modes at the route level. Your marketing pages can be fully static, your dashboard can be server-rendered, and your API endpoints can run on the edge:\n\n```typescript\n// astro.config.mjs\nimport { defineConfig } from 'astro/config';\nimport node from '@astrojs/node';\nimport vercel from '@astrojs/vercel';\n\nexport default defineConfig({\n output: 'static', // default: static\n adapter: vercel({\n imageService: true,\n }),\n});\n```\n\nThen per-page:\n\n```astro\n---\n// src/pages/dashboard.astro\n// This page opts into server rendering\nexport const prerender = false;\n\nconst session = await getSession(Astro.cookies);\nif (!session) return Astro.redirect('/login');\n---\n```\n\nEverything is static by default. You opt individual routes into SSR with ` export const prerender = false `. This is the inverse of [Next.js](<https://nextjs.org/>)'s approach where you're in SSR-land by default and opt into static generation."},{"heading":"Performance Comparison with Next.js","level":2,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#performance-comparison-with-nextjs","content":"The following data comes from comparable production content sites:\n\n| Metric | Astro 5 (docs site, 800 pages) | Next.js 15 (docs site, 650 pages) |\n| --- | --- | --- |\n| Build time | 18s | 94s |\n| JS shipped (homepage) | 12KB | 87KB |\n| TTFB (CDN) | 22ms | 31ms |\n| LCP (mobile 3G) | 1.2s | 2.1s |\n| TTI (mobile 3G) | 1.4s | 3.8s |\n\nThe build time difference is largely because Astro doesn't need to bundle a client-side React runtime for every page. The JS difference is the big one, 12KB vs 87KB matters enormously on constrained networks.\n\nBut this comparison is unfair if the site is heavily interactive. The Next.js site in this comparison had interactive search, client-side filtering, and a feedback widget on every page. Astro can do all of that (with React islands), but once you have 5+ interactive components per page, the island architecture starts losing its advantage because you're shipping React anyway.\n\nA practical rule of thumb: if more than 30% of the page surface area is interactive, stick with Next.js. Below that threshold, Astro's island architecture delivers measurably better performance."},{"heading":"Migration from Next.js: Common Pitfalls","level":2,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#migration-from-nextjs-common-pitfalls","content":"Migrating a Next.js 14 blog (340 posts, MDX, custom components) to Astro 5 reveals several common pitfalls:\n\n**MDX component imports**: Next.js auto-imports components via ` mdx-components.tsx `. Astro needs explicit imports in each MDX file or a remark plugin to inject them. A remark plugin solves this:\n\n```javascript\n// remark-auto-import.mjs\nexport function remarkAutoImport() {\n return (tree) => {\n const imports = [\n \"import CodeBlock from '../../components/CodeBlock.astro';\",\n \"import Callout from '../../components/Callout.astro';\",\n ];\n\n tree.children.unshift({\n type: 'mdxjsEsm',\n value: imports.join('\\n'),\n data: {\n estree: {\n type: 'Program',\n body: imports.map((imp) => ({\n type: 'ImportDeclaration',\n specifiers: [/* parsed from import string */],\n source: { type: 'Literal', value: imp.match(/from '(.+)'/)[1] },\n })),\n sourceType: 'module',\n },\n },\n });\n };\n}\n```\n\n**Image optimization**: Next.js ` <Image> ` component doesn't translate 1:1. Astro's ` <Image> ` from ` astro:assets ` handles local images well but remote images need explicit ` domains ` configuration in ` astro.config.mjs `. Sites with 200+ posts using remote images need the domains allowlist populated.\n\n**RSS feed**: Next.js doesn't have built-in RSS. Astro's ` @astrojs/rss ` package is excellent but the content layer migration meant RSS generation scripts need rewriting to use the new ` getCollection() ` API.\n\nBuild time went from 94 seconds to 14 seconds. Bundle size per page dropped from ~90KB to ~8KB (only pages with interactive components ship JS). Core Web Vitals across the board improved, LCP median dropped from 1.8s to 0.9s on mobile."},{"heading":"What's Still Missing","level":2,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#whats-still-missing","content":"Astro 5 isn't perfect. The dev server HMR is slower than Next.js for large sites (500+ pages). The ecosystem of pre-built components is smaller. If you need middleware-heavy patterns (auth, A/B testing, feature flags on every request), the server island model is less ergonomic than Next.js middleware.\n\nThe TypeScript experience is also slightly behind. Next.js's type inference for ` getStaticProps ` and server components is tighter. Astro's TypeScript support has improved massively, but you'll occasionally hit ` any ` types in the content layer API that Next.js would catch.\n\nStill, for content sites, genuinely content-driven sites where the primary job is rendering text, images, and structured data, Astro 5 stands out as the strongest tool in the category. The content layer API is the data pipeline content sites have always needed, server islands solve the dynamic-content-in-static-pages problem elegantly, and view transitions make the whole thing feel like a modern SPA without the JavaScript tax.\n\nBuilding one page with the new content layer, measuring the build times, and checking Lighthouse scores provides sufficient data for an informed decision."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#questions","content":""},{"heading":"Is Astro 5 a drop-in replacement for Astro 4?","level":3,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#question-1","content":"Not quite. The content layer API replaces the old content collections with a loader-based system. If you're using defineCollection with type: 'content', you'll need to migrate to the new loader pattern. The CLI migration tool handles about 80% of cases, but custom remark/rehype plugins may need manual adjustments."},{"heading":"Can Astro 5 replace Next.js for all use cases?","level":3,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#question-2","content":"No. Astro excels at content-driven sites where most pages are static or lightly dynamic. If you need heavy client-side interactivity, complex auth flows, or real-time features on every page, Next.js with React Server Components is still the better fit. The sweet spot for Astro is blogs, docs, marketing sites, and portfolios with selective interactivity."},{"heading":"How do server islands affect caching strategies?","level":3,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#question-3","content":"Each server island is fetched independently, so you can set different cache policies per island. Static shells get full CDN caching while dynamic islands bypass it. Use Cache-Control headers on your island endpoints and consider stale-while-revalidate for islands that can tolerate slightly stale data."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#article-tips","content":"1. **Tip:** When migrating from Astro 4 content collections, don't try to rewrite your ` config.ts ` all at once. Use ` defineCollection() ` with the new ` loader ` property on one collection first, verify the build, then proceed. The old glob-based approach still works during the transition period.\n2. **Tip:** Server islands with ` server:defer ` are tempting to slap everywhere, but each one adds a network waterfall on the client. Profile your actual usage with ` performance.mark() ` before and after, More than 3 deferred islands per page tends to start hurting LCP on 3G connections.\n3. **Tip:** For view transitions, always set ` transition:persist ` on your audio/video elements and any component holding WebSocket connections. Without this, live notification badges reset on navigation because the DOM gets nuked on every transition.\n\n- astro\n- view-transitions\n- content-collections\n- server-islands\n- hybrid-rendering\n- nextjs\n- frontend-architecture\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Lucio Durán","url":"https://lucioduran.com/"},{"text":"Español","url":"https://lucioduran.com/es/blog/astro-5-view-transitions-content-collections"},{"text":"Astro","url":"https://astro.build/"},{"text":"Next.js","url":"https://nextjs.org/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com/"},{"@type":"WebSite","@id":"https://lucioduran.com/#website","url":"https://lucioduran.com/","name":"Lucio Durán","inLanguage":["en","es"],"publisher":{"@id":"https://lucioduran.com/#person"}},{"@type":"WebPage","@id":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#page","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections","name":"Content Layer API, Server Islands, and View Transitions in Astro 5 | Lucio Durán","description":"An analysis of Astro 5's content layer API, server islands architecture, and view transitions, with production benchmarks comparing performance against Next.js for content-heavy sites.","inLanguage":"en","author":{"@id":"https://lucioduran.com/#person"},"publisher":{"@id":"https://lucioduran.com/#person"},"isPartOf":{"@id":"https://lucioduran.com/#website"},"isAccessibleForFree":true,"datePublished":"2026-02-21","mainEntity":{"@id":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#article"},"breadcrumb":{"@id":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#breadcrumb"}},{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#article","mainEntityOfPage":{"@id":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#page"},"headline":"Content Layer API, Server Islands, and View Transitions in Astro 5","description":"An analysis of Astro 5's content layer API, server islands architecture, and view transitions, with production benchmarks comparing performance against Next.js for content-heavy sites.","datePublished":"2026-02-21","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@id":"https://lucioduran.com/#person"},"url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Frontend","keywords":"astro, view-transitions, content-collections, server-islands, hybrid-rendering, nextjs, frontend-architecture","wordCount":1636,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/astro-5-view-transitions-content-collections#article","inLanguage":"es","url":"https://lucioduran.com/es/blog/astro-5-view-transitions-content-collections"}],"isAccessibleForFree":true},{"@type":"Blog","@id":"https://lucioduran.com/blog#blog","url":"https://lucioduran.com/blog","name":"Blog","inLanguage":"en","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com/"},"publisher":{"@id":"https://lucioduran.com/#person"},"isPartOf":{"@id":"https://lucioduran.com/#website"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Content Layer API, Server Islands, and View Transitions in Astro 5","item":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections"}],"@id":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#breadcrumb"},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is Astro 5 a drop-in replacement for Astro 4?","acceptedAnswer":{"@type":"Answer","text":"Not quite. The content layer API replaces the old content collections with a loader-based system. If you're using defineCollection with type: 'content', you'll need to migrate to the new loader pattern. The CLI migration tool handles about 80% of cases, but custom remark/rehype plugins may need manual adjustments."}},{"@type":"Question","name":"Can Astro 5 replace Next.js for all use cases?","acceptedAnswer":{"@type":"Answer","text":"No. Astro excels at content-driven sites where most pages are static or lightly dynamic. If you need heavy client-side interactivity, complex auth flows, or real-time features on every page, Next.js with React Server Components is still the better fit. The sweet spot for Astro is blogs, docs, marketing sites, and portfolios with selective interactivity."}},{"@type":"Question","name":"How do server islands affect caching strategies?","acceptedAnswer":{"@type":"Answer","text":"Each server island is fetched independently, so you can set different cache policies per island. Static shells get full CDN caching while dynamic islands bypass it. Use Cache-Control headers on your island endpoints and consider stale-while-revalidate for islands that can tolerate slightly stale data."}}],"@id":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#questions","url":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#questions","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections#page"},"author":{"@id":"https://lucioduran.com/#person"}}]}],"contentHash":"cbe25b5d8269adcf61826fd11045e68a6cee5d359a0b61ce4cba4e587e963d27","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/astro-5-view-transitions-content-collections","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":73389,"contentBytes":14763}}
