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.
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.
01npm create astro@latest yemre-portfolio-journal02cd yemre-portfolio-journal03npm run devAstro'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.
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.
01npx astro add sitemap01import { 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.
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.
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'.
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.
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 };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.
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>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.
01---02import PostSearch from '../../components/PostSearch';03---04<h1>Yazılar</h1>05<PostSearch client:visible />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.
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.
01npx astro add node01import { defineConfig } from 'astro/config';02import node from '@astrojs/node';03 04export default defineConfig({05 output: 'static',06 adapter: node({ mode: 'standalone' }),07});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.
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.
01---02const visitorName = Astro.cookies.get('visitor')?.value ?? 'ziyaretçi';03---04<p>Hoş geldin, {visitorName}.</p>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.
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.
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.
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.
01npm install @astrojs/rss01import 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.
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).
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});01npm init playwright@latest # @playwright/test + tarayıcılar / browsers02npm run build03npx playwright test # webServer önizlemeyi kendisi açar / starts the preview itselfDeployment 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.
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 build01# Yalnızca Node adaptörü kuruluysa / Only when the Node adapter is installed02npm run build03node ./dist/server/entry.mjsBefore going live, check the list below; these are the most common mistakes in Astro projects:
- Don't assume an
.astrocomponent runs in the browser; use an island or a script for interaction. - Don't add
client:loadto 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/zodrather thanastro:content; don't copy oldsrc/content/config.tsexamples 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'; usestaticorserverwith per-pageprerender. - Don't put secrets in client-visible values;
PUBLIC_variables andastro:env/clientare 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:
- Astro: GitHub releases
- Astro 7.0 announcement
- Astro 6.0 announcement
- Astro docs: Install and setup
- Astro docs: Project structure
- Astro docs: Why Astro?
- Astro docs: Islands architecture
- Astro docs: Astro components
- Astro docs: Routing
- Astro docs: Content collections
- Astro docs: Front-end frameworks
- Astro docs: Template directives
- Astro docs: On-demand rendering
- Astro docs: Server Islands
- Astro docs: Actions
- Astro docs: Environment variables
- Astro docs: Internationalization
- Astro docs: Images
- Astro docs: View transitions
- Astro docs: Sitemap integration
- Astro docs: RSS recipe
- Astro docs: Testing
- Astro docs: Deploy
- 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 ↗