What is Astro, and when should you choose it?

Astro is an open-source web framework designed for content-heavy websites. Its core idea is simple: pages are turned into HTML at build time by default, and Astro components send no runtime of their own to the browser. A UI component from React, Vue or Svelte is not hydrated in the browser unless it gets a client:* directive; where interaction is needed, only that part of the page comes to life as an “island”. <script> tags you add yourself and hydrated islands do, of course, send JavaScript. As of 6 October 2026, when I wrote this guide, the current stable release is Astro 7.3.5, published on 24 September 2026; the installation guide requires Node.js 22.12.0 or newer and does not support odd-numbered Node versions such as 23.

Astro 7 (22 June 2026) moved the .astro compiler and the default Markdown/MDX processor to Rust, switched to Vite 8 and Rolldown, and made route caching stable. The previous major release, Astro 6 (10 March 2026), rebuilt the development server so it can use the production runtime, and introduced a built-in Fonts API, Live Content Collections and a Content Security Policy (CSP) API. If you follow an older tutorial, knowing these changes makes it easier to understand why copied code doesn't work.

The strongest reason to choose Astro is a content-first project: blogs, documentation, portfolios, corporate and marketing pages. Application frameworks such as Next.js, Nuxt or SvelteKit may be a more natural starting point for state-heavy interfaces that change per signed-in user. The right question is not “which one is better” but “how much of this project is truly interactive, and how much must be fresh on every request?” For an admin panel or a personalised product screen Astro may be an unnecessary detour; for a content site it is often the simplest path.

To make the difference concrete, look at the component models. In Next.js, pages combine server and client components, and the React application lives in the browser too. Nuxt renders the Vue application on the server and brings it to life in the browser through hydration. SvelteKit is likewise a full application framework. Astro starts from the opposite direction: the page is HTML, and a UI component comes to life in the browser only when you ask for it. Measure your own production build to see which approach sends less JavaScript for your site; on the other hand, in an application where many screens share the same client state, connecting Astro's islands can mean extra work.

Let me show you an example: the site you are reading is itself a static build produced with Astro. The published HTML contains CSS and JavaScript files from the /_astro/ folder whose names carry a content fingerprint. That does not mean “no JavaScript at all”; the pages also contain inline scripts for language choice and small interactions. Throughout this guide we will build a similar structure step by step with a bilingual portfolio and blog example.

Astro islands: a static HTML page and three islands that hydrate on load, idle, or visibility.
HTML arrives first; search, the theme control, and related posts hydrate on their own events while Astro content stays HTML.
src/pages/index.astro: a static first page with no client JavaScript.astro
01---02const title = 'Yemre Portfolio & Journal';03---04<html lang="tr">05  <head>06    <meta charset="utf-8" />07    <meta name="viewport" content="width=device-width" />08    <title>{title}</title>09    <meta name="description" content="Projeler, notlar ve teknik yazılar." />10  </head>11  <body>12    <main><h1>{title}</h1><p>Türkçe içerik ve teknik notlar.</p></main>13  </body>14</html>

Setup and project structure

First confirm with node -v that your Node.js version is at least 22.12.0. Then create the project with the official starter and open the development server. The starter asks a few questions and lets you pick a template; the simplest starter template is enough for this guide.

Creating a new Astro project and starting the development server.sh
01npm create astro@latest yemre-portfolio-journal02cd yemre-portfolio-journal03npm run dev

Astro's folder layout is easy to read. Every file under src/pages/ becomes an address; src/components/ holds reusable components, src/layouts/ shared page frames, src/content/ Markdown content and src/assets/ images to be processed. Files in the public/ folder are copied as they are. One important detail: posts inside src/content/ do not become pages by themselves; a page file has to list them and turn them into routes.

Astro project tree explaining the roles of src/pages, src/content, and public.
Pages define routes, content collections provide data, and public files are served unchanged.

Configuration lives in astro.config.mjs. The example below sets the site address, the sitemap integration and bilingual routing in which Turkish has no prefix and English is published under /en/. Always fill in site with your real domain: canonical, sitemap and RSS links are generated as absolute addresses from this value.

