Kuasai Astro.js dari dasar. Pelajari mengapa Astro diciptakan, pahami core concepts seperti islands architecture, partial hydration, file-based routing, dan API routes. Bangun complete production-ready portfolio site yang cover semua Astro fundamentals dengan best practices.

Astro merepresentasikan revolutionary approach ke web development yang prioritize performance dan developer experience. Berbeda dengan traditional frameworks yang ship massive amounts of JavaScript ke browser, Astro build fast, content-focused websites dengan minimal JavaScript. Tapi mengapa Astro ada, dan apa yang membuatnya fundamentally different?
Dalam artikel ini, kita akan explore Astro's philosophy, understand mengapa Astro diciptakan, dive deep ke core concepts, dan build complete production-ready portfolio site yang demonstrate semua fundamental Astro patterns.
Sebelum Astro, web development faced significant challenges:
Fred K. Schott created Astro di 2021 dengan revolutionary approach:
Islands architecture adalah Astro's core concept. Interactive components adalah "islands" dalam 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>Hanya components yang need interactivity yang 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 dengan React -->
<Counter client:load initialCount={0} />
<!-- Static component - no JavaScript -->
<Footer />
</body>
</html>Astro menggunakan file-based routing similar ke Next.js. Files dalam 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 adalah .astro files yang combine HTML, CSS, dan 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, atau other frameworks untuk 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 kapan dan bagaimana components hydrated.
---
// Different hydration strategies
import Counter from '../components/Counter.jsx';
---
<!-- Load immediately -->
<Counter client:load />
<!-- Load ketika idle -->
<Counter client:idle />
<!-- Load ketika visible (intersection observer) -->
<Counter client:visible />
<!-- Load on interaction -->
<Counter client:only="react" />
<!-- Never hydrate (static only) -->
<Counter />Create API endpoints menggunakan .json.ts atau .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 untuk 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 dalam 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 dan query content dengan 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 dalam Astro components automatically scoped.
---
// src/components/Button.astro
interface Props {
variant?: 'primary' | 'secondary';
}
const { variant = 'primary' } = Astro.props;
---
<button class={variant}>
<slot />
</button>
<style>
/* Scoped ke component ini 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>Mari build complete portfolio site yang demonstrate semua 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 untuk 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 untuk 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 ketika possible
// ❌ Avoid unnecessary hydration
// ❌ Don't load heavy libraries untuk simple interactions// ✅ Use content collections untuk organized content
// ✅ Leverage TypeScript untuk type safety
// ✅ Use frontmatter untuk metadata
// ❌ Avoid mixing content dan code
// ❌ Don't hardcode content dalam components// ✅ Keep components focused dan reusable
// ✅ Use props untuk configuration
// ✅ Leverage slots untuk composition
// ❌ Avoid large monolithic components
// ❌ Don't mix concerns// ✅ Use semantic HTML
// ✅ Include proper meta tags
// ✅ Generate sitemaps dan 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 dalam 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 atau use lighter alternatives
<HeavyLibrary client:visible />Astro merepresentasikan fundamental shift dalam how we think tentang web development. Dengan prioritize performance dan content, Astro enable developers untuk build fast, scalable websites dengan minimal JavaScript.
Portfolio site yang kita build demonstrate semua core Astro concepts dalam action. Understanding islands architecture, partial hydration, file-based routing, dan content collections adalah essential untuk building modern Astro applications.
Key takeaways:
Next steps:
Astro adalah future of content-focused web development. Keep learning, building, dan pushing boundaries dari apa yang possible.