Astro Interview Questions and Answers
Last updated:
Check out 40 of the most common Astro interview questions, then take an AI-powered practice interview
Q1What is Astro and what problem does it solve?
BasicFundamentals
Answer
Astro is a content-focused web framework created by Fred K. Schott in 2021. It solves a specific problem: most modern frameworks (Next.js, Nuxt, SvelteKit) are 'app-first' and ship a JavaScript runtime to the browser even for pages that don't need it, blogs, docs, landing pages, resulting in heavy bundles and worse Core Web Vitals.
Astro flips this: it's 'content-first' and renders pages to HTML at build time (or on demand), shipping zero JavaScript by default. When a page does need interactivity, you opt in per component using `client:*` directives, this is the Islands Architecture. The result: blogs and marketing sites built in Astro typically score 100/100 on Lighthouse out of the box, while sites built in Next.js need careful optimization to hit the same numbers.
Mechanically, Astro is a Vite-powered MPA generator: `astro build` walks `src/pages/`, executes each page's frontmatter on the server, and writes real `.html` files into `dist/` alongside a hashed `_astro/` folder that only contains assets an island actually needs. There is no client-side router unless you opt in with `<ClientRouter />`, so every navigation is a normal browser document request. Interviewers usually follow up with two probes.
First, 'when is Astro the wrong choice?', and the honest answer is any screen driven by long-lived client state (a trading terminal, a chat app, a drag-and-drop builder), because islands intentionally do not share a component tree. Second, 'zero JS by default sounds absolute, what still ships?', and the answer is that Astro emits a tiny hydration runtime only when at least one `client:*` island exists on the page, plus whatever the island's framework bundle costs. A page with no directives ships literally no script tag from Astro.
Key Points
- Zero JavaScript by default, opt-in interactivity
- Islands Architecture: partial hydration per component
- Multi-framework: React + Vue + Svelte in the same project
- Excellent for content-heavy sites: blogs, docs, marketing
Q2What is an `.astro` file and how does it differ from a JSX component?
BasicComponents
Answer
An `.astro` file is Astro's single-file component format. It has three parts: a code fence (`---`) at the top for the component script that runs at build time, an HTML-like template body, and an optional `<style>` block scoped by default. Unlike JSX, the script section runs only on the server, never in the browser, so you can `await` database queries, read filesystem files, or import Node-only packages directly.
The template uses JSX-like expressions (`{}`) but renders as plain HTML. There's no `useState`, no `useEffect`, no client-side runtime for the `.astro` component itself. The differences that trip people up in interviews are concrete. `class` stays `class`, not `className`, and `for` stays `for`, because the template is HTML rather than JSX.
Attributes accept `class:list={['btn', { active: isActive }]}` for conditional classes. A component renders exactly once per request or per build, so there is no re-render, no dependency array, and no reconciliation. Passing children works through `<slot />` rather than a `children` prop, and named slots use `<slot name="footer" />`.
Anything you want to run in the browser goes in a `<script>` tag or in a framework island, not in the fence. A frequent follow-up is 'can an `.astro` component be interactive?', and the answer is only through a plain `<script>` (which Astro bundles and hoists) or by delegating to a React, Vue, Svelte, or Solid component with a `client:*` directive. Another follow-up: top-level `await` is legal inside the fence because the whole file is treated as an ES module executed on the server, which is why `await getCollection('blog')` needs no wrapper.
---
// This runs at build time only, never in the browser
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
---
<ul>
{posts.map(post => (
<li><a href={`/blog/${post.slug}`}>{post.data.title}</a></li>
))}
</ul>
<style>
ul { list-style: none; padding: 0; }
</style>
Key Points
- Three sections: frontmatter script, template, scoped styles
- Script runs only at build time / on server, never in browser
- Styles are scoped to the component automatically
Q3What is the Islands Architecture in Astro?
BasicIslands
Answer
Islands Architecture (coined by Katie Sylor-Miller, popularized by Astro) is a rendering pattern where a page is mostly static HTML, with small interactive 'islands' of JavaScript scattered throughout. Each island is independently hydrated, the rest of the page stays static. Contrast this with Next.js or Nuxt, where the entire page is hydrated as one React/Vue tree even if 95% of it is static content.
In Astro, your homepage might be 100% HTML with a single interactive React component for the search bar; only the search bar's JS gets shipped, parsed, and executed. This dramatically reduces JavaScript payload, typical Astro sites ship 0-10 KB of JS vs 100-300 KB for equivalent Next.js sites. Under the hood, each island is wrapped in an `<astro-island>` custom element in the emitted HTML, carrying attributes such as `uid`, `component-url`, `renderer-url`, `client`, and a serialized `props` blob.
A small runtime script watches those elements, and depending on the directive it either imports the renderer immediately, waits for `requestIdleCallback`, or attaches an `IntersectionObserver`. That is why you can view source on an Astro page and count your islands exactly. The consequences interviewers push on: islands are isolated, so two React islands on the same page each boot their own React root and cannot share `useContext` or component state; props crossing the boundary are serialized into the HTML, so functions and class instances throw a serialization error at build; and an island that never enters the viewport with `client:visible` never downloads its JavaScript at all. The correct framing in an interview is that Astro optimizes for pages where interactivity is the exception, and the cost of that model is coordination between islands, which you solve with a shared store rather than with props.
---
import Header from '../components/Header.astro'; // 0 KB, static HTML
import SearchBox from '../components/SearchBox.jsx'; // island
import PriceFilter from '../components/PriceFilter.svelte'; // island
---
<Header />
<!-- Two independent islands, two separate hydration roots -->
<SearchBox client:load />
<PriceFilter client:visible />
<!-- Emitted HTML looks roughly like: -->
<!--
<astro-island uid="Z1a2b3" client="load"
component-url="/_astro/SearchBox.CqW1.js"
renderer-url="/_astro/client.react.BxY9.js"
props='{"placeholder":[0,"Search jobs"]}'>
<input placeholder="Search jobs" />
</astro-island>
-->
Key Points
- Static HTML by default, interactive components are 'islands'
- Each island hydrates independently, no global tree
- Massive reduction in shipped JS vs traditional SSR frameworks
- Each island becomes an `<astro-island>` element with serialized props
- Islands cannot share React context or component state with each other
Q4What are the `client:*` directives and when do you use each?
BasicHydration
Answer
`client:*` directives tell Astro how and when to hydrate a UI framework component. There are five: `client:load` hydrates immediately on page load (use for above-the-fold interactivity), `client:idle` waits for `requestIdleCallback` (good for non-critical components like analytics buttons), `client:visible` uses IntersectionObserver to hydrate only when the component scrolls into view (best for below-the-fold widgets), `client:media={query}` hydrates when a media query matches (e.g. mobile-only nav), and `client:only={framework}` skips server rendering entirely and renders client-side only (use sparingly, breaks SEO). Without any directive, the component renders to HTML at build time and ships zero JS, this is the default.
Details that separate a rehearsed answer from a real one: `client:media` accepts any valid media query string, for example `client:media="(max-width: 768px)"`, and the island stays dormant on desktop, which is the cleanest way to ship a mobile drawer without penalising desktop users. Directives are evaluated per usage, not per component, so the same `<Counter />` can be `client:load` in the header and `client:visible` in the footer, and Astro emits one shared chunk with two `<astro-island>` wrappers. `client:only` is the only directive that takes a framework value (`client:only="react"`) because there is no server render to infer the renderer from. Astro also supports `client:idle={{timeout: 500}}` so a low-priority island still hydrates on a busy main thread instead of waiting forever. The follow-up a senior interviewer asks is 'what does `client:visible` cost you?', and the honest answer is a perceptible delay on fast scrolls, which you mitigate with `client:visible={{rootMargin: "200px"}}` so the observer fires before the island reaches the fold.
---
import SearchBar from '../components/SearchBar.jsx';
import NewsletterSignup from '../components/NewsletterSignup.jsx';
import AnalyticsButton from '../components/AnalyticsButton.jsx';
---
<!-- Hydrates on page load (search is critical) -->
<SearchBar client:load />
<!-- Hydrates only when scrolled into view -->
<NewsletterSignup client:visible />
<!-- Renders to HTML, no JS shipped at all -->
<StaticIcon />
Q5How do you use React components inside an Astro project?
BasicFrameworks
Answer
Install the integration and add it to `astro.config.mjs`. Run `npx astro add react` and Astro will install `@astrojs/react`, React, and React DOM, then update the config. After that, you can import and use any `.jsx`/`.tsx` component inside `.astro` files.
By default, React components render to static HTML at build time (no JS shipped). To make them interactive, add a `client:*` directive. The same pattern works for Vue (`astro add vue`), Svelte, Solid, Preact, Lit, and Alpine, and you can mix them in the same project, which is unique to Astro.
Three practical points come up in interviews. First, if you install both `@astrojs/react` and `@astrojs/preact`, you must disambiguate with `include` and `exclude` globs on each integration, otherwise Astro cannot decide which renderer owns a `.jsx` file and the build fails with a renderer resolution error. Second, children passed from an `.astro` file into a React island are rendered on the server and injected as an `<astro-slot>`, which means they arrive as inert HTML: a nested `client:load` component inside those children does not get its own hydration root, so put interactive children inside the React tree instead.
Third, props cross the boundary through Astro's serializer, so `<Chart client:load data={rows} onSelect={fn} />` works for `rows` and throws on `fn`; pass an event name or an id and wire the handler inside the island. Also remember `@astrojs/react` v4 targets React 18 and 19, and `astro add react` writes the correct `jsx: "react-jsx"` settings into `tsconfig.json` for you.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import vue from '@astrojs/vue';
import svelte from '@astrojs/svelte';
import preact from '@astrojs/preact';
export default defineConfig({
integrations: [
// Disambiguate when React and Preact coexist
react({ include: ['**/react/*'] }),
preact({ include: ['**/preact/*'] }),
vue(),
svelte(),
],
});
Q6How do you create dynamic routes in Astro?
BasicRouting
Answer
Astro uses file-based routing in `src/pages/`. For dynamic routes, use square brackets in the filename, e.g. `src/pages/blog/[slug].astro` matches `/blog/anything`. For static site generation (the default), you must export a `getStaticPaths()` function that returns the list of routes to pre-render.
For server-rendered or hybrid routes, you can access the slug via `Astro.params.slug` directly without `getStaticPaths`. Catch-all routes use `[...slug].astro` (matches `/blog/2026/01/post-title`). The rules people get wrong: `getStaticPaths()` runs once at build in an isolated module scope, so it cannot read `Astro.params`, `Astro.request`, or anything from the page frontmatter, and values you need in the template must come back through `props`.
A rest parameter can match an empty segment, so `[...slug].astro` also serves the parent path with `Astro.params.slug` set to `undefined`, which is exactly how catch-all 404 handlers are built. Route specificity is deterministic: static segments beat dynamic segments, and dynamic segments beat rest parameters, so `/blog/rss` in `rss.astro` wins over `[slug].astro`. If you call `getStaticPaths()` on a route in a project using SSR, Astro ignores it unless the file also sets `export const prerender = true`. Two common build errors are worth memorising: `getStaticPaths() function is required for dynamic routes` means you forgot the export on a prerendered page, and `getStaticPaths() returned an object with duplicate params` means two entries produced the same slug, usually from two Markdown files that slugify identically.
---
// src/pages/blog/[slug].astro
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
Q7What are Content Collections in Astro?
BasicContent
Answer
Content Collections are Astro's first-class way to manage local content (Markdown, MDX, YAML, JSON). You define a schema with Zod in `src/content/config.ts`, place your content files in `src/content/<collection-name>/`, and Astro provides type-safe `getCollection()` and `getEntry()` APIs. The schema enforces frontmatter validity at build time, if a blog post is missing a required field, the build fails with a clear error.
TypeScript autocompletion works on `post.data.title`, `post.data.publishDate`, etc. This is one of Astro's killer features for content-heavy sites, you get the DX of a CMS without running a CMS. The generated types live in `.astro/types.d.ts`, which `astro sync` (and `astro dev`) regenerates; if autocomplete on `post.data` suddenly disappears, running `npx astro sync` is the fix. `getCollection` takes an optional filter callback, so `getCollection('blog', ({ data }) => !data.draft)` is the idiomatic way to keep drafts out of production while leaving them visible in dev via `import.meta.env.PROD`. Two schema helpers matter in real projects: `image()` validates and optimizes a frontmatter cover image so it can be passed straight to `<Image>`, and `reference('authors')` turns an author slug into a checked cross-collection link you resolve with `getEntry(post.data.author)`.
Version awareness scores points here: in Astro 5.x collections are defined with a `loader` instead of `type: 'content'`, `entry.slug` became `entry.id`, and `await entry.render()` became `const { Content } = await render(entry)` with `render` imported from `astro:content`. Old projects keep working through the `legacy.collections` flag. When frontmatter fails validation, the build stops with the collection name, the file path, and the Zod issue, which is precisely why teams prefer collections over raw globbing.
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
publishDate: z.date(),
author: z.string(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
Key Points
- Schema validation via Zod at build time
- Type-safe `getCollection()` and `getEntry()`
- TypeScript autocompletion on frontmatter fields
Q8How do you write Markdown and MDX content in Astro?
BasicContent
Answer
Plain Markdown works out of the box, drop a `.md` file in `src/pages/` and it becomes a page, or in a content collection for structured content. For MDX (Markdown + JSX, so you can embed components inside content), install the integration: `npx astro add mdx`. Then `.mdx` files can import and use Astro/React/Vue components inline.
Frontmatter at the top (between `---` fences) is parsed into structured data. Astro's syntax highlighting uses Shiki by default, same highlighter as VS Code, and supports configurable themes. Beyond the basics: inside a `.md` page, frontmatter is exposed as `frontmatter.title`, while inside `.mdx` you can also `export const something = ...` and import components at the top of the file.
Markdown pages accept a `layout:` frontmatter key and Astro passes the parsed data plus `headings` (an array of `{ depth, slug, text }`) into that layout as props, which is how table-of-contents components are built without a plugin. Configuration lives under `markdown` in `astro.config.mjs`: `shikiConfig.themes` for light and dark, `remarkPlugins` and `rehypePlugins` for things like `remark-gfm` or `rehype-slug`, and `markdown.syntaxHighlight: 'shiki' | 'prism' | false`. A real gotcha interviewers like: MDX does not inherit the `markdown` remark and rehype plugins unless you set `extendMarkdownConfig: true` on the MDX integration, so a plugin that works in `.md` silently stops working after you rename the file to `.mdx`. Another: MDX treats indentation and JSX strictly, so a stray `<` in prose becomes a parse error, and HTML comments must be written as `{/* ... */}`.
---
title: 'My First Post'
publishDate: 2026-01-15
---
import CallToAction from '../../components/CallToAction.astro';
# {frontmatter.title}
Regular markdown works here, and so do components:
<CallToAction href="/signup">Join now</CallToAction>
Q9What's the difference between `Astro.props` and `Astro.params`?
BasicComponents
Answer
`Astro.props` is the data passed to a component from its parent, like React props. `Astro.params` is the dynamic route segment values from the URL (e.g. `slug` from `[slug].astro`). For static routes generated via `getStaticPaths()`, you typically return both: `params` for the URL segments and `props` for any data the page needs (so you don't re-fetch in the template). The distinctions worth stating out loud: values in `params` must be strings or `undefined`, and Astro coerces them into the URL, so returning `params: { id: 42 }` produces the path `/users/42` but `Astro.params.id` reads back as the string `'42'`.
Props are not serialized for a plain `.astro` component, so you can pass a `Date`, a `Map`, or a full database row without penalty, unlike props handed to a hydrated island. In a prerendered route, props come from `getStaticPaths()`; in an SSR route there is no `getStaticPaths()`, so `Astro.props` is an empty object and every value must be derived from `Astro.params`, `Astro.request`, or `Astro.locals`, which also means the params are attacker-controlled and need validation before hitting a query. Typing is done by declaring `interface Props { user: User }` in the fence, and `astro check` enforces it at every call site. For Markdown pages that declare a `layout`, the frontmatter arrives as `Astro.props.frontmatter`, a detail that catches people who expect the fields at the top level.
---
// src/pages/users/[id].astro
export async function getStaticPaths() {
const users = await fetchUsers();
return users.map(user => ({
params: { id: user.id }, // becomes Astro.params.id
props: { user }, // becomes Astro.props.user
}));
}
const { id } = Astro.params;
const { user } = Astro.props;
---
<h1>User {id}: {user.name}</h1>
Q10How do scoped styles work in Astro?
BasicStyling
Answer
Any `<style>` block inside an `.astro` component is automatically scoped, Astro adds a hash-based attribute to all selectors so styles don't leak. Global styles can be opted into with `<style is:global>`. You can also import CSS files directly (`import './styles.css'`) for global styles.
Astro supports Sass/SCSS, PostCSS, and CSS-in-JS via integrations. Tailwind is added with `npx astro add tailwind`, extremely common in real-world Astro projects because Tailwind's utility classes pair well with the static-first approach (no runtime CSS-in-JS overhead). Mechanically, the scoping works by hashing the component and appending a `data-astro-cid-<hash>` attribute to matching elements and selectors, so specificity stays low and there is no runtime cost.
The consequence, and the thing interviewers dig into, is that scoped styles do not reach markup you did not author in that file: HTML injected with `set:html`, elements rendered inside a hydrated React island, and children passed through `<slot />` all fall outside the scope. The escape hatches are `:global(...)` around a specific selector, `<style is:global>` for the whole block, and `<style define:vars={{ accent }}>` to pass runtime values in as CSS custom properties. Astro also deduplicates and bundles component CSS, links it in the `<head>` of every page that uses the component, and inlines small stylesheets when `build.inlineStylesheets` is set to `'auto'` (the default) or `'always'`. Ordering matters too: imported global CSS is applied before scoped styles, so a Tailwind utility can be overridden by a scoped rule, which is usually the opposite of what people expect on their first Astro project.
<!-- Scoped: only affects THIS component's h1 -->
<style>
h1 { color: indigo; }
</style>
<!-- Global: affects all h1s on the page -->
<style is:global>
h1 { font-family: 'Inter', sans-serif; }
</style>
Q11What is the `<Fragment>` element in Astro and when do you need it?
BasicComponents
Answer
`<Fragment>` (or its shorthand `<>...</>`) lets you return multiple top-level elements without wrapping them in a `<div>`. This matters in two places: (1) when a component returns multiple sibling elements, (2) when conditionally rendering a group of elements via `{condition && (<>...</>)}`. The shorthand `<>` is preferred, same as React.
Note: unlike React, you can also use `set:html` on a Fragment to inject raw HTML safely. The `set:html` case is the one that actually shows up in production Astro code, because CMS bodies and Markdown rendered outside a content collection arrive as HTML strings. Putting `set:html` on a `<Fragment>` injects that string without adding a wrapper element, which keeps your typography CSS selectors (`article > p`) working.
The counterpart is `set:text`, which escapes the value, and the security rule is that `set:html` performs no sanitisation at all, so untrusted HTML must go through a sanitiser such as `sanitize-html` in the fence before it reaches the template. A second real use of `<Fragment>` is passing content into a named slot without an extra DOM node: `<Fragment slot="footer">...</Fragment>`. Note the capital F, since Astro resolves lowercase tags as plain HTML elements and `<fragment>` would be emitted literally into the page. Also unlike React, Astro templates do not need keys on mapped arrays because nothing re-renders, so `{items.map(i => <li>{i}</li>)}` is complete and correct with no `key` prop, and adding one just emits a stray attribute.
---
const showHero = true;
---
{showHero && (
<>
<h1>Welcome</h1>
<p>This is the hero section.</p>
</>
)}
<!-- Inject raw HTML (e.g. from a CMS) -->
<Fragment set:html={post.bodyHtml} />
Q12How do you configure Astro's output mode (static vs server vs hybrid)?
BasicConfiguration
Answer
Set the `output` option in `astro.config.mjs`. Three values: `'static'` (default, every page is pre-rendered to HTML at build time, no Node runtime needed), `'server'` (every page is server-rendered on request, requires a Node/Deno/Cloudflare runtime), or `'hybrid'` (mostly static, opt-in to SSR per page via `export const prerender = false`). Astro 5.x changed this slightly: the default is now `'static'` and you opt routes into SSR.
Pick `static` for blogs/docs/marketing, `hybrid` for sites with mostly static pages plus a few dynamic ones (e-commerce search, dashboards), `server` only if every page must be dynamic. To be precise about the 5.x change, since interviewers test it: `output: 'hybrid'` was removed as a value in Astro 5.0, and the two remaining values now cover all three behaviours. `output: 'static'` plus `export const prerender = false` on selected routes is exactly what hybrid used to mean, and `output: 'server'` plus `export const prerender = true` inverts the default. Passing `'hybrid'` in 5.x fails config validation at startup.
The other rules to know: an adapter is required the moment any route is server-rendered, `astro build` then emits both `dist/client/` and `dist/server/` instead of a flat folder, and `prerender` must be a statically analysable top-level export, so a value computed from an environment variable is rejected. APIs that need a request, including `Astro.request.headers`, `Astro.cookies` reads, and `Astro.clientAddress`, are unavailable in a prerendered route and Astro throws a build error naming the offending API rather than silently returning empty values. A useful production pattern is keeping the whole marketing site static and marking only `/search`, `/api/*`, and authenticated routes as `prerender = false` so a CDN still serves the bulk of the traffic.
// astro.config.mjs (Astro 5.x: 'hybrid' was removed, use 'static' + prerender)
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'static',
adapter: node({ mode: 'standalone' }),
});
// src/pages/dashboard.astro
---
export const prerender = false; // this one route is server-rendered
const user = Astro.locals.user;
---
<h1>Hello {user.name}</h1>
Q13How do you run client-side JavaScript in an `.astro` component, and what does `is:inline` change?
BasicScripts
Answer
A plain `<script>` tag inside an `.astro` file is processed by Astro, not passed through. Astro treats it as an ES module, runs it through Vite (so `import` of npm packages and TypeScript both work), bundles it into a hashed file under `_astro/`, hoists it to the page `<head>`, and deduplicates it: if a component containing that script renders twelve times on a page, the script is included and executed once, not twelve times. That last rule is the one candidates get wrong.
Adding `is:inline` flips every part of that: the tag stays exactly where you wrote it, is emitted verbatim into the HTML, is not bundled or type-checked, cannot use bare module imports, and is repeated for every instance of the component. You need `is:inline` for third-party snippets that must run at a specific point in the document, for `<script src="https://...">` pointing at an external origin, and for JSON-LD blocks with `type="application/ld+json"`. `define:vars={{ postId }}` injects server values as `const` declarations and implicitly makes the script inline, which is why a `define:vars` script loses bundling. Because a processed script has no access to frontmatter, the common alternative is writing values into `data-` attributes and reading them with `dataset` inside the script. One more gotcha: module scripts execute once per document, so with `<ClientRouter />` enabled you must re-initialise on the `astro:page-load` event.
---
const postId = Astro.props.id;
---
<!-- Bundled, hoisted, deduplicated, TypeScript allowed -->
<script>
import { animate } from 'motion';
document.querySelectorAll('[data-fade]').forEach((el) => animate(el, { opacity: 1 }));
</script>
<!-- Server value passed via data attribute (script stays bundled) -->
<button data-post-id={postId} class="like">Like</button>
<script>
document.querySelector('.like')?.addEventListener('click', (e) => {
const id = (e.currentTarget as HTMLElement).dataset.postId;
fetch(`/api/like/${id}`, { method: 'POST' });
});
</script>
<!-- Verbatim: not bundled, repeated per component instance -->
<script is:inline type="application/ld+json" set:html={JSON.stringify(schema)} />
Key Points
- Plain `<script>` is bundled by Vite, hoisted, and deduplicated per page
- `is:inline` emits the tag verbatim: no bundling, no imports, one copy per instance
- `define:vars` forces inline; prefer `data-` attributes to keep bundling
- With `<ClientRouter />`, re-run setup on the `astro:page-load` event
Q14How does Astro compare to Next.js, and when would you pick one over the other?
IntermediateComparison
Answer
The fundamental difference is the default rendering posture: Next.js is 'app-first', it assumes you're building an interactive single-page-style app with React state, and every page hydrates as one tree. Astro is 'content-first', pages are static HTML by default, and interactivity is opt-in per component. Pick Astro for: marketing sites, blogs, documentation, e-commerce catalogs, landing pages, anywhere SEO and TTI matter more than rich interactivity.
Pick Next.js for: dashboards, SaaS apps, anything with significant client-side state, complex forms, real-time updates. A common pattern in India: Razorpay uses Next.js for its dashboard product but Astro for its marketing site at razorpay.com, different tools for different jobs. Also: Astro is multi-framework (React + Vue + Svelte in same project), Next.js is React-only.
Concretely, the two differ at almost every layer: Next.js App Router composes `layout.tsx` files by nesting route segments, while Astro composes with explicit `<slot />` inside `.astro` layouts; Next.js data loading happens inside async server components and `fetch` caching, while Astro just runs top-level `await` in the fence and bakes the result at build unless the route sets `prerender = false`; `next/image` needs a running image optimizer or a loader, while `astro:assets` emits AVIF and WebP variants at build with Sharp and needs no server. Deployment differs the most in practice: a static Astro build is a folder of `.html` you can host on S3 or Cloudflare Pages with no runtime, whereas even a mostly static Next app usually expects the Next server or a platform adapter. The follow-up to prepare for is 'we already have a Next.js site, when is porting the marketing pages to Astro worth it?' A defensible answer is to measure first: if the marketing route's JavaScript payload is dominated by the framework runtime rather than by genuinely interactive widgets, the port pays back in LCP and INP; if those pages share a design system, auth state, or A/B testing infrastructure with the app, running two codebases usually costs more than it saves.
Key Points
- Astro = content-first, Next.js = app-first
- Astro ships zero JS by default; Next.js hydrates the whole page
- Astro is multi-framework; Next.js is React-only
- Many companies use BOTH: Astro for marketing, Next.js for app
Q15What are Server Islands in Astro 5.x and what problem do they solve?
IntermediateIslands
Answer
Server Islands (added in Astro 5.x) let you defer server rendering of specific components until AFTER the rest of the page has been served. The static shell loads instantly, and the dynamic component is fetched and rendered asynchronously on the server. Use case: a blog post (mostly static) that also shows a personalized 'recommended posts' panel based on the current user, without Server Islands, the whole page has to wait for the personalization API.
Mark a component with `server:defer` and provide a fallback. The user sees the cached static page immediately, and the server island streams in once ready. This is fundamentally different from React Server Components: Astro's model is per-component opt-in, with explicit server boundaries.
The mechanics matter in an interview. At build time Astro replaces the deferred component with a placeholder plus a small script, and at runtime the browser issues a second request to a generated endpoint under `/_server-islands/<ComponentName>` that returns the rendered HTML and swaps it in. That has three consequences.
The page itself stays fully cacheable, so you can put `Cache-Control: public, max-age=0, s-maxage=3600` on the document and still show per-user content. The island's props are encrypted with a key generated at build (`ASTRO_KEY`), which is why a multi-instance deployment must share the same build output or set the key explicitly, otherwise decryption fails at runtime. And the component must be an `.astro` component with an adapter present, because rendering happens on the server. The trade-offs to name: the deferred region costs an extra round trip and is not in the initial HTML, so never put your LCP element or SEO-critical text behind `server:defer`, and always size the `slot="fallback"` markup to match the final content or you trade a JavaScript payload win for a CLS regression.
---
import BlogPost from '../components/BlogPost.astro';
import RecommendedPosts from '../components/RecommendedPosts.astro';
---
<BlogPost post={post} />
<!-- Page renders instantly; this fetches in the background -->
<RecommendedPosts server:defer userId={userId}>
<div slot="fallback">Loading recommendations...</div>
</RecommendedPosts>
Key Points
- Static shell + deferred dynamic regions
- Cacheable static, personalized dynamic, best of both
- Use `server:defer` directive with a `fallback` slot
Q16Explain the Content Layer API introduced in Astro 5.x.
IntermediateContent
Answer
The Content Layer API generalizes Content Collections beyond local files. In Astro 4.x and earlier, collections could only pull from `src/content/`. In 5.x, you can define a `loader` for a collection that pulls content from anywhere, a headless CMS (Contentful, Sanity, Notion), a remote API, a database.
Astro caches the fetched content at build time, runs it through your Zod schema for validation, and gives you the same type-safe `getCollection()` interface. This eliminates the need for custom integrations for each CMS, you write a loader function once and the rest of Astro treats it like local content. Big win for India: lots of teams use Sanity or Contentful for blog content, and now there's a clean, standard way to integrate.
There are two loader shapes and interviewers ask you to distinguish them. An inline loader is just an async function returning an array of objects that each have an `id`, which is fine for a few hundred records. An object loader is `{ name, load({ store, logger, parseData, meta, generateDigest, refreshContextData }) }` and is what you write for anything serious, because it gets access to the persistent store and can do incremental syncing: save an ETag or a `lastModified` cursor in `meta`, compare a `generateDigest(entry)` hash, and call `store.set()` only for changed records.
The cache lives in `.astro/data-store.json`, survives between builds, and is the reason a 20,000-post CMS build stops re-fetching everything on every CI run. Built-in loaders `glob()` and `file()` cover local Markdown and a single JSON or YAML file respectively; `glob()` takes `pattern` and `base` and also a `generateId` callback when you need custom slugs. Two migration notes: entries loaded this way expose `id` rather than `slug`, and to render Markdown bodies you call `const { Content } = await render(entry)` with `render` imported from `astro:content`, since `entry.render()` no longer exists on Content Layer collections.
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { glob, file } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
publishDate: z.date(),
}),
});
// Or a custom remote loader:
const products = defineCollection({
loader: async () => {
const res = await fetch('https://api.example.com/products');
const data = await res.json();
return data.map(p => ({ id: p.slug, ...p }));
},
schema: z.object({
id: z.string(),
name: z.string(),
price: z.number(),
}),
});
export const collections = { blog, products };
Q17What are View Transitions and how do you enable them in Astro?
IntermediateView Transitions
Answer
View Transitions are an animation API built on the browser's native View Transitions API. They let you create smooth fade/slide animations between page navigations without ever touching client-side routing, Astro intercepts link clicks, fetches the next page, and animates the transition. Enable them with a single line: `<ViewTransitions />` in your layout's `<head>`.
You can name specific elements (`transition:name='hero'`) to animate them as continuous elements across pages, e.g. a product image that grows from the listing into the detail page. Falls back gracefully in browsers that don't support the View Transitions API (currently Firefox; works in Chrome and Safari). Version detail that interviewers check: in Astro 5.x the component was renamed from `<ViewTransitions />` to `<ClientRouter />`, still imported from `astro:transitions`, and the old name was removed.
Adding it opts your site into a client-side router, which changes the runtime contract in ways that cause most view-transition bugs. Module scripts execute once on the first load and are not re-run after a swap, so initialisation code must listen for `astro:page-load` instead of `DOMContentLoaded`. State you want to survive navigation (an audio player, a sidebar's scroll position) needs `transition:persist` on the element, and hydrated islands keep their state when persisted.
The full lifecycle is `astro:before-preparation`, `astro:after-preparation`, `astro:before-swap`, `astro:after-swap`, then `astro:page-load`, and `astro:before-swap` is where you re-apply things like a dark-mode class to the incoming document before it is painted. Animations are tuned with `transition:animate="slide"`, `"fade"`, `"none"`, or a custom object, and `transition:name` values must be unique per page or the browser drops the transition. Finally, respect `prefers-reduced-motion`, because a full-page slide is exactly the kind of motion that triggers vestibular discomfort.
---
// src/layouts/Layout.astro (Astro 5.x renamed ViewTransitions to ClientRouter)
import { ClientRouter } from 'astro:transitions';
---
<html>
<head>
<title>{title}</title>
<ClientRouter />
</head>
<body>
<slot />
<audio transition:persist id="player" controls></audio>
</body>
</html>
<script>
// Runs on first load AND after every client-side navigation
document.addEventListener('astro:page-load', () => {
document.querySelectorAll('table').forEach(initSortable);
});
</script>
<!-- In a product page: -->
<img
src={product.image}
transition:name={`product-image-${product.id}`}
/>
Q18How do you handle environment variables in Astro?
IntermediateConfiguration
Answer
Astro uses Vite under the hood, so the convention is `.env`, `.env.production`, `.env.local`. Variables prefixed with `PUBLIC_` are exposed to the client bundle (accessible via `import.meta.env.PUBLIC_API_URL`). Variables WITHOUT the prefix are server-only, they're available in `.astro` frontmatter scripts and API routes but NOT in client-side React/Vue components.
Astro 5.x added `astro:env` for typed env vars: define their schema in `astro.config.mjs` with `experimental.env.schema`, and you get TypeScript autocomplete plus runtime validation at build time. Critical for production: never put `STRIPE_SECRET_KEY` without a prefix into a file that gets imported by a `client:load` component, Vite will inline it into the client bundle. In Astro 5.x the typed API is stable and no longer sits behind an experimental flag: you declare an `env.schema` in `astro.config.mjs` using `envField.string({ context: 'server', access: 'secret' })`, then import the value from `astro:env/server` or `astro:env/client`.
Public client variables are still inlined at build, public server variables are inlined into the server bundle, and secrets are read at runtime through `getSecret('STRIPE_SECRET_KEY')`, which is what makes the same build artifact deployable to staging and production. Missing or malformed values fail the build with the variable name rather than surfacing as `undefined` three layers deep. Two production traps are worth naming: in a static build every `import.meta.env` value is frozen at build time, so rotating a key means rebuilding, not restarting; and in SSR the `.env` file is a Vite dev convenience, so the deployed process reads real environment variables from the host (Cloudflare bindings, ECS task definitions, PM2 ecosystem files) and forgetting to set them there is the classic 'works locally, 500s in production' failure. Never commit `.env`, and keep `.env.example` in the repo so CI knows what to inject.
// .env
PUBLIC_API_URL=https://api.example.com
STRIPE_SECRET_KEY=sk_test_...
// In an .astro file (server-only):
const secret = import.meta.env.STRIPE_SECRET_KEY; // OK, server-only
// In a React component used with client:load:
const secret = import.meta.env.STRIPE_SECRET_KEY; // BUG, this gets inlined into the JS bundle!
const api = import.meta.env.PUBLIC_API_URL; // OK, explicitly public
Q19How do you fetch data at build time vs at request time in Astro?
IntermediateData
Answer
In a static route (`output: 'static'` or `export const prerender = true`), all `await fetch(...)` calls in the frontmatter run at build time, the result is baked into the HTML. In a server-rendered route (`output: 'server'` or `export const prerender = false`), the same code runs per request. The same Astro frontmatter syntax works for both, you don't need to learn separate `getStaticProps` / `getServerSideProps` like Next.js.
For incrementally-updated content (e.g. blog posts that should refresh hourly), the typical pattern is to redeploy on a schedule, or in 5.x, use a server island so the static page loads instantly while the dynamic data fetches in the background. The operational differences matter more than the syntax. A build-time fetch that fails takes the whole deploy down, which is usually what you want, but it means a flaky upstream API blocks releases, so wrap it in a try/catch with a cached fallback when the data is not critical.
Build-time fetches also run once per generated page, so a `getStaticPaths()` returning 5,000 routes that each fetch inside the page will hammer the API 5,001 times; fetch once in `getStaticPaths()` and hand the record down through `props` instead. Relative URLs do not work in the fence because there is no origin at build; use an absolute URL or `new URL('/api/x', Astro.site)`. On request-time routes you own the caching story yourself: set `Astro.response.headers.set('Cache-Control', 's-maxage=300, stale-while-revalidate=86400')` so the CDN absorbs the load, and remember `Astro.request.headers` throws in a prerendered route. For content that changes hourly, ISR on Vercel or Netlify, a scheduled rebuild webhook, and `server:defer` are the three answers an interviewer expects you to weigh against each other.
---
// Static route, runs at build time, result baked into HTML
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
---
<!-- For a server-rendered route, mark with: -->
<!-- export const prerender = false; -->
<!-- and the same fetch runs on every request -->
<ul>
{posts.map(p => <li>{p.title}</li>)}
</ul>
<!-- Request-time route with CDN caching -->
---
export const prerender = false;
Astro.response.headers.set(
'Cache-Control',
'public, s-maxage=300, stale-while-revalidate=86400'
);
const live = await fetch(new URL('/api/openings', Astro.site)).then(r => r.json());
---
Q20How do you set up API endpoints in Astro?
IntermediateAPI Routes
Answer
Create a file in `src/pages/api/` ending in `.ts` or `.js` (or `.json.ts` to return JSON). Export named functions for each HTTP method: `GET`, `POST`, `PUT`, `DELETE`. The function receives a context object with `request`, `params`, `cookies`, `redirect`, and `locals`.
You must return a `Response` object, Astro's API matches the Web Fetch API standard. For server output mode, these run per request. For static, you'd typically use them only to generate JSON files at build time.
Common pattern for hybrid: marketing pages are static, but `/api/contact` is a server route that posts to your CRM. Specifics that come up: the file must not also export a default Astro component, `params` follows the same `[id].ts` filename rules as pages, and `ALL` is a valid export name that catches every method not otherwise handled. In a fully static build an endpoint can still be prerendered, but then only `GET` is allowed and it must return a body Astro can write to disk, which is exactly how `src/pages/rss.xml.ts` and `sitemap` style endpoints work; add `export const prerender = false` to make it a live handler.
Because the signature is Web standard, you get `request.formData()`, `request.json()`, `cookies.set('session', value, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' })`, and `Response.json(data, { status: 201 })` as a shorter return. Middleware runs before endpoints, so shared auth and rate limiting belong there and the handler reads `locals`. Two failure modes to mention: forgetting to return a `Response` produces a runtime error rather than an empty body, and browser calls from another origin need explicit CORS headers because Astro adds none. For typed request bodies and progressive enhancement, Astro Actions are now the better tool than hand-rolled `POST` endpoints.
// src/pages/api/contact.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const data = await request.json();
if (!data.email) {
return new Response(JSON.stringify({ error: 'email required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
await sendToCRM(data);
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
Q21What is `Astro.glob()` and how is it different from `getCollection()`?
IntermediateContent
Answer
`Astro.glob(pattern)` (legacy) returns all files matching a pattern, useful for one-off uses like 'all markdown files in a folder'. It returns an array of modules with `frontmatter` and a `Content` component. `getCollection('blog')` (modern, from `astro:content`) is the typed Content Collections API, it validates frontmatter against your Zod schema, gives you autocomplete on `post.data.title`, and is the recommended approach in Astro 4.x+. `Astro.glob` is still supported but no longer encouraged. The Content Layer API in 5.x is built on top of `getCollection` and lets you load from any source, not just the filesystem.
Concretely, `Astro.glob()` is deprecated in Astro 5.x and logs a warning; the direct replacement when you genuinely need raw files is Vite's `import.meta.glob()`, which is what `Astro.glob` wrapped anyway and which supports `{ eager: true }` and `{ query: '?raw' }`. The practical differences a reviewer will list: `Astro.glob` patterns are resolved relative to the current file, so moving a component silently breaks the match and returns an empty array instead of erroring, which is the single most common bug it causes; there is no schema, so a typo in `publishDate` surfaces as `Invalid Date` in production rather than a failed build; sorting and draft filtering have to be re-implemented at every call site; and there is no caching, so every page that globs pays the cost again. `getCollection` gives you validated data, a stable `id`, generated TypeScript types in `.astro/types.d.ts`, a filter callback, and with the Content Layer a persistent store. The one case where globbing still wins is non-content assets, for example building a gallery from `import.meta.glob('../images/*.jpg')`, since those never belong in a content collection.
// Old way (Astro.glob, still works, not recommended)
const posts = await Astro.glob('../content/blog/*.md');
posts[0].frontmatter.title; // any type, no validation
// Modern way (Content Collections)
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
posts[0].data.title; // typed via Zod schema
Q22How do you implement layouts and slot-based composition in Astro?
IntermediateLayouts
Answer
Layouts are just regular `.astro` components, by convention, they live in `src/layouts/` and use `<slot />` to inject child content. The default slot accepts unnamed content; named slots (`<slot name='sidebar' />`) accept content with `slot='sidebar'` attribute. This is much closer to Vue or Web Components than React's children pattern.
Layouts can be nested, a `BlogPostLayout` can use a `BaseLayout`, etc. A common pattern: a markdown post specifies its layout in frontmatter (`layout: '../../layouts/BlogPost.astro'`) and Astro renders the markdown inside that layout automatically. The API surface worth knowing: content written between the slot tags acts as fallback when nothing is passed, `Astro.slots.has('sidebar')` lets you skip the wrapper markup entirely when a slot is empty (so you do not ship an empty `<aside>` on every page), and `await Astro.slots.render('default')` returns the slot content as an HTML string when you need to measure or post-process it. Slot names must match exactly and a template can forward a slot to a child with `<slot name="sidebar" slot="sidebar" />`, which is how three-level layout chains stay flat.
When you pass named slots into a React or Vue island, Astro converts them into props named after the slot, and the content arrives pre-rendered as HTML, so those children are static even if the island itself hydrates. A Markdown layout receives more than the body: `Astro.props` carries `frontmatter`, `headings`, `url`, `file`, plus `rawContent()` and `compiledContent()`, which is how reading-time estimates and tables of contents are built. Two gotchas: slot content is evaluated in the parent's scope, not the layout's, and a layout used by both Markdown and `.astro` pages needs defensive prop handling because only one of the two supplies `frontmatter`.
---
// src/layouts/BaseLayout.astro
const { title } = Astro.props;
---
<html>
<head><title>{title}</title></head>
<body>
<header><slot name="header" /></header>
<main><slot /></main>
<aside><slot name="sidebar" /></aside>
</body>
</html>
<!-- Usage: -->
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Home">
<h1 slot="header">Welcome</h1>
<p>Main content here.</p>
<nav slot="sidebar">Links</nav>
</BaseLayout>
Q23How do you handle redirects and 404s in Astro?
IntermediateRouting
Answer
For static redirects, use the `redirects` config in `astro.config.mjs`: `redirects: { '/old-path': '/new-path' }`. For dynamic redirects (e.g. based on auth), use `Astro.redirect(url, status)` inside an `.astro` page or API endpoint, works only in server/hybrid mode. For 404s, create `src/pages/404.astro`, Astro will use it for any non-matching route.
In server mode, return a 404 Response from your handler. With static output, the 404.html is served by your host (Netlify, Vercel, Cloudflare Pages all handle this automatically). The details that decide whether an answer sounds senior: config redirects default to HTTP 301, and you switch to a temporary redirect with the object form `{ status: 302, destination: '/new' }`, which matters because a wrongly cached 301 is effectively permanent in users' browsers.
Dynamic segments in a config redirect must line up on both sides (`'/legacy/[id]': '/products/[id]'`), and in a static build Astro emits real HTML files containing a meta refresh plus a canonical link, while adapters that support native redirects (Netlify `_redirects`, Vercel config) get server-level rules instead, which is the faster path for SEO. `Astro.redirect()` returns a `Response`, so you must `return` it, and calling it after output has begun streaming throws. For a soft 404 on a dynamic SSR route, returning `new Response(null, { status: 404 })` gives the correct status code, whereas rendering your 404 component with a 200 status creates the classic SEO problem of Google indexing thousands of 'not found' pages. In Astro 4.13 and later, `Astro.rewrite('/404')` renders another route's content under the current URL without a redirect, which is the cleanest way to serve a themed 404 body with a genuine 404 status.
// astro.config.mjs, declarative redirects
export default defineConfig({
redirects: {
'/old-blog-post': '/blog/new-slug',
'/legacy/[id]': '/products/[id]',
},
});
// Dynamic redirect in a page
---
if (!Astro.cookies.get('session')) {
return Astro.redirect('/login');
}
---
Q24How do you integrate Tailwind CSS with Astro?
IntermediateStyling
Answer
Run `npx astro add tailwind`, Astro will install `@astrojs/tailwind` (or `@tailwindcss/vite` in 5.x with Tailwind 4), create `tailwind.config.mjs`, and add the integration. Then use Tailwind classes in any `.astro` or framework component. Astro + Tailwind is one of the most popular combos in production because: (1) Tailwind's utility-first approach means almost no runtime CSS, (2) Astro's static-first approach means almost no runtime JS, together they produce extremely small payloads.
Most Indian marketing sites built in Astro use this stack. For Tailwind 4 (released late 2025), the CSS-first config approach pairs especially well with Astro's no-runtime philosophy. Be precise about the wiring, because it changed: with Tailwind 4 you no longer use the `@astrojs/tailwind` integration, you register the `@tailwindcss/vite` plugin under the `vite.plugins` key in `astro.config.mjs` and write `@import "tailwindcss";` at the top of a global stylesheet that your base layout imports.
Design tokens move out of `tailwind.config.mjs` into an `@theme { --color-brand: #4f46e5; }` block in that CSS file. Things to watch in an Astro project specifically: Tailwind's scanner only sees class names that appear as complete literal strings, so `class={`text-${size}`}` produces nothing at build and you use `class:list` with full class names instead; utilities are global while `<style>` blocks are scoped, and because scoped styles carry an extra attribute selector they win over a plain utility, which surprises people debugging a padding that will not change; and `@apply` inside a scoped block works but reintroduces the specificity problem, so most teams keep it for a handful of base elements only. Add `prettier-plugin-tailwindcss` with `prettier-plugin-astro` so class ordering stays stable across a team, and confirm `content` scanning picks up `.astro`, `.mdx`, and any framework component files.
// astro.config.mjs (Tailwind 4 via the Vite plugin)
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: { plugins: [tailwindcss()] },
});
// src/styles/global.css
// @import "tailwindcss";
// @theme { --color-brand: #4f46e5; }
// In any component
<div class="flex items-center justify-between p-4 bg-slate-50">
<h1 class="text-2xl font-bold text-slate-900">Hello</h1>
<button class:list={['px-4 py-2 rounded text-white', isPrimary ? 'bg-indigo-600' : 'bg-slate-500']}>
Sign up
</button>
</div>
Q25What gotchas come up with `client:only` and how do you debug them?
IntermediateHydration
Answer
`client:only={framework}` tells Astro to skip server rendering entirely, the component only renders in the browser. You're forced to use it when a component depends on browser-only APIs (window, localStorage, IndexedDB, charts libs that read DOM dimensions). The gotchas: (1) **You must specify the framework** (`client:only='react'`), without it, Astro doesn't know how to handle the component. (2) **No SEO**, the content is missing from the initial HTML, so Google won't index it. (3) **CLS / layout shift**, the page renders without the component, then jumps when it loads.
Mitigate with a placeholder of the correct size in the slot. (4) **Hydration mismatches don't apply**, there's no server render to mismatch against. Debug by checking the HTML output: if your component renders inside a `<astro-island>` with no children, that's client:only. A few more specifics.
The framework string must match the integration name exactly (`react`, `preact`, `vue`, `svelte`, `solid-js`, or the package name for a custom renderer); get it wrong and Astro raises the `NoClientOnlyHint` error telling you it cannot determine the renderer. Because the module is never imported on the server, `client:only` doubles as the escape hatch for libraries that crash at import time under Node, which is a legitimate reason to use it rather than a failure. Props are still serialized into the `<astro-island>` element, so the usual restriction holds: no functions, no class instances.
Styles imported by a `client:only` component are not present in the initial HTML, which produces a flash of unstyled content, so import that CSS from a parent `.astro` file instead. Before reaching for it, try the cheaper fix: keep the component server-rendered with `client:visible` and move the browser-only call into `useEffect` or an `onMount`, or dynamically `import()` the offending library after mount, so the shell still ships in the HTML for crawlers.
<!-- Chart.js needs window.innerWidth, can't SSR -->
<RevenueChart client:only="react" data={revenue}>
<!-- Placeholder reserves space, avoids CLS -->
<div slot="fallback" style="height: 400px; background: #f3f4f6;">
Loading chart...
</div>
</RevenueChart>
Q26How do you deploy an Astro site, and what's the difference between deploying static vs SSR?
IntermediateDeployment
Answer
For static output, deploy the `dist/` folder anywhere, Netlify, Vercel, Cloudflare Pages, GitHub Pages, S3+CloudFront, even nginx. No Node runtime needed. For SSR/hybrid, you need an adapter that matches your host: `@astrojs/node` (self-hosted Node), `@astrojs/vercel`, `@astrojs/cloudflare`, `@astrojs/netlify`, `@astrojs/deno`.
Add `npx astro add <adapter>`. The adapter handles the host-specific request/response shape. Cloudflare Pages with the Cloudflare adapter is extremely popular in 2026, runs on the edge (300+ locations), free tier is generous, integrates with Cloudflare R2/D1/KV.
For India: Cloudflare Pages or Vercel are most common; static sites also go on GitHub Pages or Netlify free tier. The operational differences are what interviewers probe. A static build writes everything into `dist/`; the moment an adapter is present the layout changes to `dist/client/` for assets and `dist/server/` for the handler, so a deploy script hard-coded to `dist/` silently ships an empty site.
Set `site` in `astro.config.mjs` or your sitemap, RSS feed, and canonical tags come out with relative or missing URLs, and set `base` when serving from a subpath such as GitHub Pages project sites. Decide `trailingSlash: 'always' | 'never' | 'ignore'` deliberately and match it to the host's own normalisation, otherwise you get a redirect on every internal link. Runtime constraints bite on edge hosts: the Cloudflare adapter runs on workerd, so Node built-ins need the `nodejs_compat` flag, and Sharp cannot run there, which is why you pass `imageService: 'compile'` or `'passthrough'` to the adapter.
Vercel and Netlify adapters expose ISR and on-demand revalidation; a self-hosted `@astrojs/node` deployment gets neither, so you pair it with a CDN and `stale-while-revalidate` headers. Note also that the first-party Deno adapter was retired and lives on as a community package.
// astro.config.mjs, SSR on Cloudflare Pages
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
site: 'https://example.com',
trailingSlash: 'never',
output: 'static', // opt individual routes in with prerender = false
adapter: cloudflare({ imageService: 'compile' }), // Sharp cannot run on workerd
});
// Build output with an adapter:
// dist/client/ static assets, hashed _astro/ chunks
// dist/server/ the request handler entrypoint
// Without an adapter:
// dist/ plain .html files, no runtime required
Q27How does image optimization work with `astro:assets`, and what changes for remote images?
IntermediatePerformance
Answer
`astro:assets` gives you `<Image>`, `<Picture>`, and `getImage()`, backed by Sharp as the default image service. Images imported from `src/` are first-class assets: the import returns an object with `src`, `width`, `height`, and `format`, so Astro can infer dimensions, emit a hashed filename, and generate optimized variants at build. That inference is why local images cannot cause layout shift.
Anything in `public/` is copied verbatim and is never processed, so you must pass `width` and `height` yourself and you get no format conversion. Remote images are the case interviewers press on: Astro refuses to optimize an arbitrary URL, so you allowlist origins with `image.domains: ['cdn.example.com']` or the stricter `image.remotePatterns: [{ protocol: 'https', hostname: '**.example.com' }]`, and you must supply `width` and `height` (or `inferSize: true`, which costs a fetch at build). `<Image>` renders a single `<img>` with `loading="lazy"` and `decoding="async"` by default; `<Picture>` renders a `<picture>` with `formats={['avif','webp']}` and a fallback. Use `widths` with `sizes` for true responsive art direction, `densities` for simple 1x/2x, and `getImage()` when the result must go into a CSS background or an Open Graph meta tag.
For an LCP hero, override the defaults with `loading="eager"` and `fetchpriority="high"`. Recent 5.x releases also stabilized responsive image layouts, where `image.layout` in config plus a `layout` prop (`constrained`, `fixed`, `full-width`) generates the `srcset` and sizing styles for you.
---
import { Image, Picture, getImage } from 'astro:assets';
import hero from '../assets/hero.jpg'; // width/height inferred at build
---
<!-- LCP image: eager + high priority -->
<Image src={hero} alt="Team at work" loading="eager" fetchpriority="high" widths={[400, 800, 1200]} sizes="(max-width: 768px) 100vw, 1200px" />
<!-- AVIF + WebP with an automatic fallback -->
<Picture src={hero} formats={['avif', 'webp']} alt="Team at work" />
<!-- Remote image: origin must be allowlisted, dimensions required -->
<Image src="https://cdn.example.com/a.jpg" alt="" width={800} height={600} />
---
// Optimized URL for a CSS background or an og:image tag
const og = await getImage({ src: hero, width: 1200, height: 630, format: 'webp' });
---
<meta property="og:image" content={new URL(og.src, Astro.site)} />
Key Points
- Import from `src/` to get inferred dimensions and build-time optimization
- `public/` images are never processed and need manual width and height
- Remote images require `image.domains` or `image.remotePatterns`
- `getImage()` for CSS backgrounds and og:image URLs
Q28How does Astro's link prefetching work and when should you turn it down?
IntermediatePerformance
Answer
Prefetching is built in. Set `prefetch: true` in `astro.config.mjs` and Astro starts fetching the destination document for same-origin `<a>` links before the click lands, using `<link rel="prefetch">` where supported and falling back to a low-priority `fetch()`. It is enabled automatically when you add `<ClientRouter />`, which is why sites often prefetch without anyone configuring it.
Four strategies exist: `hover` (the default, fires on mouseenter or focus), `tap` (fires on mousedown or touchstart, the safest on mobile because it only costs a request when intent is near-certain), `viewport` (an IntersectionObserver prefetches every link that scrolls into view), and `load` (prefetches all eligible links immediately). You set a site-wide default with `prefetch: { prefetchAll: true, defaultStrategy: 'hover' }` and override per link with `data-astro-prefetch="viewport"` or opt a link out with `data-astro-prefetch="false"`. For programmatic control, import `prefetch()` from `astro:prefetch`.
The tuning judgement is the interesting part of the answer: `viewport` on a listing page with 200 result links can pull megabytes of documents that are never opened, which on a metered mobile connection is a real cost, so reserve it for a small set of high-intent links such as pagination or the top result. Astro already skips prefetching when the browser reports Save-Data or a 2G-class connection, and prefetch only covers the HTML document, not the API calls a hydrated island makes after it loads.
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
prefetch: {
prefetchAll: true, // every internal <a>, not just tagged ones
defaultStrategy: 'hover' // 'hover' | 'tap' | 'viewport' | 'load'
},
});
// Per-link overrides
<a href="/pricing" data-astro-prefetch="viewport">Pricing</a>
<a href="/huge-report.pdf" data-astro-prefetch="false">Download</a>
<script>
import { prefetch } from 'astro:prefetch';
// Warm the next page once the user reaches the bottom of the list
prefetch('/jobs?page=2', { ignoreSlowConnection: false });
</script>
Q29How would you architect a large-scale content site (10,000+ pages) in Astro?
AdvancedArchitecture
Answer
At 10k+ pages, build times become the primary concern. Strategies: (1) **Incremental Static Regeneration (ISR)** via Vercel or Netlify adapters, only rebuild pages that changed. (2) **Hybrid output**: pre-render the top 1000 most-visited pages, server-render the long tail on demand and cache aggressively at the CDN. (3) **Content Layer API caching**, Astro 5.x caches loader results across builds; expensive CMS fetches don't re-run unless content changed. (4) **Split builds**, separate Astro projects per content section, deployed independently, stitched together by a reverse proxy or a `redirects` config. (5) **Parallelize image processing**, Astro's built-in `<Image>` component generates responsive sizes; for thousands of images, offload to a CDN like Cloudflare Images or Imgix instead of building locally. The Guardian and Cloudflare's docs both run Astro at this scale; their builds use sharding and aggressive caching. For India: GeeksforGeeks landing pages (a few hundred URLs) build in minutes; The Hindu archive (millions of pages) would need ISR or splitting.
Key Points
- ISR with Vercel/Netlify for incremental rebuilds
- Hybrid output: pre-render hot, SSR long-tail
- Content Layer cache survives across builds
- Offload image processing to CDN at scale
- Split into multiple Astro projects if monorepo grows unwieldy
Q30How does Astro's hydration model differ from React Server Components, and what are the trade-offs?
AdvancedArchitecture
Answer
Both Astro Islands and React Server Components (RSC) aim to ship less JavaScript, but they're philosophically different. **RSC**: every React component is either a server component or a client component; server components render on the server, client components hydrate. The boundary is implicit (file directives like `'use client'`) and the entire tree is React. Server and client components can interleave deeply. **Astro Islands**: the page is mostly static HTML (not React); interactive components are framework-agnostic islands that hydrate independently.
The boundary is explicit (`client:*` directives). Islands don't share state with each other or with the page. **Trade-offs**: RSC gives you a unified component model and easy state sharing (one React tree); Astro gives you smaller bundles, multi-framework support, and clearer mental model (it's HTML with islands, not a React app). For app-heavy products (dashboards, SaaS), RSC's seamless model wins.
For content-heavy sites, Astro's explicit islands win on bundle size and TTI. In 2026, both are valid, pick based on whether your site is closer to a magazine or to a SaaS app.
Q31How do you implement i18n (internationalization) in a production Astro site?
Advancedi18n
Answer
Astro 4.x added first-class i18n routing. Configure locales in `astro.config.mjs`: `i18n: { defaultLocale: 'en', locales: ['en', 'hi', 'es'] }`. URLs become `/hi/about`, `/es/about`, etc. Strategies: (1) **Per-locale content collections**: organize markdown as `src/content/blog/en/*.md`, `src/content/blog/hi/*.md`.
Use `getCollection('blog', e => e.id.startsWith('en/'))` to filter. (2) **UI strings**: keep a JSON file per locale (`src/i18n/en.json`, `src/i18n/hi.json`), import based on `Astro.currentLocale`. Libraries like `astro-i18n` add fallbacks and pluralization. (3) **SEO**: emit `<link rel='alternate' hreflang='hi' href='...'>` tags from a layout, use `routing: 'prefix-other-locales'` so the default locale URL is clean. **India-specific**: Hindi/Tamil/Bengali content sites typically pair Astro i18n with Devanagari-aware fonts (Noto Sans Devanagari) and need careful image OG-tag generation per locale. Razorpay's docs site uses this pattern across English, Hindi, and Tamil.
The helpers you should name are in `astro:i18n`: `getRelativeLocaleUrl('hi', 'about')` builds correct links without string-concatenating locale prefixes, `getAbsoluteLocaleUrl` does the same with `site` prepended for canonical and hreflang tags, and `getRelativeLocaleUrlList()` generates the full alternates block for a page. `Astro.currentLocale` is derived from the URL, so it is `undefined` on routes that sit outside the locale structure, which is a common source of `undefined` string lookups. Configure `fallback: { hi: 'en' }` so a missing Hindi page serves the English one instead of 404ing while translation catches up, and if each language lives on its own domain, `i18n.domains` handles that but requires SSR and an adapter. For anything beyond prefix routing, Astro 4.6 and later support `routing: 'manual'`, where you import the i18n middleware from `astro:i18n` and decide redirects yourself, which is how you honour `Accept-Language` on first visit while keeping the chosen locale in a cookie. Skip a translation library for dates and numbers: `new Intl.DateTimeFormat(Astro.currentLocale).format(date)` runs at build and ships nothing.
// astro.config.mjs
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'hi', 'es'],
routing: {
prefixDefaultLocale: false,
redirectToDefaultLocale: false,
},
},
});
// In a page
---
import en from '../i18n/en.json';
import hi from '../i18n/hi.json';
const strings = Astro.currentLocale === 'hi' ? hi : en;
---
<h1>{strings.welcome}</h1>
Q32How do you optimize a large Astro site for Core Web Vitals beyond the defaults?
AdvancedPerformance
Answer
Astro's defaults already produce excellent CWV scores, but at scale you need more. (1) **LCP**: use Astro's `<Image>` component (powered by Sharp), it generates AVIF + WebP + responsive sizes and adds `loading='lazy'` for off-screen images. For LCP image, set `loading='eager'` and `fetchpriority='high'`. (2) **CLS**: always specify `width` and `height` on images and reserve space for client islands with fallback slots. (3) **INP** (replacing FID in 2024): minimize `client:load` islands; prefer `client:visible` or `client:idle`. Audit interactive components with Chrome DevTools Performance panel. (4) **TBT**: split heavy components, a 200 KB chart library shouldn't be in a `client:load` hero. (5) **Font loading**: use `astro-font` or self-host with `<link rel='preload' as='font' crossorigin>`.
Avoid Google Fonts CDN; inline critical CSS via Astro's built-in. (6) **Edge caching**: deploy to Cloudflare/Vercel edge, set `Cache-Control: s-maxage=86400, stale-while-revalidate=604800` so revalidation is asynchronous. India-specific: many users on 3G, every KB matters. Razorpay's marketing site hits LCP <1.2s on slow 3G largely due to Astro's near-zero JS approach.
Key Points
- Use built-in <Image> for AVIF + responsive + lazy
- Reserve space to avoid CLS on islands
- Prefer client:visible / client:idle over client:load
- Self-host fonts, inline critical CSS
- Edge cache with stale-while-revalidate
Q33How do you handle authentication and protected routes in Astro?
AdvancedAuthentication
Answer
Astro deliberately doesn't ship an auth solution, it leans on its server adapter and lets you choose. Common patterns: (1) **Cookie-based sessions with Lucia Auth or Auth.js (formerly NextAuth)**: Lucia is the lightweight, type-safe choice in 2026; Auth.js for OAuth-heavy flows. Both integrate cleanly with Astro's middleware. (2) **Middleware**: `src/middleware.ts` exports `onRequest(context, next)`, inspect the session cookie, set `context.locals.user`, optionally redirect.
Every page can read `Astro.locals.user`. (3) **Edge auth via Clerk or Supabase Auth**: their SDKs work in Astro middleware. Most popular for SaaS apps that need email/social login out of the box. (4) **For static + auth**: do auth on the client only, render a public shell, let JavaScript fetch user data after login. Works for dashboards behind a public marketing site. **Critical gotcha**: never use `client:*` directives to gate access, the component still ships in HTML, just hidden.
Real auth must happen server-side. Razorpay-style architecture: marketing on Astro static + dashboard on Next.js (or a separate Astro SSR project) where Clerk handles auth.
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { lucia } from './lib/auth';
export const onRequest = defineMiddleware(async (context, next) => {
const sessionId = context.cookies.get('session')?.value;
if (sessionId) {
const { session, user } = await lucia.validateSession(sessionId);
context.locals.user = user;
}
if (context.url.pathname.startsWith('/dashboard') && !context.locals.user) {
return context.redirect('/login');
}
return next();
});
Q34What are Astro Actions and how do they differ from writing a POST endpoint by hand?
AdvancedActions
Answer
Actions are Astro's typed server functions, introduced experimentally in 4.15 and stable since 5.0. You declare them in `src/actions/index.ts` by exporting a `server` object of `defineAction({ input, handler, accept })` entries, where `input` is a Zod schema imported from `astro:schema`. Astro then generates a client proxy: calling `actions.applyToJob({ jobId })` from an island performs the fetch, validates the payload on the server, and returns `{ data, error }` rather than throwing, so the happy path and the failure path are both typed. `actions.applyToJob.orThrow()` gives you throwing semantics when you prefer try/catch.
Compared with a hand-written `src/pages/api/apply.ts`, you stop maintaining three parallel definitions (the request body type, the runtime validation, and the client fetch wrapper), and you get structured errors: `throw new ActionError({ code: 'UNAUTHORIZED' })` maps to the right HTTP status, and `isInputError(error)` exposes per-field Zod messages you can render next to inputs. The part interviewers care about is progressive enhancement: with `accept: 'form'` you can point a real `<form method="POST" action={actions.applyToJob}>` at the action, and the submission works with JavaScript disabled, while the page reads the outcome with `Astro.getActionResult(actions.applyToJob)`. The constraints to state: actions need an adapter and a server-rendered route, so a prerendered page cannot handle one; handlers run only on the server; and the input schema must be serializable, so file uploads go through `z.instanceof(File)` with `accept: 'form'`.
// src/actions/index.ts
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
applyToJob: defineAction({
accept: 'form',
input: z.object({
jobId: z.string().min(1),
email: z.string().email('Enter a valid email'),
}),
handler: async ({ jobId, email }, ctx) => {
if (!ctx.locals.user) {
throw new ActionError({ code: 'UNAUTHORIZED', message: 'Sign in to apply' });
}
return { applicationId: await createApplication(jobId, email) };
},
}),
};
// src/pages/jobs/[id].astro
---
export const prerender = false;
import { actions } from 'astro:actions';
const result = Astro.getActionResult(actions.applyToJob);
---
<form method="POST" action={actions.applyToJob}>
<input type="hidden" name="jobId" value={Astro.params.id} />
<input type="email" name="email" required />
<button>Apply</button>
</form>
{result?.error && <p class="error">{result.error.message}</p>}
Key Points
- `defineAction` + Zod from `astro:schema` gives one shared type across client and server
- Calls return `{ data, error }`; use `.orThrow()` for throwing semantics
- `isInputError(error)` yields per-field validation messages
- `accept: 'form'` keeps the form working without JavaScript
- Requires an adapter and a route with `prerender = false`
Q35Two islands need to share state. How do you wire that up in Astro?
AdvancedIslands
Answer
You cannot lift state into a common parent, because the parent is an `.astro` component that has already finished rendering and has no runtime presence. Each island is its own hydration root, so a React cart badge in the header and a Svelte cart drawer in the footer share no context. The idiomatic answer is a framework-agnostic store, and the Astro docs standardise on nanostores: define `atom()` or `map()` in a plain `.ts` module, import it in every island, and subscribe with the thin adapter for that framework (`useStore` from `@nanostores/react`, `useStore` from `@nanostores/vue`, or `$store` auto-subscription in Svelte).
Because the store lives in a shared ES module, both islands get the same instance, and cross-framework sharing works without any bridge code. The alternatives are worth naming: `CustomEvent` dispatched on `document` for one-way notifications, `localStorage` plus the `storage` event for cross-tab sync, `@nanostores/persistent` when you want both, and simply putting state in the URL with `history.replaceState` when it should be shareable and bookmarkable. Two gotchas an interviewer will look for.
First, server-rendered island HTML reflects the store's default value, not the user's real state, so a cart count renders as 0 and then flips after hydration; seed it from a prop or accept the flash and reserve the space. Second, with `<ClientRouter />` the document is never torn down, so module-level store state deliberately survives navigation, which is a feature for a cart and a bug for a form you expected to reset.
// src/stores/cart.ts (plain module, no framework)
import { atom, computed } from 'nanostores';
export const cartItems = atom<{ id: string; qty: number }[]>([]);
export const cartCount = computed(cartItems, (items) =>
items.reduce((n, i) => n + i.qty, 0)
);
export function addItem(id: string) {
cartItems.set([...cartItems.get(), { id, qty: 1 }]);
}
// src/components/CartBadge.jsx (React island)
import { useStore } from '@nanostores/react';
import { cartCount } from '../stores/cart';
export default function CartBadge({ initial = 0 }) {
const count = useStore(cartCount);
return <span>{count || initial}</span>;
}
<!-- src/pages/shop.astro: different frameworks, one store -->
<CartBadge client:load initial={serverCount} />
<CartDrawer client:visible />
Q36How does `src/middleware.ts` work in Astro, and why does it not protect prerendered pages?
AdvancedMiddleware
Answer
Middleware lives at `src/middleware.ts` (or `src/middleware/index.ts`) and exports `onRequest(context, next)`. It runs before the matched page or endpoint, receives the same context object as a page (`request`, `url`, `params`, `cookies`, `locals`, `redirect`, `rewrite`), and must either return a `Response` or the result of `await next()`. Anything you assign to `context.locals` is readable as `Astro.locals` in the page and as `ctx.locals` in endpoints and actions, and you type it by declaring `namespace App { interface Locals { user: User | null } }` in `src/env.d.ts`.
Compose multiple handlers with `sequence(auth, logging, i18n)` from `astro:middleware`; they run in array order, and code placed after `await next()` runs in reverse order, which is how you add a `Server-Timing` header around the whole render. `context.rewrite('/maintenance')` renders a different route under the same URL without a redirect. The trap, and it is a real security bug in production Astro apps: for a prerendered route, middleware runs at build time, not per request, so a session check there executes once on your CI machine and the resulting HTML is then served to everyone by the CDN. Anything you actually need to gate must live on a route with `export const prerender = false`. Two more notes: `next()` returns a `Response` whose body you can read and transform, and middleware does not run for static assets under `_astro/` or `public/`.
// src/middleware.ts
import { defineMiddleware, sequence } from 'astro:middleware';
const auth = defineMiddleware(async (context, next) => {
const token = context.cookies.get('session')?.value;
context.locals.user = token ? await verify(token) : null;
// Only meaningful because /dashboard sets prerender = false
if (context.url.pathname.startsWith('/dashboard') && !context.locals.user) {
return context.redirect('/login', 302);
}
return next();
});
const timing = defineMiddleware(async (context, next) => {
const start = performance.now();
const response = await next(); // runs the page
response.headers.set('Server-Timing', `render;dur=${performance.now() - start}`);
return response; // post-next code runs in reverse order
});
export const onRequest = sequence(auth, timing);
// src/env.d.ts
// declare namespace App {
// interface Locals { user: { id: string; email: string } | null }
// }
Key Points
- `context.locals` is the channel between middleware and pages, typed via `App.Locals`
- `sequence()` composes; post-`next()` code unwinds in reverse
- Middleware runs at BUILD time for prerendered routes, so it cannot gate them
- `context.rewrite()` swaps the rendered route without changing the URL
Q37How do you test an Astro project: components, actions, and the shipped bundle?
AdvancedTesting
Answer
Astro testing works in three layers and a good answer names all three. Static checking comes first: `astro check` (which needs `@astrojs/check` and `typescript` installed) type-checks expressions inside `.astro` templates, props against your `interface Props`, and content collection frontmatter, and it belongs in CI because plain `tsc` does not parse `.astro` files. Unit tests run in Vitest configured through `getViteConfig` from `astro/config`, which is what makes aliases, `import.meta.env`, and virtual modules such as `astro:content` resolve inside tests.
For component-level tests, the Container API renders an `.astro` component to an HTML string outside a browser: create it with `experimental_AstroContainer.create()` and call `renderToString(Component, { props, slots, params, locals, request })`, then assert on the markup. Rendering a framework island inside a container requires registering that renderer with `addServerRenderer`. Actions and content loaders are plain functions, so test their handlers directly with a fake `locals` object rather than through HTTP.
The final layer is end-to-end with Playwright, and the important detail is to run it against `astro build && astro preview` rather than `astro dev`, because dev serves unbundled modules and will not catch a broken `_astro/` chunk, a wrong `base`, or a prop that fails serialization only in the production build. Playwright is also the only realistic way to assert island behaviour: that a `client:visible` component's chunk is not requested until you scroll it into view.
// vitest.config.ts
import { getViteConfig } from 'astro/config';
export default getViteConfig({ test: { environment: 'node', globals: true } });
// test/card.test.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { expect, test } from 'vitest';
import Card from '../src/components/Card.astro';
test('Card renders the title and CTA', async () => {
const container = await AstroContainer.create();
const html = await container.renderToString(Card, {
props: { title: 'Frontend Engineer', href: '/jobs/1' },
slots: { default: '<p>Bangalore</p>' },
});
expect(html).toContain('Frontend Engineer');
expect(html).toContain('href="/jobs/1"');
});
// e2e/island.spec.ts (Playwright, run against astro build && astro preview)
test('below-fold island hydrates only after scroll', async ({ page }) => {
const requests: string[] = [];
page.on('request', (r) => requests.push(r.url()));
await page.goto('/');
expect(requests.some((u) => u.includes('NewsletterSignup'))).toBe(false);
await page.getByRole('heading', { name: 'Newsletter' }).scrollIntoViewIfNeeded();
await expect(page.getByRole('button', { name: 'Subscribe' })).toBeEnabled();
});
Key Points
- `astro check` type-checks `.astro` templates; plain `tsc` cannot
- Vitest via `getViteConfig` resolves `astro:` virtual modules
- Container API renders `.astro` components to HTML strings
- Run Playwright against `astro preview`, not `astro dev`
Q38An island renders correctly but is not interactive in production, while it works in `astro dev`. How do you debug it?
AdvancedDebugging
Answer
Work down a fixed checklist rather than guessing. First, view source and look for an `<astro-island>` wrapper around the component. No wrapper means no `client:*` directive reached the element, usually because the directive was written on an `.astro` wrapper component instead of on the framework component itself, since directives do not pass through.
If the wrapper exists, read its `client` and `component-url` attributes, then check the network tab: a 404 on that `_astro/` chunk almost always means a wrong `base` or a host rewriting asset paths, and a chunk that never gets requested with `client="visible"` means the IntersectionObserver never fired, which happens when an ancestor has `display: none` or the element has zero height until an image loads. Next, open the console: an exception thrown during hydration leaves the server-rendered HTML on screen and looks exactly like a static component, and the usual cause is module-scope access to `window`, `document`, or `localStorage` in the island or one of its imports. That is the classic dev-versus-production divergence, along with a prop that only fails serialization in the build.
Reproduce locally with `astro build && astro preview`, never with `astro dev`. Also check for a nested island passed as slot children of another island, which never hydrates, and for `<ClientRouter />` in play, where initialisation bound to `DOMContentLoaded` runs once and never again after the first swap. The dev toolbar's audit panel and `astro info` for exact versions round out the report you hand back.
<!-- WRONG: the directive is on the Astro wrapper, so nothing hydrates -->
<CardWrapper client:load>
<Counter /> <!-- plain HTML, no island -->
</CardWrapper>
<!-- RIGHT: the directive belongs on the framework component -->
<CardWrapper>
<Counter client:load />
</CardWrapper>
// Island that dies during hydration in production only
import Chart from 'some-chart-lib';
const width = window.innerWidth; // BUG: runs during SSR at module scope
export default function Panel() { /* ... */ }
// Fix: read browser APIs after mount
useEffect(() => { setWidth(window.innerWidth); }, []);
# Reproduce the real bundle
npx astro build && npx astro preview
npx astro info # versions, adapter, integrations for the bug report
Q39What breaks when upgrading an Astro 4 project to Astro 5, and how do you sequence the migration?
AdvancedMigration
Answer
Run `npx @astrojs/upgrade` first so core, integrations, and adapters move together, then work through the known breaking changes. Node requirements tighten: 18.17.1, 20.3.0, or 22 and above, with odd-numbered releases unsupported, so pin the CI image before anything else. `output: 'hybrid'` is gone; use `output: 'static'` with `export const prerender = false` on the dynamic routes. Content Collections change the most: `type: 'content'` is replaced by `loader: glob({ pattern, base })`, `entry.slug` becomes `entry.id`, `getEntryBySlug` becomes `getEntry`, and `await entry.render()` becomes `const { Content } = await render(entry)` with `render` imported from `astro:content`.
If that is too much churn in one step, set the `legacy.collections` flag, ship the upgrade, and migrate collections separately. `<ViewTransitions />` is renamed `<ClientRouter />`. The Squoosh image service was removed, so Sharp is the only built-in option and any pinned `squoosh` config must go. Vite 6 underneath means custom `vite` config and older plugins need checking. `Astro.glob()` is deprecated in favour of `import.meta.glob()`.
On the other side, 5.x is where Actions, Server Islands, and `astro:env` all became stable, which is usually the reason to upgrade at all. Sequence it as: bump Node, run the upgrade tool, get `astro check` green, run `astro build` and fix every deprecation warning it prints, then remove `legacy.collections` in a follow-up pull request.
// Astro 4.x
import { defineCollection, getEntryBySlug, z } from 'astro:content';
const blog = defineCollection({ type: 'content', schema: z.object({ title: z.string() }) });
const post = await getEntryBySlug('blog', 'hello');
const { Content } = await post.render();
// Astro 5.x
import { defineCollection, getEntry, render, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({ title: z.string() }),
});
const post = await getEntry('blog', 'hello'); // entry.id, not entry.slug
const { Content } = await render(post);
// astro.config.mjs escape hatch while you migrate incrementally
export default defineConfig({
output: 'static', // 'hybrid' removed in 5.0
legacy: { collections: true },
});
Key Points
- `output: 'hybrid'` removed; use `static` plus `prerender = false`
- Collections: `loader` replaces `type`, `entry.id` replaces `entry.slug`, `render(entry)` replaces `entry.render()`
- `ViewTransitions` renamed to `ClientRouter`
- Squoosh image service removed, Sharp only; Vite 6 underneath
- `legacy.collections` lets you split the migration into two pull requests
Q40How do you write a custom Astro integration, and which lifecycle hooks matter?
AdvancedIntegrations
Answer
An integration is a function returning an object with a `name` and a `hooks` map, typed as `AstroIntegration`. The workhorse hook is `astro:config:setup`, which receives `config`, `command` (`dev`, `build`, or `preview`), `isRestart`, `logger`, and a set of mutators: `updateConfig()` to merge Vite or Astro options, `injectRoute({ pattern, entrypoint, prerender })` to add pages the user did not write, `injectScript(stage, code)` where the stage is `head-inline`, `before-hydration`, `page`, or `page-ssr`, `addWatchFile()` so editing an external config restarts dev, `addMiddleware({ entrypoint, order })`, `addRenderer()` for a UI framework, and `addDevToolbarApp()`. Use `astro:config:done` when you need the fully resolved config, including changes made by integrations that ran after yours, since ordering follows the array in `astro.config.mjs`. `astro:server:setup` gives you the Vite dev server for middleware or mock endpoints, `astro:build:start` and `astro:build:setup` bracket the build, and `astro:build:done` receives `dir`, `pages`, `routes`, and `assets`, which is where you write extra files such as a search index or `robots.txt` and where sitemap generation happens.
Astro 5.x also exposes `astro:route:setup` for per-route tweaks. The practical guidance: prefer `injectRoute` with `prerender: true` for generated endpoints so static sites stay static, always emit through `logger` rather than `console.log` so output respects the CLI's formatting, and keep hooks synchronous where you can, because every awaited hook adds directly to cold dev-server start time.
// integrations/search-index.ts
import type { AstroIntegration } from 'astro';
import { writeFile } from 'node:fs/promises';
export default function searchIndex(): AstroIntegration {
return {
name: 'search-index',
hooks: {
'astro:config:setup': ({ command, injectRoute, addWatchFile, logger }) => {
injectRoute({
pattern: '/search.json',
entrypoint: './src/generated/search-endpoint.ts',
prerender: true,
});
addWatchFile(new URL('./search.config.json', import.meta.url));
logger.info(`wired up during ${command}`);
},
'astro:build:done': async ({ dir, pages, logger }) => {
const index = pages.map((p) => ({ url: `/${p.pathname}` }));
await writeFile(new URL('./index.json', dir), JSON.stringify(index));
logger.info(`indexed ${index.length} pages`);
},
},
};
}
// astro.config.mjs
// integrations: [searchIndex()] // hooks run in array order
Frequently Asked Questions
Is Astro a good choice in 2026 compared to Next.js?
For content-focused sites (blogs, docs, marketing, e-commerce catalogs), Astro is the better default, smaller bundles, better Lighthouse scores, faster builds. For app-heavy products (dashboards, SaaS), Next.js is still stronger. Many companies in India use BOTH: Astro for the marketing site, Next.js for the product app. They're complementary, not competitive.
How much does an Astro developer earn in India?
₹6-20 LPA in 2026 for mid-to-senior frontend developers with Astro experience. Most Astro roles are part of broader 'frontend engineer' positions, pure Astro-only roles are rare. Companies hiring: Razorpay, Postman, Zomato, and many JAMstack-focused agencies. SEO/marketing-focused teams pay at the upper end.
Can I use Astro with my existing React component library?
Yes, that's one of Astro's biggest strengths. Add `@astrojs/react`, then import any `.jsx`/`.tsx` component into `.astro` files. Most React libraries that don't depend on Next.js-specific APIs (like `next/image` or `next/link`) work out of the box. For component libraries like shadcn/ui, MUI, Mantine, all work. The main gotcha is libraries that require a client-only environment (charts, editors); use `client:only='react'` for those.
Does Astro support TypeScript?
Yes, TypeScript is first-class in Astro since 1.0. `.astro` files support TypeScript in the frontmatter section by default. Run `tsc --noEmit` or `astro check` in CI to type-check the project. Content Collections give you typed frontmatter via Zod schemas. Most production Astro projects in 2026 use TypeScript end-to-end.
How big can an Astro project get before performance suffers?
Build time scales roughly linearly with page count and image processing. A 1,000-page site builds in 30-60 seconds; a 10,000-page site can take 5-15 minutes. At that scale, use ISR (Vercel/Netlify) or split into multiple projects. Runtime performance doesn't degrade, every page is just static HTML, served by the CDN. The Guardian runs millions of pages on Astro; Cloudflare's docs site has thousands.
How long does it take to prepare for an Astro interview?
If you already write React or Vue daily, two to three weeks of evenings is realistic: about a week to build and deploy one real site (content collections, dynamic routes, an island or two), a week on the 5.x surface (Content Layer loaders, Server Islands, Actions, `astro:env`, middleware), and a few days rehearsing trade-off answers such as `client:load` versus `client:visible` and static versus SSR. Coming in without a component-framework background, budget six to eight weeks and learn one UI framework alongside Astro, because almost every Astro job also expects React. Ship the practice site publicly with a Lighthouse score you can point at; interviewers ask for a URL far more often than for a certificate.
What do interviewers expect from a fresher versus an experienced candidate on Astro?
Freshers are assessed on fundamentals and evidence: what the `---` fence does, why `client:visible` beats `client:load` below the fold, how `getStaticPaths()` feeds a dynamic route, and one deployed project you can walk through. Nobody expects adapter internals. At three years and beyond the questions turn operational: how you kept a 10,000-page build under CI limits, why an island stopped hydrating in production, how you gated a route when middleware runs at build time for prerendered pages, and how you sequenced an Astro 4 to 5 upgrade. Senior candidates are also expected to argue against Astro when the product is app-shaped, since knowing where the model breaks is treated as the real signal.
Should I learn Astro or Next.js first for frontend roles in India?
Learn React well, then Next.js, then Astro. Volume-wise, Indian job posts asking specifically for Astro are far fewer than React or Next.js posts, so Astro is best treated as a differentiator on top of a mainstream stack rather than a first framework. It is a strong differentiator though: content, SEO, and growth teams building marketing sites and documentation care about shipped JavaScript and Core Web Vitals, and Astro experience maps directly onto those conversations. The adjacent comparisons interviewers make are Gatsby (largely superseded), Hugo and Eleventy (fast but no component-framework islands), and Next.js (stronger for app-shaped products). Being able to place Astro precisely against those four is worth more than memorising directives.
Introduction
Astro has carved out a distinct niche in the 2026 frontend landscape: it's the framework you reach for when content and performance matter more than client-side interactivity. Blogs, documentation sites, marketing pages, e-commerce listings, anywhere shipping less JavaScript directly translates to better Lighthouse scores, faster TTI, and higher SEO rankings.
What makes Astro fundamentally different from Next.js or Nuxt is its Islands Architecture. By default, Astro ships zero JavaScript to the browser. UI frameworks like React, Vue, Svelte, and Solid are used only for the parts of the page that need interactivity, and you can mix them all in the same project. The rest is plain HTML, server-rendered at build time or on demand.
In India, Astro has seen rapid adoption among SEO-focused teams: marketing teams at Razorpay, Postman docs, and GeeksforGeeks landing pages are notable examples. If you're interviewing for an Astro role in 2026, expect deep questions on Islands Architecture, hydration directives (`client:*`), content collections with Zod, server islands (Astro 5.x), and the trade-offs between static, SSR, and hybrid output modes. This guide walks through the 40 most-asked Astro interview questions, grouped by difficulty.
The 40 questions below run basic first, then intermediate, then advanced. Beyond the fundamentals, they cover the Astro 5.x surface that interviewers now probe directly: the Content Layer `loader` API, Server Islands with `server:defer`, typed environment variables through `astro:env`, Astro Actions with `defineAction` and `isInputError`, middleware composed with `sequence()`, image handling via `astro:assets`, cross-island state with nanostores, component testing through the Container API, custom integrations built on the `astro:config:setup` hook, and the concrete 4.x to 5.x breaking changes (`output: 'hybrid'` removed, `ViewTransitions` renamed to `ClientRouter`, `entry.slug` replaced by `entry.id`). Every code block is runnable Astro, not pseudocode.
Ready to practice Astro interviews?
Don't just read, practice these Astro questions live with an AI interviewer that asks follow-ups and scores your answers.