Master Astro.js from the ground up. Learn why Astro was created, understand core concepts like islands architecture, partial hydration, file-based routing, and API routes. Build a complete production-ready portfolio site covering all Astro fundamentals with best practices.

Astro represents a revolutionary approach to web development that prioritizes performance and developer experience. Unlike traditional frameworks that ship JavaScript to the browser, Astro builds fast, content-focused websites with minimal JavaScript. But why does Astro exist, and what makes it fundamentally different?
In this article, we'll explore Astro's philosophy, understand why it was created, dive deep into core concepts, and build a complete production-ready portfolio site that demonstrates all fundamental Astro patterns.
Before Astro, web development faced significant challenges:
Fred K. Schott created Astro in 2021 with a revolutionary approach:
Islands architecture is Astro's core concept. Interactive components are "islands" in a sea of static HTML.
---
// src/components/Counter.astro
import { useState } from 'react';
interface Props {
initialCount?: number;
}
const { initialCount = 0 } = Astro.props;
---
<div class="counter">
<p>Count: <span id="count">{initialCount}</span></p>
<button id="increment">Increment</button>
</div>
<script>
let count = 0;
const countEl = document.getElementById('count');
const btn = document.getElementById('increment');
btn?.addEventListener('click', () => {
count++;
if (countEl) countEl.textContent = count.toString();
});
</script>
<style>
.counter {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
}
</style>Only components that need interactivity are hydrated. Static components remain static.
---
// src/pages/index.astro
import Counter from '../components/Counter.jsx';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
---
<html>
<head>
<title>My Site</title>
</head>
<body>
<!-- Static component - no JavaScript -->
<Header />
<!-- Interactive component - hydrated with React -->
<Counter client:load initialCount={0} />
<!-- Static component - no JavaScript -->
<Footer />
</body>
</html>Astro uses file-based routing similar to Next.js. Files in src/pages/ become routes.
// File structure
src/pages/
├── index.astro // /
├── about.astro // /about
├── blog/
│ ├── index.astro // /blog
│ └── [slug].astro // /blog/:slug (dynamic)
└── api/
└── posts.json.ts // /api/posts.json (API route)
// Dynamic route example
---
// src/pages/blog/[slug].astro
export async function getStaticPaths() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map(post => ({
params: { slug: post.slug },
props: { post }
}));
}
const { slug } = Astro.params;
const { post } = Astro.props;
---
<h1>{post.title}</h1>
<p>{post.content}</p>Astro components are .astro files that combine HTML, CSS, and JavaScript.
---
// src/components/Card.astro
interface Props {
title: string;
description: string;
image: string;
}
const { title, description, image } = Astro.props;
---
<div class="card">
<img src={image} alt={title} />
<h2>{title}</h2>
<p>{description}</p>
</div>
<style>
.card {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s;
}
.card:hover {
transform: translateY(-4px);
}
img {
width: 100%;
height: 200px;
object-fit: cover;
}
h2 {
margin: 16px;
font-size: 1.25rem;
}
p {
margin: 0 16px 16px;
color: #666;
}
</style>Use React, Vue, Svelte, or other frameworks for interactive islands.
---
// src/pages/index.astro
import Counter from '../components/Counter.jsx'; // React
import TodoList from '../components/TodoList.vue'; // Vue
import Weather from '../components/Weather.svelte'; // Svelte
---
<html>
<body>
<!-- React island -->
<Counter client:load />
<!-- Vue island -->
<TodoList client:idle />
<!-- Svelte island -->
<Weather client:visible />
</body>
</html>Control when and how components are hydrated.
---
// Different hydration strategies
import Counter from '../components/Counter.jsx';
---
<!-- Load immediately -->
<Counter client:load />
<!-- Load when idle -->
<Counter client:idle />
<!-- Load when visible (intersection observer) -->
<Counter client:visible />
<!-- Load on interaction -->
<Counter client:only="react" />
<!-- Never hydrate (static only) -->
<Counter />Create API endpoints using .json.ts or .json.js files.
// src/pages/api/posts.json.ts
export async function GET() {
const posts = [
{ id: 1, title: 'First Post', slug: 'first-post' },
{ id: 2, title: 'Second Post', slug: 'second-post' },
];
return new Response(JSON.stringify(posts), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}
// src/pages/api/posts/[id].json.ts
export async function GET({ params }) {
const { id } = params;
const post = {
id: parseInt(id),
title: `Post ${id}`,
content: 'Post content here...',
};
return new Response(JSON.stringify(post), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}Create reusable layouts for consistent page structure.
---
// src/layouts/BaseLayout.astro
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/blog">Blog</a>
</nav>
</header>
<main>
<slot />
</main>
<footer>
<p>© 2026 My Site</p>
</footer>
</body>
</html>
<style>
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 0;
}
header {
background: #333;
color: white;
padding: 20px;
}
nav a {
color: white;
margin-right: 20px;
text-decoration: none;
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
}
footer {
background: #f5f5f5;
padding: 20px;
text-align: center;
}
</style>
// Usage in pages
---
// src/pages/about.astro
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="About Me">
<h1>About Me</h1>
<p>Welcome to my site!</p>
</BaseLayout>Organize and query content with type safety.
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blogCollection = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
author: z.string(),
image: z.string().optional(),
tags: z.array(z.string()),
}),
});
export const collections = {
blog: blogCollection,
};
// src/pages/blog/[slug].astro
---
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const blogEntries = await getCollection('blog');
return blogEntries.map(entry => ({
params: { slug: entry.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content } = await entry.render();
---
<h1>{entry.data.title}</h1>
<p>{entry.data.description}</p>
<Content />Styles in Astro components are automatically scoped.
---
// src/components/Button.astro
interface Props {
variant?: 'primary' | 'secondary';
}
const { variant = 'primary' } = Astro.props;
---
<button class={variant}>
<slot />
</button>
<style>
/* Scoped to this component only */
button {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
button.primary {
background: #667eea;
color: white;
}
button.primary:hover {
background: #5568d3;
transform: translateY(-2px);
}
button.secondary {
background: #e5e7eb;
color: #333;
}
button.secondary:hover {
background: #d1d5db;
}
</style>Let's build a complete portfolio site that demonstrates all Astro fundamentals.
portfolio-site/
├── src/
│ ├── components/
│ │ ├── Header.astro
│ │ ├── Footer.astro
│ │ ├── ProjectCard.astro
│ │ ├── ContactForm.jsx
│ │ └── ThemeToggle.jsx
│ ├── layouts/
│ │ └── BaseLayout.astro
│ ├── pages/
│ │ ├── index.astro
│ │ ├── about.astro
│ │ ├── projects.astro
│ │ ├── blog/
│ │ │ ├── index.astro
│ │ │ └── [slug].astro
│ │ └── api/
│ │ └── contact.json.ts
│ ├── content/
│ │ ├── config.ts
│ │ └── blog/
│ │ ├── first-post.md
│ │ └── second-post.md
│ └── styles/
│ └── global.css
├── astro.config.mjs
├── package.json
└── tsconfig.jsonimport { defineCollection, z } from 'astro:content';
const blogCollection = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
author: z.string(),
image: z.string(),
tags: z.array(z.string()),
}),
});
export const collections = {
blog: blogCollection,
};---
import Header from '../components/Header.astro';
import Footer from '../components/Footer.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, initial-scale=1" />
<meta name="description" content={description} />
<title>{title} | My Portfolio</title>
<link rel="stylesheet" href="/styles/global.css" />
</head>
<body>
<Header />
<main>
<slot />
</main>
<Footer />
</body>
</html>
<style is:global>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.6;
color: #333;
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
}
</style>---
import ThemeToggle from './ThemeToggle.jsx';
---
<header class="header">
<div class="container">
<div class="logo">
<a href="/">Portfolio</a>
</div>
<nav class="nav">
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/projects">Projects</a>
<a href="/blog">Blog</a>
</nav>
<ThemeToggle client:load />
</div>
</header>
<style>
.header {
background: white;
border-bottom: 1px solid #e5e7eb;
position: sticky;
top: 0;
z-index: 100;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo a {
font-size: 1.5rem;
font-weight: bold;
text-decoration: none;
color: #667eea;
}
.nav {
display: flex;
gap: 30px;
}
.nav a {
text-decoration: none;
color: #333;
transition: color 0.2s;
}
.nav a:hover {
color: #667eea;
}
</style>---
interface Props {
title: string;
description: string;
image: string;
link: string;
tags: string[];
}
const { title, description, image, link, tags } = Astro.props;
---
<div class="card">
<img src={image} alt={title} />
<div class="content">
<h3>{title}</h3>
<p>{description}</p>
<div class="tags">
{tags.map(tag => (
<span class="tag">{tag}</span>
))}
</div>
<a href={link} class="link">View Project →</a>
</div>
</div>
<style>
.card {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
img {
width: 100%;
height: 200px;
object-fit: cover;
}
.content {
padding: 20px;
}
h3 {
margin-bottom: 10px;
font-size: 1.25rem;
}
p {
color: #666;
margin-bottom: 15px;
}
.tags {
display: flex;
gap: 8px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.tag {
background: #f0f0f0;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.875rem;
color: #666;
}
.link {
color: #667eea;
text-decoration: none;
font-weight: 600;
transition: color 0.2s;
}
.link:hover {
color: #5568d3;
}
</style>import { useState } from 'react';
export default function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: '',
});
const [submitted, setSubmitted] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const response = await fetch('/api/contact.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (response.ok) {
setSubmitted(true);
setFormData({ name: '', email: '', message: '' });
setTimeout(() => setSubmitted(false), 3000);
}
} catch (error) {
console.error('Error submitting form:', error);
}
};
return (
<form onSubmit={handleSubmit} className="contact-form">
<div className="form-group">
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
rows={5}
required
/>
</div>
<button type="submit" className="btn">
Send Message
</button>
{submitted && (
<p className="success">Message sent successfully!</p>
)}
</form>
);
}
<style>
.contact-form {
max-width: 600px;
margin: 0 auto;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 600;
}
input,
textarea {
width: 100%;
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-family: inherit;
font-size: 1rem;
}
input:focus,
textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.btn {
background: #667eea;
color: white;
padding: 12px 24px;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn:hover {
background: #5568d3;
transform: translateY(-2px);
}
.success {
color: #10b981;
margin-top: 15px;
font-weight: 600;
}
</style>export async function POST({ request }) {
try {
const data = await request.json();
const { name, email, message } = data;
// Validate data
if (!name || !email || !message) {
return new Response(
JSON.stringify({ error: 'Missing required fields' }),
{ status: 400 }
);
}
// Here you would typically send an email or save to database
console.log('Contact form submission:', { name, email, message });
return new Response(
JSON.stringify({ success: true, message: 'Message received' }),
{ status: 200 }
);
} catch (error) {
return new Response(
JSON.stringify({ error: 'Failed to process request' }),
{ status: 500 }
);
}
}---
import BaseLayout from '../layouts/BaseLayout.astro';
import ProjectCard from '../components/ProjectCard.astro';
---
<BaseLayout title="Home" description="Welcome to my portfolio">
<section class="hero">
<h1>Hi, I'm a Developer</h1>
<p>Building fast, beautiful web experiences</p>
<a href="/projects" class="cta">View My Work</a>
</section>
<section class="featured-projects">
<h2>Featured Projects</h2>
<div class="projects-grid">
<ProjectCard
title="E-Commerce Platform"
description="Full-stack e-commerce solution with payment integration"
image="/projects/ecommerce.jpg"
link="/projects/ecommerce"
tags={['React', 'Node.js', 'PostgreSQL']}
/>
<ProjectCard
title="Blog Engine"
description="Fast, SEO-optimized blog platform"
image="/projects/blog.jpg"
link="/projects/blog"
tags={['Astro', 'Markdown', 'Tailwind']}
/>
<ProjectCard
title="Task Manager"
description="Collaborative task management application"
image="/projects/tasks.jpg"
link="/projects/tasks"
tags={['Vue', 'Firebase', 'Tailwind']}
/>
</div>
</section>
</BaseLayout>
<style>
.hero {
text-align: center;
padding: 60px 20px;
}
.hero h1 {
font-size: 3rem;
margin-bottom: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: 1.25rem;
color: #666;
margin-bottom: 30px;
}
.cta {
display: inline-block;
background: #667eea;
color: white;
padding: 12px 30px;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
transition: all 0.2s;
}
.cta:hover {
background: #5568d3;
transform: translateY(-2px);
}
.featured-projects {
margin-top: 60px;
}
.featured-projects h2 {
font-size: 2rem;
margin-bottom: 40px;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px;
}
</style>---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
const sortedPosts = posts.sort((a, b) =>
new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime()
);
---
<BaseLayout title="Blog" description="Read my latest articles">
<h1>Blog</h1>
<div class="posts-list">
{sortedPosts.map(post => (
<article class="post-item">
<h2>
<a href={`/blog/${post.slug}`}>{post.data.title}</a>
</h2>
<p class="meta">
{post.data.pubDate.toLocaleDateString()} by {post.data.author}
</p>
<p>{post.data.description}</p>
<div class="tags">
{post.data.tags.map(tag => (
<span class="tag">{tag}</span>
))}
</div>
</article>
))}
</div>
</BaseLayout>
<style>
h1 {
font-size: 2.5rem;
margin-bottom: 40px;
}
.posts-list {
display: flex;
flex-direction: column;
gap: 30px;
}
.post-item {
padding: 20px;
border: 1px solid #e5e7eb;
border-radius: 8px;
transition: all 0.2s;
}
.post-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.post-item h2 {
margin-bottom: 10px;
}
.post-item a {
color: #667eea;
text-decoration: none;
}
.post-item a:hover {
text-decoration: underline;
}
.meta {
color: #999;
font-size: 0.875rem;
margin-bottom: 10px;
}
.tags {
display: flex;
gap: 8px;
margin-top: 15px;
flex-wrap: wrap;
}
.tag {
background: #f0f0f0;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.875rem;
}
</style>// ✅ Use islands for interactive components only
<Counter client:load />
// ✅ Use appropriate client directives
<Sidebar client:idle />
<Modal client:visible />
// ❌ Avoid hydrating entire pages
// ❌ Don't use client:load for everything// ✅ Lazy load images
<img src={image} alt="description" loading="lazy" />
// ✅ Use appropriate image formats
<picture>
<source srcset={webp} type="image/webp" />
<img src={fallback} alt="description" />
</picture>
// ✅ Minimize JavaScript
// Keep components static when possible
// ❌ Avoid unnecessary hydration
// ❌ Don't load heavy libraries for simple interactions// ✅ Use content collections for organized content
// ✅ Leverage TypeScript for type safety
// ✅ Use frontmatter for metadata
// ❌ Avoid mixing content and code
// ❌ Don't hardcode content in components// ✅ Keep components focused and reusable
// ✅ Use props for configuration
// ✅ Leverage slots for composition
// ❌ Avoid large monolithic components
// ❌ Don't mix concerns// ✅ Use semantic HTML
// ✅ Include proper meta tags
// ✅ Generate sitemaps and RSS feeds
// ❌ Avoid keyword stuffing
// ❌ Don't ignore accessibility// ❌ Wrong - hydrating static content
<Header client:load />
// ✅ Correct - only hydrate interactive parts
<Header />
<InteractiveWidget client:load />// ❌ Wrong - component won't be interactive
import Counter from './Counter.jsx';
<Counter />
// ✅ Correct - specify hydration strategy
<Counter client:load />// ❌ Wrong - hardcoded content
const posts = [
{ title: 'Post 1', content: '...' },
{ title: 'Post 2', content: '...' },
];
// ✅ Correct - use content collections
const posts = await getCollection('blog');// ❌ Wrong - repeating header/footer in every page
<header>...</header>
<main>...</main>
<footer>...</footer>
// ✅ Correct - use layouts
<BaseLayout>
<main>...</main>
</BaseLayout>// ❌ Wrong - loading heavy libraries unnecessarily
import HeavyLibrary from 'heavy-lib';
<HeavyLibrary />
// ✅ Correct - lazy load or use lighter alternatives
<HeavyLibrary client:visible />Astro represents a fundamental shift in how we think about web development. By prioritizing performance and content, Astro enables developers to build fast, scalable websites with minimal JavaScript.
The portfolio site we built demonstrates all core Astro concepts in action. Understanding islands architecture, partial hydration, file-based routing, and content collections is essential for building modern Astro applications.
Key takeaways:
Next steps:
Astro is the future of content-focused web development. Keep learning, building, and pushing the boundaries of what's possible.