Installing the sitemap integration (it adds itself to the configuration).sh
01npx astro add sitemap
astro.config.mjs: site address, sitemap and bilingual routing.js
01import { defineConfig } from 'astro/config';02import sitemap from '@astrojs/sitemap';03 04export default defineConfig({05  site: 'https://example.com',06  integrations: [sitemap()],07  i18n: {08    locales: ['tr', 'en'],09    defaultLocale: 'tr',10    routing: { prefixDefaultLocale: false },11  },12});

.astro components, layouts and file-based routing

An .astro file has two parts. The top part between the two --- lines (the frontmatter) is JavaScript or TypeScript that runs on the server when the component renders: at build time for prerendered pages, and on every request for on-demand pages. There you fetch data, read props and prepare variables. The bottom part is an HTML-like template. This code is not sent to the browser: the component produces HTML and is done. <slot /> is where the content of the page using the component is placed.

src/components/Section.astro: a simple component with typed props and a slot.astro
01---02interface Props {03  title: string;04}05const { title } = Astro.props;06---07<section>08  <h2>{title}</h2>09  <slot />10</section>

Layouts are the HTML skeleton shared by many pages: the <head>, the top menu and shared styles. A page uses the layout like a component and places its own content inside it. Taking the language as a prop makes it easy to produce the correct lang attribute on every page.

src/layouts/SiteLayout.astro: the shared skeleton of every page.astro
01---02import Header from '../components/Header.astro';03import '../styles/global.css';04 05interface Props {06  title: string;07  lang?: 'tr' | 'en';08}09const { title, lang = 'tr' } = Astro.props;10---11<html lang={lang}>12  <head>13    <meta charset="utf-8" />14    <meta name="viewport" content="width=device-width" />15    <title>{title}</title>16  </head>17  <body>18    <Header />19    <main><slot /></main>20  </body>21</html>

Dynamic addresses are written with square brackets: src/pages/blog/[slug].astro produces a separate page for each post. In a static build Astro needs to know which addresses to generate; the getStaticPaths() function tells it. The example below takes the Turkish posts from the content collection, creates a page for each and prints the Markdown body with the <Content /> component obtained from render(). For English posts, copy the same file to src/pages/en/blog/[slug].astro and change the filter to locale === 'en'.

src/pages/blog/[slug].astro: a page generated at build time for each post.astro
01---02import { getCollection, render } from 'astro:content';03 04export async function getStaticPaths() {05  const posts = await getCollection('blog', ({ data }) => data.locale === 'tr');06  return posts.map((post) => ({07    params: { slug: post.data.slug },08    props: { post },09  }));10}11 12const { post } = Astro.props;13const { Content } = await render(post);14---15<article>16  <h1>{post.data.title}</h1>17  <p>{post.data.description}</p>18  <Content />19</article>

Content collections and Markdown

Content collections turn your Markdown files into typed, validated data. In the current API, collections are defined in src/content.config.ts; a loader such as glob() finds the files, and a Zod schema validates fields such as title, description, language and date at build time. If you mistype a post's date or forget a required field, the build fails, which keeps broken content from going live. Note that Zod is imported from astro/zod; the astro:content import from older tutorials is no longer the recommended way.

src/content.config.ts: a blog collection with a loader and a Zod schema.ts
01import { defineCollection } from 'astro:content';02import { glob } from 'astro/loaders';03import { z } from 'astro/zod';04 05const blog = defineCollection({06  loader: glob({ base: './src/content/blog', pattern: '**/*.md' }),07  schema: z.object({08    title: z.string(),09    description: z.string(),10    slug: z.string(),11    locale: z.enum(['tr', 'en']),12    pubDate: z.coerce.date(),13    draft: z.boolean().default(false),14  }),15});16 17export const collections = { blog };
src/content/blog/astro-rehberi.md: a post that matches the schema.md
01---02title: Astro ile statik site geliştirme03description: Astro bileşenleri, içerik koleksiyonları ve islands mimarisi.04slug: astro-rehberi05locale: tr06pubDate: 2026-10-0607draft: false08---09 10## İlk bölüm11 12Yazının Markdown gövdesi burada.

For the English version, add a second file in the same folder with locale: en and a unique slug. Keeping the language as a field instead of opening language folders causes fewer surprises with the simple [slug] route, because the loader includes the folder path in the entry's ID. If you need MDX, install the official MDX integration and widen the loader pattern; otherwise plain Markdown is enough.

The order of getCollection() results is not guaranteed; if you want a date-ordered list, sort it explicitly. Filtering out drafts happens in the same place.

