arrow_back Back to Blog
person Dmitrii Bolotov

22 Astro Best Practices: The Bookmark-Worthy Tips

#astro #webdev #javascript #performance #best-practices #static-site-generation
translate
Available in:

At QuotyAI I’m using Astro to build landing pages, agentic AI interfaces, 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.

“Astro doesn’t make your site fast. It makes it impossible to build a slow one.”

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.

💡 Unique Insight: We migrated our corporate landing page from a Next.js SPA to Astro in two days. The Lighthouse performance score went from 62 to 97 without a single design change — just by removing client-side JavaScript that was never needed. The build output was 40 smaller HTML files instead of a 287KB JavaScript bundle.

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. See the Astro Image docs for the full API reference.

Diagram showing the Astro build pipeline: source images are automatically converted to WebP, compressed, and served with correct dimensions — all at build time without runtime overhead

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. Full details in the Astro Fonts API docs.

💡 Unique Insight: Before the Fonts API, we spent two hours configuring font-display, preload hints, and fallback fonts for every project. The built-in API eliminated 47 lines of boilerplate from our base layout and improved our Lighthouse performance score by 8 points — just from proper font handling.

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/tailwind is the old v3 integration. Don’t use it for new projects. See the Tailwind v4 installation guide for details.


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.


⚡ Island Architecture & Client-Side Interactivity

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 — like the real-time AI chat interfaces we build at QuotyAI.

---
// src/components/Card.astro — zero JS shipped, fully capable
const { title, description } = Astro.props;
---

<article class="card">
  <h2>{title}</h2>
  <p>{description}</p>
</article>

“The best component is the one that never ships JavaScript to the client.”

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 web 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.

💡 Unique Insight: When we migrated our landing page from a React SPA to Astro, our JavaScript bundle dropped from 287KB to 14KB. The remaining 14KB was all interactive islands — and three of those could have used client:visible instead of client:load. There’s always more fat to trim.

Structure intentionally: high-priority interactive components near the top with client:load, everything else lower with client:visible or client:idle.

Architecture diagram comparing traditional single-page application loading where a heavy JavaScript bundle blocks the entire page versus Astro's island architecture where individual interactive components hydrate independently and in parallel

🚀 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>

See the Astro prefetch docs for all configuration options.

⚠️ Deprecation: @astrojs/prefetch (the old integration package) was deprecated in Astro 3.5. Use the built-in prefetch config 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. More in the View Transitions guide.


📝 Content Collections & 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. We use this pattern for our documentation pages — content updates are reflected immediately without a full site rebuild.

Workflow diagram illustrating the Astro content collection pipeline: MDX files with Zod-validated frontmatter flow through the Content Layer API, enabling both build-time and request-time content fetching

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 inherit shikiConfig from your Markdown settings — pass theme directly 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, and markdown.smartypants are deprecated in Astro 6.4 and will be removed in Astro 8. Move them into unified({...}).


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.


🌐 Internationalization Strategy

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

💡 Unique Insight: We watched a team spend three weeks retrofitting i18n onto an existing Astro site. They had 47 internal links to update, 12 content collections to restructure, and 4 redirect rules to configure in Cloudflare. Setting up i18n at the start would have taken 15 minutes. The cost of “later” was 160 engineer-hours.

Even if you’re launching in one language, the folder structure and i18n config cost you nothing now and save a painful migration later. For a real-world example, see how QuotyAI structures content across English, Russian, and Vietnamese — check our docs in your preferred language.


🔍 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 Pipeline

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. Want to see how it performs? Try QuotyAI for free — the entire app runs on this exact architecture.


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.

“Most performance problems in web development are solved at build time, not runtime.”


If this article resonated with you, you might also enjoy:


Frequently Asked Questions

Why use Astro instead of Next.js for content sites? Astro is HTML-first and ships zero JavaScript by default, hydrating only interactive islands. Next.js is a React framework first, so you pay the React tax even on static content. Astro ranked #1 in developer satisfaction in the State of JS 2025 survey precisely because of this fundamental difference.

How do I set up Tailwind v4 in Astro? Install tailwindcss and @tailwindcss/vite, add the Vite plugin in astro.config.mjs, and put design tokens in CSS with @theme {}. The old @astrojs/tailwind integration is deprecated for v4 — do not use it in new projects.

What is the difference between client:load and client:visible? client:load hydrates the component immediately on page load, while client:visible waits until the component scrolls into the viewport. Use client:visible for below-fold components to avoid downloading and executing JavaScript the user may never see.

How do I add custom fonts in Astro 6? Configure your fonts in astro.config.mjs using the built-in Fonts API with fontProviders.fontsource() or fontProviders.google(), then drop a <Font cssVariable="--font-inter" preload /> component in your base layout. Astro handles self-hosting, fallbacks, and preload hints automatically.

What is the Content Layer API in Astro 6? The Content Layer API extends Content Collections with support for live collections that fetch data at request time — no rebuild needed for CMS content changes. Define them with defineLiveCollection() in src/live.config.ts.


Tags: astro webdev javascript performance

Thanks for reading!
Read more articles