22 Astro Best Practices: The Bookmark-Worthy Tips
At QuotyAI I'm using Astro to build landing pages and blog posts, so I have hands-on experience how to use it properly and how to vibe-code without headache.
Astro is the best framework for content sites right now - #1 in developer satisfaction in the State of JS 2025 survey, with Cloudflare backing it since January 2026. But like any tool, it rewards people who use it the way it was designed.
This is the reference I wish I had when I started. Whether you're building your first Astro project or vibe-coding a blog at 2am, these are the habits worth forming from day one.
Heads up on versions: This article covers Astro 6.x (released March 2026) and Astro 6.4 (released May 2026). Some APIs from older tutorials are now deprecated - those are called out explicitly below. Always check the upgrade guide when moving between majors.
🖼️ Assets & Media
1. Use <Image /> instead of <img />
Astro's built-in <Image /> component does a lot of work at build time that plain <img> tags leave on the table: it converts images to WebP, generates the right width and height attributes to prevent layout shift, and compresses everything without you touching a single config file.
---
import { Image } from 'astro:assets';
import hero from '../assets/hero.png';
---
<!-- ✅ Optimized: converted to WebP, compressed, no layout shift -->
<Image src={hero} alt="Hero image" />
<!-- ❌ Skips all of that -->
<img src="/hero.png" alt="Hero image" />
For art-direction scenarios (different images at different breakpoints), reach for <Picture /> instead.
2. Use the Astro 6 Built-in Fonts API
Almost every website uses custom fonts, but getting them right is surprisingly complicated - performance tradeoffs, privacy concerns, self-hosting, fallback generation, and preload hints. Astro 6 added a built-in Fonts API that handles all of it for you.
Configure your fonts in astro.config.mjs:
// astro.config.mjs
import { defineConfig, fontProviders } from 'astro/config';
export default defineConfig({
fonts: [
{
name: 'Inter',
cssVariable: '--font-inter',
provider: fontProviders.fontsource(), // or fontProviders.google()
},
],
});
Then drop a <Font /> component in your base layout:
---
// src/layouts/Layout.astro
import { Font } from 'astro:assets';
---
<head>
<Font cssVariable="--font-inter" preload />
<style is:global>
body { font-family: var(--font-inter); }
</style>
</head>
Behind the scenes, Astro downloads and caches the font for self-hosting, generates optimized fallbacks, adds font-display: swap, and inserts the right <link rel="preload"> hints. Zero manual configuration.
Why not Google Fonts CDN? It costs you a third-party DNS lookup, a network round trip, and hands font delivery to Google. The Fonts API self-hosts everything from your own CDN automatically.
🎨 Styling
3. Use Tailwind v4 via the Vite plugin
The old @astrojs/tailwind integration is deprecated for Tailwind v4. Use @tailwindcss/vite instead - it runs Tailwind inside Vite's pipeline, which means faster HMR, smaller production CSS, and no separate PostCSS pass.
npm install tailwindcss @tailwindcss/vite
// astro.config.mjs
import { defineConfig, fontProviders } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
});
⚠️ Deprecation:
@astrojs/tailwindis the old v3 integration. Don't use it for new projects.
4. Your config lives in CSS now (Tailwind v4)
In v4, tailwind.config.js is gone. Design tokens go directly in your CSS with @theme {}:
/* src/styles/global.css */
@import "tailwindcss";
@theme {
--color-brand: oklch(0.75 0.18 175);
--font-family-sans: var(--font-inter); /* wire up your Fonts API variable */
}
Import it once in your base layout and Vite handles the rest.
⚡ Interactivity (Islands)
5. Use plain .astro components by default - not React
This is the most important mindset shift when coming from Next.js: you don't need a JS framework for most of your UI.
.astro components are server-rendered, ship zero JavaScript, and support props, slots, and scoped styles. They cover headers, navbars, cards, footers, and anything that doesn't need client-side state. Reach for React, Vue, or Svelte only when you genuinely need interactivity.
---
// src/components/Card.astro - zero JS shipped, fully capable
const { title, description } = Astro.props;
---
<article class="card">
<h2>{title}</h2>
<p>{description}</p>
</article>
If you're coming from Next.js: Astro is not a React framework with SSG bolted on. It's an HTML-first framework that lets you optionally add React for interactive components. That distinction matters a lot for performance.
6. Pick the right client:* directive
When you do need a JavaScript island, be intentional about when it hydrates:
| Directive | When it hydrates | Best for |
|---|---|---|
client:load |
Immediately on load | Above-fold interactive UI |
client:idle |
When browser is idle | Non-critical widgets |
client:visible |
When scrolled into view | Below-fold components |
client:only="react" |
Client only, no SSR | Browser-API-dependent components |
The most common mistake is reaching for client:load everywhere. If a component is below the fold, client:visible means its JavaScript won't even be requested until the user scrolls to it.
<!-- ❌ Loads and hydrates immediately, even if never seen -->
<HeavyChart client:load />
<!-- ✅ Only hydrates when scrolled into view -->
<HeavyChart client:visible />
7. Islands load in parallel - use that
Unlike traditional SPAs where a heavy component blocks the page, Astro's islands hydrate independently. A heavy chart at the bottom won't block a lightweight nav at the top.
Structure intentionally: high-priority interactive components near the top with client:load, everything else lower with client:visible or client:idle.
🚀 Navigation & Perceived Performance
8. Enable built-in prefetching
One config line makes all internal links prefetchable on hover - navigation feels instant because the page is already in memory before the user clicks.
// astro.config.mjs
export default defineConfig({
prefetch: {
prefetchAll: true,
defaultStrategy: 'hover', // also: 'tap', 'viewport'
},
});
For specific links, you can opt in without prefetchAll:
<a href="/blog/my-post" data-astro-prefetch="viewport">Read more</a>
⚠️ Deprecation:
@astrojs/prefetch(the old integration package) was deprecated in Astro 3.5. Use the built-inprefetchconfig option above.
9. Add View Transitions for SPA-feel without the SPA cost
One import in your base layout gives you smooth, animated page transitions without shipping a full client-side router:
---
// src/layouts/Layout.astro
import { ClientRouter } from 'astro:transitions';
---
<head>
<ClientRouter />
</head>
Elements with matching transition:name attributes morph between pages. It's one of Astro's most underrated features.
📝 Content & Developer Experience
10. Use Content Collections for all your Markdown
Content Collections give you type-safe frontmatter with schema validation. No more post.data.title returning undefined at runtime.
// src/content/config.ts
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod'; // ← correct import in Astro 6
const blog = defineCollection({
schema: z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
⚠️ Deprecation: Older tutorials use
import { z } from 'astro:content'. In Astro 6, Zod 4 is bundled separately - import from'astro/zod'instead.
Astro 6's Content Layer API also supports live collections that fetch at request time (no rebuild needed for CMS content changes), using defineLiveCollection() in src/live.config.ts.
11. Set up TypeScript path aliases
Stop writing ../../../components/Card.astro. Configure aliases once in tsconfig.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"],
"@lib/*": ["src/lib/*"]
}
}
}
Every import becomes clean:
import Card from '@components/Card.astro';
import { formatDate } from '@lib/utils';
12. Use MDX when your content needs components
Plain Markdown is great for text. MDX is great for text plus interactive demos, custom callouts, and embedded components.
npx astro add mdx
---
title: My Post
---
import CodeSandbox from '@components/CodeSandbox.astro';
Here's a live example:
<CodeSandbox src="https://..." />
And then the article continues in plain Markdown...
13. Use the <Code /> component for dynamic code blocks
Astro ships a built-in <Code /> component powered by Shiki - the same highlighter used for Markdown code fences, but available as a component in .astro and .mdx files. This is the right tool whenever you need to render code that's dynamic at build time: from a file, a CMS, a variable, or a prop.
---
import { Code } from 'astro:components';
const snippet = await Astro.glob('./examples/*.ts');
---
<!-- Syntax highlight any string of code -->
<Code code={`const foo = 'bar';`} lang="js" />
<!-- Dynamic code from a file or CMS -->
<Code code={snippet[0].default} lang="ts" theme="github-dark" />
<!-- Inline code rendering -->
<p>Use <Code code="npm run dev" lang="bash" inline /> to start.</p>
No extra packages, no configuration. It supports all Shiki themes, all languages, and even Shiki transformers for things like diff highlighting and line focus effects.
You can also set your global Markdown code block theme in astro.config.mjs:
export default defineConfig({
markdown: {
shikiConfig: {
themes: {
light: 'github-light',
dark: 'github-dark',
},
},
},
});
Note:
<Code />does not inheritshikiConfigfrom your Markdown settings - passthemedirectly as a prop when you need a specific look.
14. Use the modern Markdown processor (Astro 6.4)
Astro 6.4 introduced a new pluggable markdown.processor API and a Rust-based processor called Sätteri that's dramatically faster than the default unified pipeline.
If you don't use remark/rehype plugins, switch to Sätteri:
npm install @astrojs/markdown-satteri
// astro.config.mjs
import { satteri } from '@astrojs/markdown-satteri';
export default defineConfig({
markdown: {
processor: satteri(),
},
});
If you do use remark/rehype plugins, migrate to the new unified processor API:
// astro.config.mjs
import { unified } from '@astrojs/markdown-remark';
import remarkToc from 'remark-toc';
export default defineConfig({
markdown: {
processor: unified({
remarkPlugins: [remarkToc],
}),
},
});
⚠️ Deprecation: Top-level
markdown.remarkPlugins,markdown.rehypePlugins,markdown.gfm, andmarkdown.smartypantsare deprecated in Astro 6.4 and will be removed in Astro 8. Move them intounified({...}).
15. Use Astro.logger for structured troubleshooting
console.log works, but it disappears into a wall of build output with no context. Astro 6.2 introduced an experimental structured logger you can use directly in your pages and components via Astro.logger.
Enable it in astro.config.mjs:
// astro.config.mjs
import { defineConfig, logHandlers } from 'astro/config';
export default defineConfig({
experimental: {
logger: logHandlers.console(), // or .json({ pretty: true }) for structured output
},
});
Then use it anywhere in your Astro frontmatter:
---
const posts = await getCollection('blog');
Astro.logger.info(`Rendering blog index with ${posts.length} posts`);
if (posts.length === 0) {
Astro.logger.warn('No posts found - check your content directory');
}
---
Three levels: info, warn, error. Errors go to stderr, the rest to stdout. For CI pipelines and log aggregators, use logHandlers.json() to get structured output that's easy to parse:
experimental: {
logger: logHandlers.json({ pretty: true, level: 'warn' }) // only warn+error
}
You can also pass --experimentalJson to astro build on the CLI without touching your config.
🌐 i18n - Set It Up from Day One
16. Add i18n routing before you have routes to regret
Retrofitting internationalization onto an existing site means restructuring your entire src/pages/ directory and updating every internal link. Do it at the start, even if you only support one language today.
// astro.config.mjs
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'vi', 'ja'], // add more later
routing: {
prefixDefaultLocale: false, // /blog instead of /en/blog
},
},
});
Use getRelativeLocaleUrl() for all internal links so they stay locale-aware:
---
import { getRelativeLocaleUrl } from 'astro:i18n';
const { currentLocale } = Astro;
---
<a href={getRelativeLocaleUrl(currentLocale, '/blog')}>Blog</a>
Organize content by locale in your collections:
src/content/blog/
en/post-1.md
vi/post-1.md
Even if you're launching in one language, the folder structure and i18n config cost you nothing now and save a painful migration later.
🔍 SEO & Discoverability
17. Add @astrojs/sitemap
One integration, automatic sitemap generation from all your routes:
npx astro add sitemap
// astro.config.mjs
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://yourdomain.com', // required
integrations: [sitemap()],
});
Astro generates /sitemap-index.xml at build time. Submit it to Google Search Console and you're done.
18. Always set site: in your config
This single field unlocks Astro.site throughout your project, makes canonical URLs work correctly, and is required for the sitemap integration.
export default defineConfig({
site: 'https://yourdomain.com',
});
19. Commit to a trailing slash strategy
Google doesn't care if you use /blog/ or /blog, but it does care if you mix both. Pick one:
export default defineConfig({
trailingSlash: 'always', // or 'never'
});
Inconsistency creates duplicate-content issues that quietly hurt your SEO.
20. Add an RSS feed
Two files and your content is subscribable - useful for readers, aggregators, and Dev.to's feed import feature.
npm install @astrojs/rss
// src/pages/rss.xml.js
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: 'My thoughts on dev stuff',
site: context.site,
items: posts.map(post => ({
title: post.data.title,
pubDate: post.data.date,
link: `/blog/${post.slug}/`,
})),
});
}
🌍 Deployment
21. Deploy to edge CDN platforms
Astro's output is static HTML by default. That means it belongs on a CDN with global edge delivery - not a traditional server. Cloudflare Pages, Netlify, and Vercel all support Astro with zero config.
# Cloudflare Pages (first-class support since Cloudflare acquired Astro)
npx astro add cloudflare
This is where all the build-time work pays off. Your "server" is just files on a CDN, served from the closest data center to each visitor.
22. Be explicit about output: 'static'
It's the default, but stating it communicates intent:
export default defineConfig({
output: 'static', // pre-render everything at build time
});
If a teammate adds a server route by accident, it'll be immediately obvious something doesn't fit the architecture.
TL;DR
| Category | Habit |
|---|---|
| Images | Use <Image />
|
| Fonts | Built-in Fonts API (Astro 6) - handles self-hosting, fallbacks, and preload |
| Styling | Tailwind v4 via @tailwindcss/vite, @theme {} in CSS |
| Interactivity | Default to .astro, not React; match client:* to component priority |
| Performance | Enable prefetch, add View Transitions |
| Content | Content Collections + import { z } from 'astro/zod' + MDX + <Code /> + Sätteri |
| Logging |
Astro.logger for structured troubleshooting (Astro 6.2+) |
| i18n | Set it up on day one, not day 100 |
| SEO | Sitemap, site:, trailing slash consistency, RSS |
| Deployment | Edge CDN, explicit output: 'static'
|
Astro rewards developers who lean into its defaults. Ship static HTML, hydrate surgically, optimize at build time - and you'll have a fast site almost by accident.
Top comments (0)