Overview
Astro’s default output is HTML and CSS with zero JavaScript. That is the performance advantage over React-first frameworks: the baseline is already fast. The risk is shipping that advantage away by adding client:load to components that do not need it. This page covers how to measure what you have shipped, how CSS scoping works, and the font and image strategies that keep Core Web Vitals green. See core-web-vitals for the field-data tracking side.
Ship no JS unless a component needs interactivity
Every component without a client:* directive renders to static HTML. Never add client:load to a component just because it is written in React. A React component with no event handlers, no state, and no browser APIs renders identically as a static .astro component with zero framework overhead.
The test: if you can rewrite the component as a .astro file without losing anything, do it. The .astro file ships no runtime. The React file ships 45 KB of React runtime to every page that uses it.
Audit bundles with astro build --inspect
astro build writes the built output to dist/. Inspect the dist/_astro/ directory to see what JavaScript was generated and which pages include it.
npx astro build
ls -lh dist/_astro/Each file in dist/_astro/ is a module chunk. File names include a hash; look for chunks named after your components. A chunk larger than 20 KB uncompressed is worth investigating. Use rollup-plugin-visualizer or vite-bundle-visualizer to see a treemap of module sizes.
Check the HTML output for each page type:
grep -r '<script' dist/ | grep -v 'type="application/ld+json"'Pages with no interactive islands should have no <script> tags in the output.
Understand Astro’s scoped CSS
CSS written in the <style> block of an .astro component is scoped to that component by default. Astro adds a unique data attribute (data-astro-cid-xxxx) to the component’s HTML elements and the same attribute to the CSS selectors. No class naming convention is needed.
<style>
h2 { color: navy; } /* becomes h2[data-astro-cid-xxxx] */
</style>Scoped styles produce small per-component CSS files that are only loaded on pages that use the component. For global styles, use <style is:global> or import a global CSS file in your base layout. Do not use :global() wrappers inside scoped blocks unless you specifically need to target child components.
Load fonts with font-display: swap and preconnect
Self-host fonts in public/fonts/ and declare @font-face in a global CSS file. Set font-display: swap to paint fallback text immediately.
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var.woff2") format("woff2-variations");
font-display: swap;
font-weight: 100 900;
}Preconnect to the font origin in BaseLayout.astro:
<link rel="preconnect" href="https://fonts.example.com" crossorigin />For self-hosted fonts, the preconnect is to your own domain, which the browser handles implicitly. The main cost to avoid is a Google Fonts <link> tag that adds a DNS lookup, TLS handshake, and redirect before the font file loads.
Preload the LCP image from the base layout
The LCP image on a content page is often a hero photograph. Preloading it prevents the browser from discovering it late through the HTML parser.
<link rel="preload" as="image" href={heroImageUrl} fetchpriority="high" />Pass the image URL as a prop to BaseLayout.astro and write the <link rel="preload"> only when a hero URL is present. See astro-image-optimization for the <Image /> component rules.
Avoid React on pages that are entirely static content
Reach for this question before adding any framework component: does this page have user interaction, client state, or browser APIs? If the answer is no, every element on the page should be an .astro component. A blog post, a product detail page, and a marketing landing page almost never need React.
For the one or two interactive elements a content page does need (a search box, a theme toggle, a video player), add a single island with the appropriate client:* directive. See astro-islands for directive selection rules.