Post list: Turkish, non-draft posts in date order.astro
01---02import { getCollection } from 'astro:content';03 04const posts = (await getCollection('blog', ({ data }) =>05  data.locale === 'tr' && !data.draft06)).sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());07---08<ul>09  {posts.map((post) => (10    <li><a href={'/blog/' + post.data.slug + '/'}>{post.data.title}</a></li>11  ))}12</ul>
Astro content collection build pipeline with an optional live-data branch.
Markdown entries are validated, page routes are generated, and static HTML is produced; live data runs separately at request time.

Build-time collections are not a good fit for data that must be fresh on every request; stock information or a frequently updated list freezes in the state it had at build time. For these cases, Live Content Collections, introduced in Astro 6, query the data at request time, but they need an adapter and on-demand rendering. For content that rarely changes, such as blog posts, build-time collections are enough: the data is read once at build time and no adapter is needed.

Islands architecture: adding interactivity selectively

Astro's most distinctive idea is the islands architecture. Most of the page stays static HTML; only the parts that need interaction, such as a search box or a theme switcher, are brought to life in the browser with a UI library. You first install the official integration (for example npx astro add react). The key point: importing a React component does not by itself run it in the browser. If the component is used without a client:* directive, it is rendered to HTML on the server and no JavaScript is sent to the browser.

src/pages/blog/index.astro: the search component hydrates when it becomes visible.astro
01---02import PostSearch from '../../components/PostSearch';03---04<h1>Yazılar</h1>05<PostSearch client:visible />
src/components/PostSearch.tsx: a small stateful React island (a starting point for search; filtering the list is the step you add).tsx
01import { useState } from 'react';02 03export default function PostSearch() {04  const [query, setQuery] = useState('');05  return (06    <label>07      Yazılarda ara08      <input09        value={query}10        onChange={(event) => setQuery(event.currentTarget.value)}11      />12    </label>13  );14}

The directive decides when the island hydrates. These are event definitions, not durations; they do not guarantee “loads in so many milliseconds”:

  • client:load: loads and hydrates JavaScript as the page loads; use it only for interaction that is needed immediately.
  • client:idle: hydrates after the initial load, once the browser is idle.
  • client:visible: hydrates when the component becomes visible on screen; ideal for parts lower on the page.
  • No directive: the component stays HTML only and is not hydrated in the browser.

If you want an app-like feel when moving between pages, you can optionally add the ClientRouter component. But this means scripts do not rerun on every page swap; tie code that must reinitialise to the astro:page-load event and always test navigation.

An optional client router in the shared layout's head.astro
01---02import { ClientRouter } from 'astro:transitions';03---04<head>05  <ClientRouter />06</head>

Static, on-demand rendering, Server Islands and Actions

In Astro the default output is static: every page is generated once at build time and everyone receives the same HTML. If some pages must be generated on every request, such as a preview page that reads a cookie, you add an adapter. The adapter lets Astro run server code on a runtime such as Node, Vercel, Netlify or Cloudflare. Then you switch only that page to on-demand rendering with prerender = false; everything else stays static.

Installing the Node adapter.sh
01npx astro add node
astro.config.mjs: static output with the Node adapter.js
01import { defineConfig } from 'astro/config';02import node from '@astrojs/node';03 04export default defineConfig({05  output: 'static',06  adapter: node({ mode: 'standalone' }),07});
src/pages/private-preview.astro: a single page generated on every request.astro
01---02export const prerender = false;03const language = Astro.cookies.get('preview-language')?.value ?? 'tr';04---05<p>Preview language: {language}</p>

If most of the project is on-demand, do the opposite: set output: 'server' and use prerender = true on pages that must stay static. The output: 'hybrid' value you may see in older tutorials is not a current option; “hybrid” is just a term for a project that mixes static and on-demand pages.

Qualitative comparison of Astro static, SSR, hybrid route mix, and Server Island rendering.
Compare when output is produced, what hosting is needed, and where each mode is useful.

Server Islands go a step further: the whole page is prebuilt, but a single component is deferred with server:defer and rendered on the server at request time. Meanwhile the placeholder inside slot="fallback" is shown. This is not the same as a React island; there is no component hydrating in the browser, just an HTML fragment that arrives later from the server. It suits a small personalised greeting or a frequently changing counter, and it requires an adapter.

