02. Progetto, componenti e stili
← 01 - Fondamenta e modello mentale · Indice · successivo → 03 - Routing, pagine ed endpoint
2.1 Struttura di un progetto
public/ asset serviti così come sono (favicon, robots.txt, PDF)
src/
pages/ OBBLIGATORIA - ogni file qui dentro è una rotta
components/ convenzione
layouts/ convenzione
styles/ convenzione
content/ convenzione (i tuoi .md), ma non è più obbligatoria
content.config.ts configurazione delle content collections
astro.config.mjs
tsconfig.json
Solo src/pages/ è imposta dal framework. Tutto il resto è convenzione e puoi
riorganizzarlo.
public/ contro src/: la differenza non è cosmetica.
- File in
src/→ processati da Vite: hashati, ottimizzati, bundlati. Le immagini qui dentro passano per l’ottimizzatore. - File in
public/→ copiati intatti, referenziati per path assoluto (/foo.pdf). Nessuna ottimizzazione, nessun cache-busting.
Regola pratica: immagini e asset che vuoi ottimizzati stanno in src/; PDF,
robots.txt, favicon e file che devono avere un URL stabile stanno in public/.
Cambiato da Astro 4
src/content/non è più una cartella riservata esrc/content/config.tsnon esiste più: la configurazione delle collection sta insrc/content.config.tse i contenuti possono stare dove vuoi. Vedi 04 - Contenuti - Markdown, MDX e content collections.
2.2 Anatomia di un componente .astro
Un file .astro ha due parti: il frontmatter (tra i ---) e il template.
---
// Questo codice gira SOLO sul server, a build time o a request time.
// Non finisce mai nel bundle del browser.
import Bottone from '../components/Bottone.astro';
interface Props {
titolo: string;
saluto?: string;
}
const { titolo, saluto = 'Ciao' } = Astro.props;
const dati = await fetch('https://api.example.com/cose').then((r) => r.json());
---
<h2>{saluto}, {titolo}!</h2>
<ul>
{dati.map((d) => <li>{d.nome}</li>)}
</ul>
{dati.length > 0 && <p>Trovati {dati.length} elementi.</p>}
<Bottone />Cose da notare:
awaital livello superiore del frontmatter: è normale, non serve una funzione asincrona. Astro attende prima di renderizzare.interface Propsè la convenzione riconosciuta daastro checke dall’estensione VS Code. Non è obbligatoria, ma senza non hai autocompletamento.- Le espressioni
{}funzionano come in JSX per interpolazione,.map()e condizionali (&&, ternario).
.astro non è JSX
| Astro | JSX | |
|---|---|---|
| Attributi | HTML standard: class, for, data-value | className, htmlFor |
| Elementi radice multipli | consentiti, niente wrapper | serve un root o <> |
| Commenti | <!-- HTML --> e {/* JS */} | solo {/* */} |
I commenti HTML finiscono nel DOM, quelli JS no. Dettaglio piccolo, utile quando lasci note nel template.
Tag dinamici: il nome della variabile deve iniziare per maiuscola.
---
const Elemento = 'div';
---
<Elemento>Ciao</Elemento>Con la minuscola Astro lo tratta come tag HTML letterale <elemento>.
📖 Astro components · Astro syntax
2.3 Slot: come si compone
Lo <slot /> è il buco dove finiscono i figli del componente.
---
// src/components/Riquadro.astro
---
<div class="riquadro">
<header><slot name="titolo" /></header>
<div class="corpo">
<slot>Contenuto di default se non passi nulla</slot>
</div>
<footer><slot name="note" /></footer>
</div><Riquadro>
<h2 slot="titolo">Attenzione</h2>
<p>Questo finisce nello slot di default.</p>
<small slot="note">Nota a piè di riquadro.</small>
</Riquadro>Due cose meno ovvie ma molto utili:
Slot condizionale: renderizzi il contenitore solo se lo slot è stato riempito:
{Astro.slots.has('note') && (
<aside><slot name="note" /></aside>
)}Slot passante: un layout che inoltra uno slot a un layout interno:
<LayoutBase>
<slot name="head" slot="head" />
<slot />
</LayoutBase>Ricorsione con Astro.self: è come si costruisce un menu ad albero o una
sidebar annidata:
---
// src/components/Menu.astro
type Voce = { titolo: string; href?: string; figli?: Voce[] };
interface Props { voci: Voce[] }
const { voci } = Astro.props;
---
<ul>
{voci.map((v) => (
<li>
{v.href ? <a href={v.href}>{v.titolo}</a> : <span>{v.titolo}</span>}
{v.figli && <Astro.self voci={v.figli} />}
</li>
))}
</ul>2.4 Layout
Un layout è solo un componente .astro che contiene <html> e uno <slot />.
Non c’è magia.
---
// src/layouts/Base.astro
interface Props { titolo: string; descrizione?: string }
const { titolo, descrizione = 'Sito di esempio' } = Astro.props;
const canonical = new URL(Astro.url.pathname, Astro.site);
---
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{titolo}</title>
<meta name="description" content={descrizione} />
<link rel="canonical" href={canonical} />
</head>
<body>
<nav><a href="/">Home</a> · <a href="/guida/">Guida</a></nav>
<main><slot /></main>
<footer>© {new Date().getFullYear()}</footer>
</body>
</html>I layout si annidano liberamente: LayoutArticolo.astro importa Base.astro e
gli passa le props. È il modo normale di avere “un layout per i post” e “un layout
per le pagine” senza duplicare l’<head>.
Astro 7 è severo sull'HTML
Il compilatore è stato riscritto in Rust ed è l’unico disponibile. Non corregge più l’HTML malformato: un tag non chiuso è un errore di build, non un avvertimento. In pratica è un miglioramento, ma se migri un progetto vecchio preparati a qualche errore al primo build.
📖 Layouts
2.5 Stili
<style> dentro un componente .astro è scoped di default: Astro aggiunge un
attributo data-astro-cid-* agli elementi e lo usa come selettore. Non serve fare nulla.
<h1>Titolo</h1>
<style>
h1 { color: rebeccapurple; } /* colpisce solo l'h1 di QUESTO componente */
</style>Per uscire dallo scope:
<style is:global>
body { margin: 0; }
</style>
<style>
article :global(h2) { border-bottom: 1px solid; } /* mirato */
</style>:global() è quasi sempre la risposta giusta quando devi stilare HTML che non
scrivi tu, per esempio il markup generato dal Markdown, che non ha l’attributo
scope del componente. È l’errore numero uno di chi stila una guida: scrivi
h2 { ... } nel componente che renderizza il contenuto, non funziona nulla,
e non capisci perché. La risposta è :global(h2).
Precedenza (a parità di specificità, dal più debole al più forte):
<link> nell’head → CSS importato → stili scoped. Gli scoped vincono sempre a
parità di specificità.
class:list
Sostituisce clsx. Stringhe aggiunte, chiavi di oggetto se truthy, array appiattiti,
false/null/undefined ignorati:
<div class:list={['card', { attiva: isAttiva, disabilitata: !abilitato }, extraClassi]}>define:vars
Passa valori dal frontmatter al CSS come custom properties:
---
const accento = 'oklch(0.7 0.15 250)';
---
<style define:vars={{ accento }}>
a { color: var(--accento); }
</style>Su
<script>no
define:varssu un<script>implicais:inline: lo script non viene più bundlato né deduplicato. La documentazione stessa consiglia di non usarlo per gli script e di passare i dati con attributidata-*(vedi 05 - Interattività - island, script e view transitions). Su<style>invece è perfetto.
Passare una classe a un componente figlio
class è una parola riservata in JS, quindi va rinominata nel destructuring:
---
const { class: classe, ...resto } = Astro.props;
---
<div class:list={['base', classe]} {...resto}><slot /></div>Tailwind
Dal 2025 Tailwind 4 si installa come plugin Vite, non come integrazione Astro:
npx astro add tailwindQuesto aggiunge @tailwindcss/vite alla config e crea src/styles/global.css con
@import "tailwindcss";, che poi importi nel layout. Niente più tailwind.config.cjs:
la configurazione di Tailwind 4 è CSS-first.
@astrojs/tailwindè legacyL’integrazione
integrations: [tailwind()]che trovi in tutti i tutorial vecchi serve solo per Tailwind 3. Non usarla su progetti nuovi.
📖 Styling
2.6 Import e asset
Astro importa nativamente: .astro, .md/.mdx, .js/.ts, .json, .css,
.module.css, immagini, pacchetti npm.
---
import dati from '../data/prodotti.json';
import '../styles/globale.css';
import immagine from '../assets/foto.jpg'; // oggetto con .src, .width, .height, .format
import testoGrezzo from '../snippets/esempio.py?raw';
---
<img src={immagine.src} width={immagine.width} height={immagine.height} alt="Foto" />
<pre>{testoGrezzo}</pre>Il suffisso ?raw è oro per una guida tecnica: importi un file sorgente vero e
lo mostri, invece di copiaincollarlo dentro il Markdown e vederlo divergere dal codice reale.
Esistono anche ?url e ?inline.
Glob import: solo stringhe letterali, niente variabili:
const moduli = import.meta.glob('../snippets/*.ts', { eager: true, query: '?raw', import: 'default' });
Astro.glob()non esiste piùRimosso in Astro 6. Usa
import.meta.glob(). Per il contenuto strutturato, però, la risposta giusta sono le content collections, non il glob.
Alias in tsconfig.json, così eviti i ../../../:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@componenti/*": ["src/components/*"],
"@layout/*": ["src/layouts/*"]
}
}
}📖 Imports
2.7 Configurazione minima da conoscere
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://esempio.it', // serve a sitemap, RSS, canonical
base: '/', // se il sito sta in una sottocartella
trailingSlash: 'ignore', // 'always' | 'never' | 'ignore'
build: { format: 'directory' }, // 'directory' | 'file' | 'preserve'
integrations: [],
markdown: {},
});site è quello che dimenticano tutti: senza, Astro.site è undefined, e
sitemap, RSS e URL canonici non funzionano.
Esercizi
- Costruisci
Base.astro+Articolo.astro(che usaBase) + tre pagine che li usano. Verifica che l’<head>sia scritto in un posto solo. - Crea un componente
Callout.astrocon<slot />e una proptipo(info | attenzione | pericolo) che cambia i colori conclass:list. Ti servirà nel modulo 4 per usarlo dentro MDX. - Prendi un file
.tsreale del tuo progetto, importalo con?rawin una pagina e mostralo dentro un<pre>. Modifica il file e ricarica: il contenuto deve seguirlo. - Scrivi uno stile per gli
h2del contenuto Markdown. Falo prima senza:global()per vedere il problema con i tuoi occhi, poi correggi.