Getting started
Installation
# Create new project
npm create astro@latest
# Choose template
npm create astro@latest -- --template portfolio
# Manual installation
npm install astro
Quick Start
# Start dev server
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
# Check for errors
npm run astro check
Project Structure
src/
pages/ # File-based routing
index.astro
about.astro
layouts/ # Reusable layouts
components/ # Astro/framework components
content/ # Content collections
public/ # Static assets
astro.config.mjs # Configuration
Astro Components
Component Structure
---
// Component Script (frontmatter)
// Runs at BUILD TIME, not runtime
const greeting = "Hello";
const items = await fetchData();
---
<!-- Component Template -->
<div class="container">
<h1>{greeting} World</h1>
<ul>
{items.map(item => <li>{item}</li>)}
</ul>
</div>
<style>
/* Scoped styles by default */
.container {
padding: 2rem;
}
</style>
<script>
// Runs in BROWSER
console.log('Client-side code');
</script>
Component Props
---
interface Props {
title: string;
description?: string;
items: string[];
}
const { title, description, items } = Astro.props;
---
<article>
<h2>{title}</h2>
{description && <p>{description}</p>}
<ul>
{items.map(item => <li>{item}</li>)}
</ul>
</article>
Dynamic Expressions
---
const name = "Astro";
const show = true;
const items = [1, 2, 3];
---
<!-- Variables -->
<h1>{name}</h1>
<!-- Conditional rendering -->
{show && <p>Visible</p>}
{show ? <p>Yes</p> : <p>No</p>}
<!-- Lists -->
<ul>
{items.map(n => <li>{n}</li>)}
</ul>
<!-- Dynamic attributes -->
<div class:list={['base', { active: show }]}>
Content
</div>
<!-- Set directives -->
<div set:html={htmlString} />
<div set:text={textString} />
Layouts
Basic Layout
---
// src/layouts/BaseLayout.astro
interface Props {
title: string;
description?: string;
}
const { title, description } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width">
<title>{title}</title>
{description && <meta name="description" content={description}>}
</head>
<body>
<header>
<nav><!-- Navigation --></nav>
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer">
<!-- Default footer content -->
</slot>
</footer>
</body>
</html>
Using Layouts
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Home Page" description="Welcome">
<h1>Main Content</h1>
<p>This goes in the default slot</p>
<div slot="footer">
<p>Custom footer content</p>
</div>
</BaseLayout>
Nested Layouts
---
// src/layouts/BlogLayout.astro
import BaseLayout from './BaseLayout.astro';
interface Props {
title: string;
author: string;
pubDate: Date;
}
const { title, author, pubDate } = Astro.props;
---
<BaseLayout title={title}>
<article>
<header>
<h1>{title}</h1>
<p>By {author} on {pubDate.toLocaleDateString()}</p>
</header>
<slot />
</article>
</BaseLayout>
Pages & Routing
File-Based Routing
src/pages/
index.astro → /
about.astro → /about
blog/
index.astro → /blog
[slug].astro → /blog/:slug
posts/
[page].astro → /posts/1, /posts/2, ...
[...path].astro → /* (catch-all)
api/
posts.json.ts → /api/posts.json
Dynamic Routes
---
// src/pages/blog/[slug].astro
export async function getStaticPaths() {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json());
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
---
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
Pagination
---
// src/pages/posts/[page].astro
export async function getStaticPaths({ paginate }) {
const posts = await fetchAllPosts();
return paginate(posts, { pageSize: 10 });
}
const { page } = Astro.props;
---
<div>
{page.data.map(post => (
<article>{post.title}</article>
))}
<nav>
{page.url.prev && <a href={page.url.prev}>Previous</a>}
<span>Page {page.currentPage} of {page.lastPage}</span>
{page.url.next && <a href={page.url.next}>Next</a>}
</nav>
</div>
Rest Parameters
---
// src/pages/[...path].astro
// Matches /any/path/here
const { path } = Astro.params;
// path = "any/path/here"
---
<h1>Path: {path}</h1>
Islands Architecture
Client Directives
---
import Counter from '../components/Counter.jsx';
import Carousel from '../components/Carousel.vue';
import Search from '../components/Search.svelte';
---
<!-- Load immediately on page load -->
<Counter client:load />
<!-- Load when browser is idle -->
<Carousel client:idle />
<!-- Load when visible in viewport -->
<Search client:visible />
<!-- Load when media query matches -->
<Sidebar client:media="(max-width: 768px)" />
<!-- Skip SSR, render only on client -->
<Chart client:only="react" />
Directive Comparison
| Directive | When | Use Case | Priority |
|---|---|---|---|
client:load |
Page load | Critical UI | High JS |
client:idle |
After load | Non-critical | Medium JS |
client:visible |
In viewport | Below fold | Low JS |
client:media |
Media query | Responsive | Conditional |
client:only |
Client-side | No SSR | Client-only |
Passing Props to Islands
---
import InteractiveWidget from '../components/Widget.tsx';
const data = await fetchData();
const config = { theme: 'dark', lang: 'en' };
---
<InteractiveWidget
client:load
data={data}
config={config}
onEvent={() => console.log('clicked')}
/>
Zero JS by Default
---
// This ships ZERO JavaScript to browser
const data = await fetchData();
---
<div>
<h1>Static Content</h1>
{data.map(item => <p>{item}</p>)}
</div>
UI Framework Integration
Setup Integrations
# Add React
npx astro add react
# Add Vue
npx astro add vue
# Add Svelte
npx astro add svelte
# Add Solid
npx astro add solid
# Add Preact
npx astro add preact
# Add Lit
npx astro add lit
Configuration
// astro.config.mjs
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import vue from "@astrojs/vue";
export default defineConfig({
integrations: [react(), vue()],
});
Using Framework Components
---
// Mix frameworks in one page
import ReactCounter from '../components/Counter.tsx';
import VueCarousel from '../components/Carousel.vue';
import SvelteSearch from '../components/Search.svelte';
---
<div>
<!-- Static Astro component (no JS) -->
<header>Static Header</header>
<!-- Interactive React component -->
<ReactCounter client:load initial={0} />
<!-- Interactive Vue component -->
<VueCarousel client:visible items={items} />
<!-- Interactive Svelte component -->
<SvelteSearch client:idle />
</div>
React Example
// src/components/Counter.tsx
import { useState } from "react";
interface Props {
initial?: number;
}
export default function Counter({ initial = 0 }: Props) {
const [count, setCount] = useState(initial);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Content Collections
Define Collection Schema
// src/content/config.ts
import { defineCollection, z } from "astro:content";
const blogCollection = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
author: z.string(),
tags: z.array(z.string()),
draft: z.boolean().default(false),
image: z.string().optional(),
}),
});
export const collections = {
blog: blogCollection,
};
Content Structure
src/content/
blog/
post-1.md
post-2.mdx
post-3/
index.md
cover.jpg
config.ts
Query Collections
---
import { getCollection, getEntry } from 'astro:content';
// Get all entries
const allPosts = await getCollection('blog');
// Filter entries
const publishedPosts = await getCollection('blog', ({ data }) => {
return data.draft !== true;
});
// Sort entries
const sortedPosts = allPosts.sort((a, b) =>
b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
// Get single entry
const post = await getEntry('blog', 'post-1');
---
<ul>
{publishedPosts.map(post => (
<li>
<a href={`/blog/${post.slug}`}>
{post.data.title}
</a>
</li>
))}
</ul>
Render Content
---
import { getEntry } from 'astro:content';
const post = await getEntry('blog', Astro.params.slug);
const { Content } = await post.render();
---
<article>
<header>
<h1>{post.data.title}</h1>
<p>{post.data.description}</p>
<time>{post.data.pubDate.toLocaleDateString()}</time>
</header>
<Content />
</article>
Data Collections
// src/content/config.ts
const authorsCollection = defineCollection({
type: "data", // JSON/YAML files
schema: z.object({
name: z.string(),
bio: z.string(),
avatar: z.string().url(),
social: z.object({
twitter: z.string().optional(),
github: z.string().optional(),
}),
}),
});
export const collections = {
blog: blogCollection,
authors: authorsCollection,
};
Styling
Scoped Styles
---
const title = "Styled Component";
---
<div class="container">
<h1>{title}</h1>
</div>
<style>
/* Automatically scoped to this component */
.container {
padding: 2rem;
background: #f0f0f0;
}
h1 {
color: #333;
font-size: 2rem;
}
</style>
Global Styles
<style is:global>
/* Global styles */
body {
font-family: system-ui;
margin: 0;
}
.global-class {
color: red;
}
</style>
Tailwind CSS
# Install Tailwind
npx astro add tailwind
---
// Tailwind works automatically
---
<div class="container mx-auto px-4">
<h1 class="text-4xl font-bold text-gray-900">
Title
</h1>
<p class="mt-4 text-gray-600">
Description
</p>
</div>
CSS Modules
---
import styles from './Component.module.css';
---
<div class={styles.container}>
<h1 class={styles.title}>Title</h1>
</div>
Sass/SCSS
# Install Sass
npm install sass
<style lang="scss">
$primary-color: #3273dc;
.container {
background: $primary-color;
h1 {
color: white;
}
}
</style>
CSS Variables
<style>
:root {
--color-primary: #3b82f6;
--color-secondary: #8b5cf6;
--spacing: 1rem;
}
.button {
background: var(--color-primary);
padding: var(--spacing);
}
</style>
Build & Deploy
Build Configuration
// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://example.com",
base: "/my-app",
output: "static", // or 'server', 'hybrid'
build: {
assets: "_assets",
inlineStylesheets: "auto",
},
});
Output Modes
| Mode | Description | Use Case |
|---|---|---|
static |
Pre-rendered at build | Default, best performance |
server |
On-demand rendering | Dynamic content, SSR |
hybrid |
Mix static + SSR | Selective rendering |
SSR Adapters
# Node.js
npx astro add node
# Vercel
npx astro add vercel
# Netlify
npx astro add netlify
# Cloudflare
npx astro add cloudflare
# Deno
npx astro add deno
SSR Configuration
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({
mode: "standalone",
}),
});
Hybrid Rendering
// astro.config.mjs
export default defineConfig({
output: "hybrid", // SSG by default
});
---
// Opt-in to SSR for this page
export const prerender = false;
---
<div>This page is server-rendered</div>
---
// Force static in server mode
export const prerender = true;
---
<div>This page is pre-rendered</div>
Environment Variables
# .env
PUBLIC_API_URL=https://api.example.com
SECRET_KEY=secret123
---
// Access public vars (client + server)
const apiUrl = import.meta.env.PUBLIC_API_URL;
// Access private vars (server-side only)
const secret = import.meta.env.SECRET_KEY;
---
<div data-api={apiUrl}></div>
<script>
// Only PUBLIC_ vars available here
const url = import.meta.env.PUBLIC_API_URL;
</script>
Assets & Images
Image Component
---
import { Image } from 'astro:assets';
import myImage from '../assets/photo.png';
---
<!-- Optimized local image -->
<Image
src={myImage}
alt="Description"
width={800}
height={600}
/>
<!-- Remote image -->
<Image
src="https://example.com/image.jpg"
alt="Remote image"
width={800}
height={600}
format="webp"
/>
<!-- With loading priority -->
<Image
src={myImage}
alt="Hero"
loading="eager"
quality={90}
/>
Picture Component
---
import { Picture } from 'astro:assets';
import myImage from '../assets/photo.png';
---
<Picture
src={myImage}
formats={['avif', 'webp']}
alt="Responsive image"
widths={[400, 800, 1200]}
sizes="(max-width: 768px) 100vw, 800px"
/>
getImage API
---
import { getImage } from 'astro:assets';
import myImage from '../assets/photo.png';
const optimizedImage = await getImage({
src: myImage,
width: 800,
format: 'webp',
quality: 80,
});
---
<img src={optimizedImage.src} alt="Optimized" />
Static Assets
---
// Files in public/ are served as-is
---
<!-- Favicon -->
<link rel="icon" href="/favicon.ico" />
<!-- Logo -->
<img src="/logo.svg" alt="Logo" />
<!-- Font -->
<link href="/fonts/custom.woff2" rel="preload" as="font" />
API Routes
JSON Endpoint
// src/pages/api/posts.json.ts
import type { APIRoute } from "astro";
export const GET: APIRoute = async ({ params, request }) => {
const posts = await fetchPosts();
return new Response(JSON.stringify(posts), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
};
Dynamic Routes
// src/pages/api/posts/[id].json.ts
import type { APIRoute } from "astro";
export const GET: APIRoute = async ({ params }) => {
const { id } = params;
const post = await getPost(id);
if (!post) {
return new Response(null, {
status: 404,
statusText: "Not found",
});
}
return new Response(JSON.stringify(post));
};
POST Requests
// src/pages/api/subscribe.json.ts
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ request }) => {
const data = await request.json();
// or: const data = await request.formData();
const email = data.email;
// Process subscription
await subscribe(email);
return new Response(
JSON.stringify({
message: "Subscribed successfully",
}),
{
status: 201,
},
);
};
Static Paths for APIs
// src/pages/api/users/[id].json.ts
export async function getStaticPaths() {
const users = await fetchUsers();
return users.map((user) => ({
params: { id: user.id.toString() },
props: { user },
}));
}
export const GET: APIRoute = ({ props }) => {
return new Response(JSON.stringify(props.user));
};
Advanced Features
Middleware
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
export const onRequest = defineMiddleware(async (context, next) => {
// Before request
console.log("Request:", context.url.pathname);
const response = await next();
// After request
response.headers.set("X-Custom-Header", "value");
return response;
});
Middleware Sequence
// src/middleware.ts
import { sequence } from "astro:middleware";
const auth = defineMiddleware(async (context, next) => {
// Auth logic
return next();
});
const logging = defineMiddleware(async (context, next) => {
// Logging logic
return next();
});
export const onRequest = sequence(auth, logging);
View Transitions
---
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<!-- Animated page transitions -->
<a href="/about">About</a>
</body>
</html>
Custom Transitions
---
import { fade, slide } from 'astro:transitions';
---
<div transition:animate={fade({ duration: '0.5s' })}>
Fade in/out
</div>
<div transition:animate={slide({ duration: '0.3s' })}>
Slide in/out
</div>
<!-- Persist element across pages -->
<div transition:persist>
<video>...</video>
</div>
Prefetch Links
---
import { ViewTransitions } from 'astro:transitions';
---
<head>
<ViewTransitions />
</head>
<!-- Prefetch on hover -->
<a href="/about" data-astro-prefetch>
About
</a>
<!-- Prefetch immediately -->
<a href="/blog" data-astro-prefetch="load">
Blog
</a>
<!-- Prefetch when visible -->
<a href="/docs" data-astro-prefetch="visible">
Docs
</a>
Configuration
astro.config.mjs
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import tailwind from "@astrojs/tailwind";
export default defineConfig({
// Site config
site: "https://example.com",
base: "/subpath",
trailingSlash: "ignore", // 'always', 'never', 'ignore'
// Build config
output: "static", // 'server', 'hybrid'
build: {
format: "directory", // or 'file'
assets: "_assets",
},
// Dev server
server: {
port: 3000,
host: true,
},
// Integrations
integrations: [react(), tailwind()],
// Markdown
markdown: {
shikiConfig: {
theme: "dracula",
},
},
// Vite config
vite: {
plugins: [],
},
});
TypeScript Config
// tsconfig.json
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}
Path Aliases
// tsconfig.json
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"]
}
}
}
Performance Best Practices
Zero JS by Default
---
// This component ships ZERO JavaScript
const data = await fetchData();
---
<div>
<h1>Static Content</h1>
{data.map(item => <p>{item}</p>)}
</div>
This is Astro's killer feature - static by default.
Selective Hydration
---
import Heavy from '../components/Heavy.tsx';
---
<!-- Only load when visible -->
<Heavy client:visible />
<!-- Only load on mobile -->
<MobileMenu client:media="(max-width: 768px)" />
<!-- Defer until idle -->
<Analytics client:idle />
Use client:visible for below-the-fold content.
Optimize Images
---
import { Image } from 'astro:assets';
import hero from '../assets/hero.jpg';
---
<!-- Automatic optimization -->
<Image
src={hero}
alt="Hero"
loading="eager" # for above-fold
format="avif" # modern format
quality={80} # balance size/quality
/>
<!-- Lazy load below fold -->
<Image
src={feature}
alt="Feature"
loading="lazy"
/>
Content Optimization
---
// Fetch at build time, not runtime
const posts = await getCollection('blog');
// Cache expensive operations
const cached = await cache.get('key') ??
await expensiveOperation();
---
Common Patterns
SEO Component
---
// src/components/SEO.astro
interface Props {
title: string;
description: string;
image?: string;
type?: 'website' | 'article';
}
const { title, description, image, type = 'website' } = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const imageURL = image ? new URL(image, Astro.site) : undefined;
---
<head>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
<!-- Open Graph -->
<meta property="og:type" content={type} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalURL} />
{imageURL && <meta property="og:image" content={imageURL} />}
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
{imageURL && <meta name="twitter:image" content={imageURL} />}
</head>
Reading Time
---
import { getEntry } from 'astro:content';
const post = await getEntry('blog', Astro.params.slug);
const { Content } = await post.render();
// Calculate reading time
const wordCount = post.body.split(/\s+/g).length;
const readingTime = Math.ceil(wordCount / 200);
---
<article>
<header>
<h1>{post.data.title}</h1>
<p>{readingTime} min read · {wordCount} words</p>
</header>
<Content />
</article>
RSS Feed
// src/pages/rss.xml.ts
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
export async function GET(context) {
const posts = await getCollection("blog");
return rss({
title: "My Blog",
description: "A blog about web development",
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `/blog/${post.slug}/`,
})),
customData: `<language>en-us</language>`,
});
}
Sitemap
# Install sitemap integration
npx astro add sitemap
// astro.config.mjs
import sitemap from "@astrojs/sitemap";
export default defineConfig({
site: "https://example.com",
integrations: [sitemap()],
});
Gotchas & Tips
Component Script Runs at Build Time
---
// This runs during BUILD, not in browser
console.log('Build time'); // Prints during npm run build
const data = await fetch('https://api.example.com');
// Fetched at build time, not on page load
---
<script>
// This runs in BROWSER
console.log('Runtime');
</script>
The frontmatter is server-side/build-time code.
No Client State in .astro Files
---
// ❌ This won't work - no reactivity
let count = 0;
---
<!-- ❌ Won't update on click -->
<button onclick={() => count++}>Count: {count}</button>
<!-- ✅ Use framework component instead -->
<Counter client:load />
Astro components are static - use framework components for interactivity.
Client Directives Required for Interactivity
---
import Counter from '../components/Counter.jsx';
---
<!-- ❌ Won't work - no interactivity -->
<Counter />
<!-- ✅ Correct - interactive -->
<Counter client:load />
Slots vs Props
<!-- ✅ Use slots for HTML/components -->
<Card>
<h2>Title</h2>
<p>Content with <strong>markup</strong></p>
</Card>
<!-- ✅ Use props for data -->
<Card title="Title" description="Plain text" />
Import Paths Must Include Extension
---
// ✅ Correct
import Component from '../components/Component.astro';
// ❌ Wrong - missing extension
import Component from '../components/Component';
---
getStaticPaths Must Return Array
---
// ❌ Wrong - missing return
export async function getStaticPaths() {
const posts = await getCollection('blog');
posts.map(post => ({ params: { slug: post.slug } }));
}
// ✅ Correct
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post }
}));
}
---
Migration Guide
From Next.js
| Next.js | Astro |
|---|---|
pages/index.js |
src/pages/index.astro |
getStaticProps |
Frontmatter code |
getStaticPaths |
getStaticPaths() |
_app.js |
Layout components |
getServerSideProps |
export const prerender = false |
next/image |
astro:assets |
useRouter |
Astro.url |
From Gatsby
| Gatsby | Astro |
|---|---|
gatsby-node.js |
getStaticPaths() |
| GraphQL queries | getCollection() |
gatsby-config.js |
astro.config.mjs |
| Plugins | Integrations |
gatsby-image |
astro:assets |
From Jekyll
| Jekyll | Astro |
|---|---|
_posts/ |
src/content/blog/ |
| Front matter | Frontmatter with schema |
_layouts/ |
src/layouts/ |
_includes/ |
src/components/ |
| Liquid | JSX-like syntax |
Also see
- Astro Documentation - Official documentation
- Astro GitHub - Source code and issues
- Astro Discord - Community support
- Astro Themes - Starter templates
- Astro Integrations - Official and community integrations
- Islands Architecture - Concept explanation
- Astro Content Collections - Type-safe content guide
- Astro View Transitions - SPA-like navigation
- Astro Blog - Official blog with updates