Skip to content

Astro + Atomic Design

Write performant, maintainable Astro sites that leverage the framework’s strengths: content-focused, islands architecture, and excellent developer experience.

Organize projects with clear separation of concerns:

src/
├── components/
│ ├── ui/ # Reusable UI components
│ ├── layout/ # Layout components
│ └── islands/ # Interactive islands
├── content/
│ ├── blog/ # Blog posts
│ └── config.ts # Content collections
├── layouts/
│ └── Layout.astro # Base layout
├── pages/
│ ├── index.astro
│ └── blog/
│ └── [...slug].astro
├── styles/
│ └── global.css
└── env.d.ts

Note: For component organization using Atomic Design methodology, see the ATOMIC DESIGN METHODOLOGY section.

Use consistent naming conventions:

✅ BlogPost.astro # Component
✅ BaseLayout.astro # Layout
✅ [slug].astro # Dynamic route
✅ [...path].astro # Rest parameter
✅ api/posts.json.ts # Endpoint
❌ blogPost.astro
❌ blog_post.astro

Structure components with clear sections:

---
// 1. Imports
import BaseLayout from '@/layouts/BaseLayout.astro';
import Card from '@/components/ui/Card.astro';
// 2. Props interface
interface Props {
title: string;
description?: string;
}
// 3. Props destructuring with defaults
const { title, description = 'Default description' } = Astro.props;
// 4. Data fetching
const posts = await getPosts();
// 5. Logic
const formattedTitle = title.toUpperCase();
---
<!-- 6. Template -->
<BaseLayout title={formattedTitle}>
<h1>{title}</h1>
{description && <p>{description}</p>}
{posts.map(post => (
<Card title={post.title} />
))}
</BaseLayout>
<!-- 7. Scoped styles -->
<style>
h1 {
color: var(--accent);
}
</style>
<!-- 8. Scripts -->
<script>
console.log('Page loaded');
</script>

Always type component props:

---
interface Props {
title: string;
tags?: string[];
publishedAt: Date;
featured?: boolean;
}
const {
title,
tags = [],
publishedAt,
featured = false
} = Astro.props;
---
<article class:list={{ featured }}>
<h2>{title}</h2>
<time datetime={publishedAt.toISOString()}>
{publishedAt.toLocaleDateString()}
</time>
{tags.length > 0 && (
<ul>
{tags.map(tag => <li>{tag}</li>)}
</ul>
)}
</article>

Use clear conditional patterns:

---
const { user, isLoggedIn } = Astro.props;
---
<!-- Short-circuit for simple cases -->
{isLoggedIn && <p>Welcome back!</p>}
<!-- Ternary for either/or -->
{isLoggedIn ? (
<Dashboard user={user} />
) : (
<LoginForm />
)}
<!-- Multiple conditions -->
{status === 'loading' && <Spinner />}
{status === 'error' && <Error message={error} />}
{status === 'success' && <Content data={data} />}

Define strict schemas for content:

src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
publishedAt: z.date(),
updatedAt: z.date().optional(),
tags: z.array(z.string()).default([]),
featured: z.boolean().default(false),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };

Use type-safe content queries:

---
import { getCollection } from 'astro:content';
// Get all published posts
const posts = await getCollection('blog', ({ data }) => {
return !data.draft;
});
// Sort by date
const sortedPosts = posts.sort((a, b) =>
b.data.publishedAt.getTime() - a.data.publishedAt.getTime()
);
// Get single entry
const post = await getEntry('blog', 'my-post-slug');
---
{sortedPosts.map(post => (
<article>
<h2>{post.data.title}</h2>
<a href={`/blog/${post.slug}`}>Read more</a>
</article>
))}

Create type-safe dynamic routes:

