DEV Community

Cover image for 22 Astro Best Practices: The Bookmark-Worthy Tips
Dmitrii
Dmitrii

Posted on • Originally published at quotyai.com

22 Astro Best Practices: The Bookmark-Worthy Tips

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" />
Enter fullscreen mode Exit fullscreen mode

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()
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// astro.config.mjs
import { defineConfig, fontProviders } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  vite: {
    plugins: [tailwindcss()],
  },
});
Enter fullscreen mode Exit fullscreen mode

⚠️ Deprecation: @astrojs/tailwind is 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 */
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

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'
  },
});
Enter fullscreen mode Exit fullscreen mode

For specific links, you can opt in without prefetchAll:

<a href="/blog/my-post" data-astro-prefetch="viewport">Read more</a>
Enter fullscreen mode Exit fullscreen mode

⚠️ 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>
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

⚠️ 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/*"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Every import becomes clean:

import Card from '@components/Card.astro';
import { formatDate } from '@lib/utils';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
---
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...
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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',
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// astro.config.mjs
import { satteri } from '@astrojs/markdown-satteri';

export default defineConfig({
  markdown: {
    processor: satteri(),
  },
});
Enter fullscreen mode Exit fullscreen mode

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],
    }),
  },
});
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
  },
});
Enter fullscreen mode Exit fullscreen mode

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');
}
---
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Organize content by locale in your collections:

src/content/blog/
  en/post-1.md
  vi/post-1.md
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// astro.config.mjs
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://yourdomain.com', // required
  integrations: [sitemap()],
});
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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'
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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}/`,
    })),
  });
}
Enter fullscreen mode Exit fullscreen mode

🌍 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
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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)