src/components/VisitorNote.astro: a fragment rendered on the server at request time.astro
01---02const visitorName = Astro.cookies.get('visitor')?.value ?? 'ziyaretçi';03---04<p>Hoş geldin, {visitorName}.</p>
A deferred server island and its placeholder on a static page.astro
01---02import VisitorNote from '../components/VisitorNote.astro';03---04<VisitorNote server:defer>05  <span slot="fallback">Ziyaretçi bilgisi yükleniyor…</span>06</VisitorNote>

For server work such as form submissions, Astro Actions provide typed server functions. They validate input at runtime with Zod and return errors in a standard shape. The example below validates a contact form; in a real project it would pass the email to a trusted mail service, and you would keep that service's secret key on the server only with astro:env. Actions also need an adapter.

src/actions/index.ts: a contact action with Zod validation.ts
01import { defineAction } from 'astro:actions';02import { z } from 'astro/zod';03 04export const server = {05  contact: defineAction({06    input: z.object({07      email: z.string().email(),08      message: z.string().min(1).max(2000),09    }),10    handler: async ({ email, message }) => {11      // Send to a trusted mail service here; do not expose its secret in client code.12      return { accepted: Boolean(email && message) };13    },14  }),15};

Bilingual routing and images

The configuration in section two made Turkish the default, unprefixed language. When you put Turkish pages under src/pages/ and English pages under src/pages/en/, Astro maps them to / and /en/. You can use the astro:i18n helpers to produce links in the right language inside components. Remember to keep a separate page title, description and content record for each language; routing alone does not translate anything.

For images, the <Image /> component from the astro:assets module optimises images imported into the project at build time. Giving width and height prevents layout shift, and meaningful alt text is essential for accessibility. Use a plain <img> for files in public/ that should be served as they are; optimising images from remote servers needs extra allow-list configuration.

src/components/Portrait.astro: an optimised image with dimensions and alt text.astro
01---02import { Image } from 'astro:assets';03import portrait from '../assets/portrait.webp';04---05<Image06  src={portrait}07  alt="Yunus Emre Balçın"08  width={640}09  height={640}10/>

SEO, performance and testing

Because Astro produces static HTML, search engines read the content directly; still, setting the title, description, canonical and language tags correctly is up to you. Collecting the shared <head> in the layout is the cleanest way. You can produce the canonical address as an absolute URL with Astro.site when site is defined in the configuration. On a bilingual site, every page has to point to its counterpart in the other language with hreflang; each page should pass those addresses to the layout as props based on its own translation, and a page without a translation should produce no alternate link at all.

Shared head in the layout: title, description, canonical and hreflang.astro
01---02interface Props {03  title: string;04  description: string;05  lang?: 'tr' | 'en';06  alternates?: { tr?: string; en?: string };07}08const { title, description, lang = 'tr', alternates = {} } = Astro.props;09const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefined;10---11<html lang={lang}>12  <head>13    <meta charset="utf-8" />14    <meta name="viewport" content="width=device-width" />15    <title>{title}</title>16    <meta name="description" content={description} />17    {canonical && <link rel="canonical" href={canonical} />}18    {alternates.tr && <link rel="alternate" hreflang="tr" href={alternates.tr} />}19    {alternates.en && <link rel="alternate" hreflang="en" href={alternates.en} />}20  </head>21  <body><slot /></body>22</html>

The @astrojs/sitemap integration is enough for the sitemap. The RSS feed is written as a static endpoint: src/pages/rss.xml.ts reads the post collection and produces the feed with the official @astrojs/rss package.

Installing the RSS package.sh
01npm install @astrojs/rss
src/pages/rss.xml.ts: an RSS feed from the content collection.ts
01import rss from '@astrojs/rss';02import { getCollection } from 'astro:content';03 04export async function GET(context) {05  const posts = await getCollection('blog', ({ data }) =>06    data.locale === 'tr' && !data.draft07  );08  return rss({09    title: 'Yemre Portfolio & Journal',10    description: 'Türkçe teknik yazılar.',11    site: context.site,12    items: posts.map((post) => ({13      title: post.data.title,14      description: post.data.description,15      pubDate: post.data.pubDate,16      link: '/blog/' + post.data.slug + '/',17    })),18  });19}

On performance, keeping the advantage Astro gives you is up to you: leave non-interactive content as .astro HTML, hydrate only the UI that needs browser state, choose the right directive and give images dimensions. Don't write speed claims without measuring; measure your own production build. For testing, a small Playwright test running against the production preview is a good start.

src/test/home.spec.ts: a Playwright smoke test for the home page.ts
01import { test, expect } from '@playwright/test';02 03test('home page has a title and main heading', async ({ page }) => {04  await page.goto('http://localhost:4321/');05  await expect(page).toHaveTitle(/Yemre Portfolio/);06  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();07});

For the test to run, install Playwright and its browsers. The webServer setting in playwright.config.ts starts the production build in the preview server by itself before the tests; the test visits that server's address (http://localhost:4321 by default).

playwright.config.ts: the webServer setting that starts the preview server before the tests.ts
01import { defineConfig } from '@playwright/test';02 03export default defineConfig({04  testDir: './src/test',05  webServer: {06    command: 'npm run preview',07    url: 'http://localhost:4321/',08    reuseExistingServer: !process.env.CI,09  },10  use: { baseURL: 'http://localhost:4321/' },11});
Installing Playwright, building for production and running the tests.sh
01npm init playwright@latest   # @playwright/test + tarayıcılar / browsers02npm run build03npx playwright test          # webServer önizlemeyi kendisi açar / starts the preview itself

Deployment and common mistakes

With the default static output, npm run build writes everything into the dist/ folder; you can upload that folder to any static host. If you use on-demand rendering, Actions or Server Islands, install the matching official adapter and follow the provider's current deployment guide; with the Node adapter you run the server entry after building.

The default static build.sh
01# Varsayılan statik çıktı: dist/ klasörünü statik barındırmaya yükleyin02# Default static output: upload the dist/ folder to a static host03npm run build
Only if you use the Node adapter: build and run the server entry.sh
01# Yalnızca Node adaptörü kuruluysa / Only when the Node adapter is installed02npm run build03node ./dist/server/entry.mjs

Before going live, check the list below; these are the most common mistakes in Astro projects:

  • Don't assume an .astro component runs in the browser; use an island or a script for interaction.
  • Don't add client:load to every component; choose the directive deliberately for each interaction.
  • Don't forget getStaticPaths() for a dynamic page in a static build; collection entries do not become routes on their own.
  • Import Zod from astro/zod rather than astro:content; don't copy old src/content/config.ts examples without checking.
  • Don't expect an API, session, Action or Server Island to run on plain static hosting without an adapter.
  • Don't use output: 'hybrid'; use static or server with per-page prerender.
  • Don't put secrets in client-visible values; PUBLIC_ variables and astro:env/client are public.
  • Don't expect absolute canonical, sitemap or RSS links without defining site.
  • After building, always check the canonical, hreflang and sitemap addresses in both languages.

Astro's strength comes from doing little by default: it produces HTML and steps back. You add interactivity, server code and live data only where you really need them. Keep that discipline as you adapt the portfolio and blog example in this guide to your own project; before adding a new island or adapter, assess what it adds to the browser, the server and the deployment process for your project.

Official sources and further reading

The version details and API behaviour in this article were checked against these primary sources on 6 October 2026:

  1. Astro: GitHub releases
  2. Astro 7.0 announcement
  3. Astro 6.0 announcement
  4. Astro docs: Install and setup
  5. Astro docs: Project structure
  6. Astro docs: Why Astro?
  7. Astro docs: Islands architecture
  8. Astro docs: Astro components
  9. Astro docs: Routing
  10. Astro docs: Content collections
  11. Astro docs: Front-end frameworks
  12. Astro docs: Template directives
  13. Astro docs: On-demand rendering
  14. Astro docs: Server Islands
  15. Astro docs: Actions
  16. Astro docs: Environment variables
  17. Astro docs: Internationalization
  18. Astro docs: Images
  19. Astro docs: View transitions
  20. Astro docs: Sitemap integration
  21. Astro docs: RSS recipe
  22. Astro docs: Testing
  23. Astro docs: Deploy
  24. Astro: Press resources

Astro moves quickly; for version-specific details, rely on the documentation for the Astro version your project uses. The Astro logo belongs to Astro and is used here only for identification.

✳

A good Astro site decides deliberately what stays HTML and what comes to life.

Explore more articles ↗