src/pages/blog/[...slug].astro
---
import { getCollection, type CollectionEntry } from 'astro:content';
import BlogLayout from '@/layouts/BlogLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
interface Props {
post: CollectionEntry<'blog'>;
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<BlogLayout title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
</BlogLayout>

Prefer scoped styles for components:

---
const { variant = 'primary' } = Astro.props;
---
<button class={`btn btn-${variant}`}>
<slot />
</button>
<style>
.btn {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
border: none;
cursor: pointer;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-secondary {
background: var(--color-secondary);
color: white;
}
</style>

Use global styles for design tokens:

src/styles/global.css
:root {
/* Colors */
--color-primary: hsl(220 90% 56%);
--color-secondary: hsl(280 70% 60%);
--color-background: hsl(0 0% 100%);
--color-text: hsl(0 0% 10%);
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 2rem;
/* Typography */
--font-sans: system-ui, sans-serif;
--font-mono: 'Fira Code', monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
color: var(--color-text);
background: var(--color-background);
}

Integrate CSS frameworks properly:

---
// Using Tailwind CSS
import '@/styles/global.css';
---
<div class="container mx-auto px-4">
<h1 class="text-4xl font-bold">Hello</h1>
</div>

Use appropriate client directives:

---
import Counter from '@/components/islands/Counter.svelte';
import Search from '@/components/islands/Search.tsx';
import Analytics from '@/components/islands/Analytics.vue';
---
<!-- Load immediately -->
<Counter client:load />
<!-- Load when idle -->
<Search client:idle />
<!-- Load when visible -->
<Analytics client:visible />
<!-- Load on media query -->
<MobileMenu client:media="(max-width: 768px)" />
<!-- Only render on client -->
<ClientOnly client:only="react" />

Pass serializable data to islands:

---
const initialCount = 0;
const config = {
theme: 'dark',
locale: 'en-US'
};
---
<!-- Good - serializable data -->
<Counter
client:load
initialCount={initialCount}
config={config}
/>
<!-- Bad - functions/symbols not serializable -->
<Counter
client:load
onClick={() => console.log('click')}
/>

Fetch data in component frontmatter:

---
// Good - runs at build time
const response = await fetch('https://api.example.com/posts');
const posts = await response.json();
// Good - with error handling
let data = null;
let error = null;
try {
const res = await fetch('https://api.example.com/data');
data = await res.json();
} catch (e) {
error = e.message;
}
---
{error ? (
<p>Error: {error}</p>
) : (
<ul>
{data.map(item => <li>{item.name}</li>)}
</ul>
)}

Use environment variables securely:

---
// Good - server-only secrets
const API_KEY = import.meta.env.SECRET_API_KEY;
// Good - public variables
const PUBLIC_URL = import.meta.env.PUBLIC_API_URL;
---
<div data-api-url={PUBLIC_URL}>
<!-- Never expose SECRET_API_KEY to client -->
</div>

Create type-safe API endpoints:

src/pages/api/posts.json.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ params, request }) => {
try {
const posts = await getPosts();
return new Response(JSON.stringify(posts), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch posts' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
};

Handle form submissions with Actions:

src/actions/newsletter.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
newsletter: defineAction({
input: z.object({
email: z.string().email(),
}),
handler: async (input) => {
// Subscribe to newsletter
await subscribeToNewsletter(input.email);
return { success: true };
},
}),
};
---
import { actions } from 'astro:actions';
---
<form method="POST" action={actions.newsletter}>
<input type="email" name="email" required />
<button type="submit">Subscribe</button>
</form>

Add smooth page transitions:

src/layouts/BaseLayout.astro
---
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>

Control transition behavior:

---
import { fade, slide } from 'astro:transitions';
---
<!-- Persist across navigation -->
<header transition:persist>
<nav><!-- Navigation --></nav>
</header>
<!-- Named transition -->
<img
src={hero}
alt="Hero"
transition:name="hero"
transition:animate={fade({ duration: '0.3s' })}
/>
<!-- Animate on entry -->
<article transition:animate={slide({ duration: '0.5s' })}>
<h1>{title}</h1>
</article>

Use Astro’s Image component:

---
import { Image } from 'astro:assets';
import heroImage from '@/assets/hero.jpg';
---
<!-- Optimized with automatic formats -->
<Image
src={heroImage}
alt="Hero image"
width={800}
height={600}
loading="lazy"
/>
<!-- Remote images -->
<Image
src="https://example.com/image.jpg"
alt="Remote"
width={400}
height={300}
inferSize
/>

Prefetch pages for faster navigation:

---
import { prefetch } from 'astro:prefetch';
---
<!-- Prefetch on hover -->
<a href="/about" data-astro-prefetch>About</a>
<!-- Prefetch on visibility -->
<a href="/blog" data-astro-prefetch="viewport">Blog</a>
<!-- Programmatic prefetch -->
<script>
import { prefetch } from 'astro:prefetch';
document.addEventListener('DOMContentLoaded', () => {
prefetch('/important-page');
});
</script>

✅ Use content collections for content management ✅ Type all component props with TypeScript ✅ Leverage server-side rendering by default ✅ Use islands for interactive components ✅ Optimize images with Image component ✅ Use View Transitions for smooth navigation ✅ Keep client-side JavaScript minimal ✅ Use environment variables properly ✅ Implement proper error handling ✅ Follow semantic HTML practices

❌ Hydrate entire page with client:load ❌ Fetch data client-side when avoidable ❌ Expose secrets to client ❌ Use unoptimized images ❌ Skip TypeScript types ❌ Create deeply nested component trees ❌ Mix server and client state ❌ Ignore accessibility ❌ Use inline styles excessively

tsconfig.json
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/layouts/*": ["src/layouts/*"]
}
}
}
astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://example.com',
integrations: [
tailwind(),
sitemap(),
],
output: 'static', // or 'server' or 'hybrid'
vite: {
optimizeDeps: {
exclude: ['@node-rs/argon2'],
},
},
});

Test components with Vitest:

import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { expect, test } from 'vitest';
import Card from './Card.astro';
test('Card renders with title', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Card, {
props: { title: 'Test Card' },
});
expect(result).toContain('Test Card');
});