Astro SEO: Islands, Content Collections, Server Islands, View Transitions
Canonical reference for Astro framework SEO, AEO, and AIO implementation in 2026. Astro powers approximately 0.6 percent of all websites per W3Techs March 2026 (sample 10 million top-ranked domains),…
Astro 5 with the Islands architecture, the content-first content-focused framework, server islands, view transitions, Astro DB, Astro Actions, the @astrojs integration ecosystem, and self-hosted deployment on Debian/nginx.
Canonical reference for Astro framework SEO, AEO, and AIO implementation in 2026. Astro powers approximately 0.6 percent of all websites per W3Techs March 2026 (sample 10 million top-ranked domains), which understates its strategic footprint because Astro is the fastest-growing static site generator by GitHub star velocity, the dominant content-first framework among developer audiences, and the SSG of choice for new marketing and documentation sites launched in 2025 and 2026. Among modern JavaScript framework launches in 2026, Astro accounts for approximately 22 percent per State of JS 2025 (sample 23,800 respondents), second to Next.js at 41 percent and ahead of SvelteKit at 14 percent, Nuxt at 11 percent, and Remix at 7 percent.
This framework specifies Astro-specific SEO patterns from project layout through rendering mode through content collections through schema injection through self-hosted deployment, with explicit handling of Astro 5 features, the Islands architecture, Server Islands, View Transitions, the @astrojs integration ecosystem, and migration paths to and from Astro. For the sibling SSG, see framework-hugo.md.
Cross stack implementation note: code samples below are Astro components (.astro), TypeScript for islands, YAML for frontmatter and config, and bash for deployment. For React, Vue, Svelte, Next.js, Nuxt, SvelteKit, Remix, Hugo, Jekyll, 11ty, WordPress, Shopify, and Webflow equivalents of every pattern, see framework-cross-stack-implementation.md. For schema variants per page type see framework-schema.md.
1. Document Purpose
Astro is the content-first content-focused web framework. Released as Astro 1.0 in August 2022 and reaching Astro 5.0 in September 2024, the project positions itself as the optimal architecture for marketing sites, documentation, blogs, and content-heavy applications where performance, SEO, and authoring experience must coexist. The defining design choice is the Islands architecture: the framework ships zero JavaScript by default, hydrating only the components the page explicitly opts into via client:* directives. The result is HTML output indistinguishable from pure static HTML for the majority of pages, with JavaScript interactivity reserved for the islands that actually need it.
In 2026, Astro 5 is the current major release. The release added Server Islands (per-component runtime rendering on otherwise-static pages), the stable Content Layer API (replacing the prior Content Collections type with a more general data layer), Astro DB (a libSQL-backed database via @astrojs/db), Astro Actions (typed server functions callable from client code), and View Transitions (the integration matured into core).
Strengths: near-zero JavaScript output by default (Lighthouse 95+ routinely achievable on field data), file-based routing, type-safe content collections via Zod schemas, an integration ecosystem that covers sitemap, RSS, image, MDX, Markdoc, partytown, and every major UI framework, and the ability to mix React, Vue, Svelte, Solid, and Preact components in the same project. Weaknesses: smaller core team than Next.js or Vue, ecosystem younger than Hugo or Jekyll, dynamic-feature ergonomics weaker than Next.js or Nuxt for full applications, no built-in i18n routing as polished as Next.js App Router (improving in Astro 5.x).
Recommend Astro for marketing sites, documentation, content-heavy blogs, portfolios, product marketing pages, multi-author publications with structured content needs, sites where Lighthouse performance is a contractual obligation, and teams that want React or Vue or Svelte components without committing to one. Recommend Next.js instead for full-stack applications with significant server-side logic. Recommend Nuxt for Vue stacks with useFetch/useAsyncData patterns. Recommend SvelteKit for Svelte teams where bundle size is critical. Recommend Hugo for very large content sites (over 10,000 pages) where Go single-binary deploy is wanted. Recommend 11ty for JavaScript teams that do not need a component model. Astro sits squarely between the simplicity of Hugo and the application capability of Next.js, with the Islands architecture as the unique value.
1.1 Required Tools
- Astro 5.x as the core framework
- Node.js 20 minimum, Node.js 22 LTS preferred for new builds
- pnpm 9.x or npm 10.x for package management
- TypeScript 5.x for type-safe content collections and component props
- Git for content version control and deployment
- nginx 1.24+ for self-hosted deployment on Bubbles
- PM2 5.x for Node.js process management when SSR is used
- Let's Encrypt for SSL termination
- Vite 5.x ships as the dev server and build tool
1.2 Operating Modes
Mode A, Install. New Astro site. Follow Sections 2 through 14.
Mode B, Audit. Existing Astro site. Use the Section 15 audit rubric.
Mode C, Migrate In. Next.js, 11ty, Hugo, Jekyll, or WordPress site moving to Astro. See Section 13 and framework-migration.md.
Mode D, Migrate Out. Astro site moving to Next.js, Nuxt, or other framework. See Section 13.
Mode E, Static-Only. Pure SSG build with output: 'static', deployed as files to nginx. Read Sections 4, 14.
Mode F, Hybrid SSR. Some pages SSG, some SSR, via output: 'hybrid' and export const prerender. Read Sections 4, 14.
Mode G, Server Islands. Static page shell with per-component runtime rendering via server:defer. Read Section 12.
2. Client Variables Intake
Capture site specifics before any SEO recommendation. Astro variability across rendering modes, integration sets, and hosting targets makes generic advice less useful than for monolithic CMS platforms.
astro_intake:
platform:
astro_version: "" # 4.x, 5.0, 5.1, 5.2
node_version: "" # 20.x, 22.x
package_manager: "" # pnpm, npm, yarn
output_mode: "" # static, server, hybrid
adapter: "" # none, node, deno, vercel, netlify
server_islands_used: false
view_transitions_used: false
content:
total_pages: 0
total_blog_posts: 0
content_collections_list: [] # blog, docs, authors
languages_published: []
markdown_format: "" # md, mdx, markdoc
content_layer_api_used: false
layouts:
layouts_list: [] # BaseLayout, BlogLayout, DocsLayout
components_count: 0
component_frameworks: [] # react, vue, svelte, solid, preact, alpine
islands_count: 0
integrations:
sitemap: false
rss: false
mdx: false
image: false
partytown: false
react_or_vue_or_svelte: false
db: false
tailwind: false
urls:
trailing_slash_policy: "" # always, never, ignore
canonical_strategy: "" # layout_default, per_page_override
schema:
organization_schema: false
article_schema: false
breadcrumb_schema: false
schema_dts_typed: false
performance:
js_kb_initial: 0
lcp_p75_ms: 0
inp_p75_ms: 0
cls_p75: 0
lighthouse_performance: 0
hosting:
document_root: "" # /var/www/sites/[domain]/dist/
web_server: "" # nginx_static, nginx_to_node
third_party_cdn: "" # none preferred per Joseph standard
ssl_termination: "" # letsencrypt
ai_surface:
appears_in_aio_for_brand: false
llms_txt_present: false
aeo_json_present: false
migration:
previous_platform: "" # none, nextjs, 11ty, hugo, jekyll, wordpress
url_mapping_documented: false
Stored at /var/www/sites/[domain]/audit/astro/intake.yaml. The intake drives the rendering mode decision (Section 4), the integration installation order (Section 10), and the islands hydration strategy (Section 12).
3. Astro Platform Overview 2026
Astro 5.0 released September 2024 with the Content Layer API, Server Islands, Astro DB stable, Astro Actions stable, and View Transitions promoted from integration to core. Astro 5.2 released January 2026 with islands ergonomics and the Container API for testing components in isolation. Astro 5.3 is in development with focus on incremental static regeneration patterns.
3.1 Astro 5 Technical Stack
Astro 5.2 is the current stable, Astro 5.3 is in development. Dev server is Vite 5.4. Build tool is Vite 5.4 atop Rollup 4. Default renderer is static (SSG). SSR adapters are node, deno, vercel, netlify. Default markdown is remark plus rehype with shiki. Default image service is sharp 0.33. TypeScript is the default. The router is file-based at src/pages/. API routes are .ts files under src/pages/api/ in SSR or hybrid mode. The content API is Content Collections (legacy) plus Content Layer (Astro 5). The server-island directive is server:defer. Client directives are client:load, client:visible, client:idle, client:media, and client:only.
Astro's runtime in static mode is zero. In SSR mode the runtime is Node.js with a server layer from the chosen adapter. The build output for static is plain HTML, CSS, and per-island JavaScript chunks. The build output for hybrid is a mix of static HTML and serverless handler manifests. The build output for SSR is a Node.js server entry plus static assets. Joseph's Bubbles default is static unless the project specifically requires server-side logic.
3.2 The Islands Architecture
The Islands architecture is Astro's defining technical decision. Each page is rendered as static HTML at build time (or request time in SSR). Interactive components are explicitly marked with a client:* directive, and only those components ship JavaScript to the browser. The rest of the page is static HTML with zero runtime overhead. Every .astro component renders to HTML at build time and ships zero JavaScript unless explicitly opted in via a directive. Server Islands (Astro 5+) extend this with server:defer: a component renders on the server at request time while the rest of the page is static. The five client directives are client:load (eager), client:idle (requestIdleCallback), client:visible (IntersectionObserver), client:media (media query), and client:only (skip SSR, render only on client).
For SEO the implication is profound: pages are HTML by default, search engine crawlers see full content immediately, AI crawlers (GPTBot, ClaudeBot, Perplexity, Google-Extended) ingest content without JavaScript execution, and Core Web Vitals are essentially solved at the architecture level.
3.3 Content Collections and the Content Layer
Astro 4.x introduced Content Collections, a type-safe API for managing structured content. The pattern: declare a collection in src/content/config.ts with a Zod schema, place content files in src/content/<collection>/, and use getCollection() and getEntry() from astro:content. The benefit is build-time validation, TypeScript types, and a unified API across Markdown, MDX, JSON, and YAML.
Astro 5.0 extended this to the Content Layer API. The Content Layer generalizes the source: collections can pull from local files, from a remote API, from a CMS, from a database, or from any custom loader. The same Zod schemas validate the shape. The result is a framework-native answer to headless CMS integration that does not require a separate library.
3.4 Server Islands
Server Islands defer per-component rendering to runtime while keeping the surrounding page static. The directive is server:defer. The component renders on a Node.js server at request time, the rest of the page is static, and the fragment streams into the page.
For SEO the pattern means the static portion is indexed normally, the dynamic island progressively enhances the experience, and there is no client-side JavaScript required for the dynamic content. Same architectural intent as React Server Components but accomplished with a simpler runtime model.
3.5 View Transitions
Astro 5 promoted View Transitions into core. A single <ClientRouter /> component in the layout enables the View Transitions API for multi-page applications. Cross-page navigation animates as a single page transition, the URL updates, and the experience feels like a SPA without the SPA tax. The integration adds approximately 7 KB of client JavaScript and unlocks per-element transition naming via transition:name. SEO impact is zero: pages remain server-rendered HTML.
3.6 The @astrojs Integration Ecosystem
The official integrations cover sitemap (@astrojs/sitemap), RSS (@astrojs/rss), MDX (@astrojs/mdx), Markdoc (@astrojs/markdoc), Partytown for third-party scripts in a Web Worker (@astrojs/partytown), every major UI framework (@astrojs/react, @astrojs/vue, @astrojs/svelte, @astrojs/solid-js, @astrojs/preact, @astrojs/alpinejs), Tailwind CSS (@astrojs/tailwind), libSQL database (@astrojs/db), and the Node SSR adapter (@astrojs/node). Installation is pnpm astro add <name>. The command edits astro.config.mjs, installs the package, and runs any post-install steps. For SEO the must-haves are @astrojs/sitemap, @astrojs/rss (if a blog exists), and @astrojs/mdx (if structured content benefits from inline components). Section 10 covers each integration in detail.
4. Rendering Modes
Astro supports three primary rendering modes and a fourth via Server Islands. The mode is set in astro.config.mjs via the output field. Each mode has SEO implications that compound across the site.
4.1 Static Mode (SSG, Default)
Set output: 'static' in astro.config.mjs along with site: 'https://thatdeveloperguy.com' and trailingSlash: 'always'. Every page is pre-rendered at build time. The build output is plain HTML files plus per-page CSS and per-island JavaScript chunks. nginx serves the files directly. No Node.js runtime is required in production. This is the default for new Astro projects and the recommended mode for the majority of Joseph's Bubbles deployments. SEO benefit: pages are HTML on the wire, crawlers see content immediately, Core Web Vitals are nearly automatic.
4.2 Server Mode (SSR)
Set output: 'server' with adapter: node({ mode: 'standalone' }) from @astrojs/node. Every page is rendered on each request. The build output includes a Node.js server entry. PM2 manages the process. nginx proxies to the upstream port. SEO impact: pages remain HTML on the wire, but build-time pre-rendering is forgone unless a route opts back in with export const prerender = true. SSR is appropriate when most pages require runtime data (logged-in users, personalized content, frequently updated data).
4.3 Hybrid Mode
Set output: 'hybrid' with the same Node adapter as SSR mode. Pages are SSR by default, but routes can opt into pre-rendering with export const prerender = true at the top of the .astro frontmatter. Hybrid is the recommended mode for sites that have a static marketing surface plus a small dynamic surface (an admin panel, a search results page, an account dashboard).
---
// src/pages/about.astro
export const prerender = true;
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="About" description="About ThatDeveloperGuy">
<h1>About</h1>
</BaseLayout>
4.4 Server Islands Pattern
Server Islands layer onto any of the three modes above. The page is statically rendered, and individual components within that page are deferred to runtime via server:defer.
---
export const prerender = true;
import BaseLayout from '../layouts/BaseLayout.astro';
import LiveBrainStats from '../components/LiveBrainStats.astro';
---
<BaseLayout title="ThatDeveloperGuy" description="SDVOSB web shop">
<h1>Service-Disabled Veteran-Owned Small Business</h1>
<LiveBrainStats server:defer />
</BaseLayout>
The static portion is indexed normally and the deferred portion progressively enhances. AI crawlers see the static HTML; live updates are a layer on top.
4.5 Decision Tree
For all-static or periodically-rebuilt content (marketing site, docs, blog, portfolio), choose output: 'static' and let nginx serve dist/ directly. For mostly-static content with some dynamic routes (marketing plus a search page, marketing plus an admin), choose output: 'hybrid' with export const prerender per route and nginx-to-Node SSR (PM2) with pre-rendered pages cached. For most pages needing runtime data (dashboard application, internal tool, multi-tenant SaaS), choose output: 'server' with nginx-to-Node SSR (PM2). For a single dynamic component within an otherwise static page (live post count, signed-in greeting, cart count), use any output mode plus server:defer on the dynamic component.
For Joseph's typical client (marketing site, blog, services pages, contact form), the answer is output: 'static' with the contact form posting to a Python form handler on Bubbles (e.g., the ThatStupidComputer pattern at port 8001) or to an Astro Action if a Node runtime exists.
5. SEO Implementation
Astro's component model gives the SEO layer a natural home: a single BaseLayout.astro owns the head tags, layouts inherit from it, and per-page frontmatter overrides title, description, canonical, OG image, and schema type.
5.1 The BaseLayout Pattern
---
// src/layouts/BaseLayout.astro
interface Props {
title: string;
description: string;
canonical?: string;
ogImage?: string;
ogType?: 'website' | 'article' | 'profile';
noindex?: boolean;
publishedTime?: string;
modifiedTime?: string;
author?: string;
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
}
const {
title, description, canonical,
ogImage = '/og/default.jpg',
ogType = 'website', noindex = false,
publishedTime, modifiedTime,
author = 'Joseph W. Anady', jsonLd,
} = Astro.props;
const siteName = 'ThatDeveloperGuy';
const siteUrl = Astro.site?.toString().replace(/\/$/, '') ?? '';
const canonicalUrl = canonical ?? new URL(Astro.url.pathname, Astro.site).toString();
const ogImageUrl = ogImage.startsWith('http') ? ogImage : `${siteUrl}${ogImage}`;
const fullTitle = title.includes(siteName) ? title : `${title} | ${siteName}`;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<meta name="author" content={author} />
<link rel="canonical" href={canonicalUrl} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<meta property="og:type" content={ogType} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:site_name" content={siteName} />
{ogType === 'article' && publishedTime && <meta property="article:published_time" content={publishedTime} />}
{ogType === 'article' && modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageUrl} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate" type="application/rss+xml" title={`${siteName} RSS`} href="/rss.xml" />
{jsonLd && <script type="application/ld+json" is:inline set:html={JSON.stringify(jsonLd)} />}
<slot name="head" />
</head>
<body><slot /></body>
</html>
This layout owns every meta tag, infers canonical from Astro.url.pathname, defaults the OG image, and injects optional JSON-LD via the jsonLd prop.
5.2 Per-Page Frontmatter Overrides
---
// src/pages/services.astro
import BaseLayout from '../layouts/BaseLayout.astro';
const jsonLd = {
'@context': 'https://schema.org', '@type': 'Service',
name: 'Web Development and SEO Services',
provider: { '@type': 'Organization', name: 'ThatDeveloperGuy', url: 'https://thatdeveloperguy.com' },
areaServed: { '@type': 'AdministrativeArea', name: 'United States' },
};
---
<BaseLayout
title="Services"
description="Custom web development, SEO implementation, and AI-search optimization from a SDVOSB in Cassville, Missouri."
ogImage="/og/services.jpg"
jsonLd={jsonLd}
>
<main><h1>Services</h1></main>
</BaseLayout>
The pattern keeps SEO concerns out of the page body. The page declares its data, the layout renders the head.
5.3 Canonical Handling
The BaseLayout computes canonical from Astro.url.pathname joined to Astro.site. Edge cases: paginated archive page 1 should canonical to the bare archive URL (pass canonical={'/blog/'} on /blog/page/1/); filtered taxonomy URLs (/blog/?tag=seo vs /blog/tag/seo/) should canonical to the cleaner form; trailing-slash mismatches (/about vs /about/) resolve through trailingSlash: 'always' in astro.config.mjs plus nginx 301 of the slashless form; cross-domain canonicals pass an absolute URL explicitly.
5.4 Robots.txt
Astro does not generate robots.txt by default. Place it in public/robots.txt with one User-agent: * block (Allow: /), explicit allow blocks for GPTBot, ClaudeBot, PerplexityBot, and Google-Extended, plus a final Sitemap: https://thatdeveloperguy.com/sitemap-index.xml line.
AI crawlers are explicitly allowed per Joseph's AIO policy (see framework-aicitations.md). For policies that block specific crawlers see framework-robotstxt-crawlbudget.md.
5.5 Sitemap
Sitemap is handled by the @astrojs/sitemap integration (Section 10.1). The integration generates sitemap-index.xml and sitemap-0.xml at build time. Reference the index in robots.txt and submit to Google Search Console.
5.6 The aeo.json, llms.txt, brand.json Trio
Joseph's standard adds three files for AI search optimization. Place in public/:
public/llms.txt(per framework-aicitations.md)public/aeo.json(structured site facts for AI extraction)public/brand.json(brand voice and entity facts)
The files are copied verbatim. See framework-aicitations.md and framework-aioverviews.md for schema and content patterns.
6. Schema Implementation
JSON-LD is the recommended schema format. Astro injects JSON-LD via the is:inline set:html pattern shown in the BaseLayout. The pattern covers the @id graph approach and TypeScript typing via schema-dts.
6.1 The @id Graph Pattern
---
const siteUrl = 'https://thatdeveloperguy.com';
const orgId = `${siteUrl}/#organization`;
const websiteId = `${siteUrl}/#website`;
const webPageId = `${canonicalUrl}#webpage`;
const graph = [
{ '@type': 'Organization', '@id': orgId, name: 'ThatDeveloperGuy', url: siteUrl,
logo: { '@type': 'ImageObject', url: `${siteUrl}/logo.png`, width: 512, height: 512 },
sameAs: ['https://www.linkedin.com/in/josephanady', 'https://github.com/Janady13'] },
{ '@type': 'WebSite', '@id': websiteId, url: siteUrl, name: 'ThatDeveloperGuy',
publisher: { '@id': orgId }, inLanguage: 'en-US' },
{ '@type': 'WebPage', '@id': webPageId, url: canonicalUrl, name: fullTitle, description,
isPartOf: { '@id': websiteId }, about: { '@id': orgId }, inLanguage: 'en-US' },
];
const jsonLd = { '@context': 'https://schema.org', '@graph': graph };
---
The @id graph entries reference each other by @id, the Organization is declared once at site level, every page contributes a WebPage entry, and per-type entities (Article, Service, Product, FAQPage, BreadcrumbList) extend the graph from the page layout or component.
6.2 Article Schema for Blog Posts
---
// src/layouts/BlogPostLayout.astro
import BaseLayout from './BaseLayout.astro';
const { title, description, publishedTime, modifiedTime, author, ogImage, tags = [] } = Astro.props;
const canonicalUrl = new URL(Astro.url.pathname, Astro.site).toString();
const siteUrl = 'https://thatdeveloperguy.com';
const articleJsonLd = {
'@context': 'https://schema.org', '@type': 'Article',
'@id': `${canonicalUrl}#article`,
headline: title, description,
image: ogImage.startsWith('http') ? ogImage : `${siteUrl}${ogImage}`,
datePublished: publishedTime, dateModified: modifiedTime ?? publishedTime,
author: { '@type': 'Person', name: author, url: `${siteUrl}/about/` },
publisher: { '@id': `${siteUrl}/#organization` },
mainEntityOfPage: { '@id': `${canonicalUrl}#webpage` },
keywords: tags.join(', '), inLanguage: 'en-US',
};
---
<BaseLayout {...Astro.props} ogType="article" jsonLd={articleJsonLd}>
<slot />
</BaseLayout>
The Article schema attaches to the page's #article id, references the site Organization via @id, and ties mainEntityOfPage to the page's WebPage id. Search engines and AI crawlers parse the graph as a single connected entity.
6.3 BreadcrumbList Schema
---
const { items } = Astro.props;
const siteUrl = 'https://thatdeveloperguy.com';
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, idx) => ({
'@type': 'ListItem',
position: idx + 1,
name: item.name,
item: item.url.startsWith('http') ? item.url : `${siteUrl}${item.url}`,
})),
};
---
<nav aria-label="Breadcrumb"><ol>
{items.map((item, idx) => (
<li>{idx < items.length - 1 ? <a href={item.url}>{item.name}</a> : <span aria-current="page">{item.name}</span>}</li>
))}
</ol></nav>
<script type="application/ld+json" is:inline set:html={JSON.stringify(breadcrumbJsonLd)} />
The component renders both the visible HTML breadcrumb and the matching JSON-LD. The pattern keeps the visible markup and the structured data co-located.
6.4 Type-Safety via schema-dts
pnpm add -D schema-dts
---
import type { Article, WithContext } from 'schema-dts';
const articleSchema: WithContext<Article> = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Migrating from Hugo to Astro',
datePublished: '2026-04-15',
author: { '@type': 'Person', name: 'Joseph W. Anady' },
};
---
The schema-dts package provides TypeScript types for every Schema.org entity. The compiler catches typos, missing required properties, and invalid value shapes. For per-page-type schema variants see framework-schema.md.
7. Performance Profile
Astro's Islands architecture is the performance story. The framework ships near-zero JavaScript by default, and the JavaScript it does ship is per-island, code-split, and hydrated on demand. Lighthouse 95+ is the baseline expectation on Joseph's Astro deployments.
7.1 Baseline Performance Numbers
Observed on Bubbles-hosted Astro sites in March 2026. A 12-page marketing site: Lighthouse p75 98, LCP p75 950 ms, INP p75 45 ms, CLS p75 0.02, initial JS 0 KB, origin response 80 ms. A 145-page blog site: Lighthouse p75 96, LCP p75 1180 ms, INP p75 60 ms, CLS p75 0.03, initial JS 14 KB, origin response 90 ms. A 280-page docs site: Lighthouse p75 97, LCP p75 1050 ms, INP p75 55 ms, CLS p75 0.01, initial JS 18 KB, origin response 85 ms.
The numbers are typical, not best-case. The marketing site number reflects zero JavaScript shipped because no islands are used. The blog site reflects a single visible search island (14 KB Preact). For per-metric optimization recommendations see framework-pageexperience.md.
7.2 Client Directives in Detail
client:load hydrates on page load (critical interactive elements above the fold). client:idle hydrates when requestIdleCallback fires (non-critical elements like newsletter signup or comments). client:visible hydrates when IntersectionObserver fires (below-the-fold widgets, mid-page carousels). client:media hydrates when a media query matches (mobile-only menu, desktop-only sidebar). client:only renders only on client and skips SSR entirely (browser-only APIs). The directive choice has direct SEO impact only when client:only is used: content inside is not in the HTML on first load, not crawler-visible, and therefore not indexed. Avoid client:only for content that should appear in search results.
7.3 The "Right Tool for the Right Island" Pattern
Astro's framework-agnostic island model means a React component can sit next to a Vue component next to a Svelte component in the same page. The recommendation is to standardize on one framework per project but treat the framework choice per island as the optimization knob:
- Use Preact instead of React for islands when bundle size matters (Preact is approximately 3 KB vs React's approximately 45 KB)
- Use vanilla JS or Alpine.js for very small interactions (a menu toggle, a form validator)
- Use Solid for performance-critical reactive islands
- Reserve React or Vue for islands that benefit from the larger ecosystem
7.4 Image Optimization
Astro 5 ships with astro:assets and the <Image /> and <Picture /> components. Images are processed at build time, optimized to AVIF and WebP, sized for the requested dimensions, and served with srcset for responsive delivery.
---
import { Image, Picture } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<Image src={heroImage} alt="Founder Joseph Anady" width={1200} height={630}
format="avif" loading="eager" fetchpriority="high" />
<Picture src={heroImage} alt="Founder Joseph Anady"
widths={[480, 800, 1200, 1600]}
sizes="(max-width: 800px) 100vw, 1200px"
formats={['avif', 'webp']} />
The build runs sharp on the source image and emits the requested format and dimensions. For the broader image optimization pattern see framework-imageseo.md.
7.5 Font Loading
Astro does not ship a font integration in core. Self-host fonts from public/fonts/, declare them in CSS with font-display: swap, and preload the LCP font in the head via <link rel="preload" as="font" type="font/woff2" href="/fonts/inter-var.woff2" crossorigin />. Avoid Google Fonts as a third-party origin per Joseph's no-third-party-CDN standard.
8. URL Structure and Routing
Astro routes are file-based: every file in src/pages/ becomes a route. The path mirrors the filesystem.
8.1 File-Based Routing Rules
Static routes map directly: src/pages/about.astro becomes /about/. Index routes use src/pages/services/index.astro for /services/. Dynamic single-param routes use src/pages/blog/[slug].astro for /blog/:slug/ and require getStaticPaths() in static mode. Rest-param routes use src/pages/docs/[...path].astro for /docs/anything/here/. API routes are src/pages/api/contact.ts at /api/contact and require SSR or hybrid mode. Layout files live in src/layouts/*.astro and are not routed (used via import).
8.2 Trailing Slash Policy
// astro.config.mjs
export default defineConfig({
trailingSlash: 'always',
build: { format: 'directory' },
});
The Joseph standard is trailingSlash: 'always'. The setting ensures every URL ends with /, every file is generated as <path>/index.html, and nginx serves directories naturally without rewrites. The alternative trailingSlash: 'never' requires nginx rewrites for clean URLs; the inconsistency causes 301 chains on user-typed URLs. Always-slash is the lower-friction choice.
8.3 Dynamic Routes via getStaticPaths
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import BlogPostLayout from '../../layouts/BlogPostLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({ params: { slug: post.slug }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<BlogPostLayout
title={post.data.title} description={post.data.description}
publishedTime={post.data.publishDate.toISOString()}
modifiedTime={post.data.updatedDate?.toISOString()}
author={post.data.author}
ogImage={post.data.image ?? '/og/blog-default.jpg'}
tags={post.data.tags}>
<article><h1>{post.data.title}</h1><Content /></article>
</BlogPostLayout>
In static mode getStaticPaths() enumerates every URL at build time. In SSR or hybrid mode the function is replaced by Astro.params.slug and the route renders per request.
8.4 The prefetch Directive
<a href="/services/" data-astro-prefetch>Services</a>
The data-astro-prefetch attribute instructs the View Transitions layer to prefetch the linked page on hover or viewport entry. The page HTML and assets are warm in the browser cache by the time the user clicks. SEO impact is zero; perceived performance improvement is substantial.
8.5 The Pagination Pattern
---
// src/pages/blog/[...page].astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
export const getStaticPaths = async ({ paginate }) => {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const sorted = posts.sort((a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime());
return paginate(sorted, { pageSize: 12 });
};
const { page } = Astro.props;
const isFirstPage = page.currentPage === 1;
const canonical = isFirstPage
? 'https://thatdeveloperguy.com/blog/'
: `https://thatdeveloperguy.com/blog/${page.currentPage}/`;
---
<BaseLayout title={isFirstPage ? 'Blog' : `Blog Page ${page.currentPage}`}
description="Latest posts from ThatDeveloperGuy" canonical={canonical}>
<Fragment slot="head">
{page.url.prev && <link rel="prev" href={page.url.prev} />}
{page.url.next && <link rel="next" href={page.url.next} />}
</Fragment>
<main>
<h1>{isFirstPage ? 'Blog' : `Blog Page ${page.currentPage}`}</h1>
{page.data.map((post) => <article><h2><a href={`/blog/${post.slug}/`}>{post.data.title}</a></h2></article>)}
</main>
</BaseLayout>
Pagination canonical points page 1 to the bare /blog/ URL (not /blog/1/) and the rel=prev/next pair gives crawlers the sequence relationship.
9. Content Collections
Content Collections are the typed content authoring API. Astro 5 generalizes this to the Content Layer API which can pull from any source via a loader.
9.1 The config.ts Pattern
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string().max(80),
description: z.string().min(120).max(160),
publishDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
author: z.string().default('Joseph W. Anady'),
image: z.string().optional(),
tags: z.array(z.string()).default([]),
category: z.enum(['development', 'seo', 'business', 'ai']).optional(),
draft: z.boolean().default(false),
canonical: z.string().url().optional(),
noindex: z.boolean().default(false),
}),
});
const authors = defineCollection({
type: 'data',
schema: z.object({
name: z.string(),
role: z.string(),
bio: z.string(),
avatar: z.string(),
social: z.object({
twitter: z.string().optional(),
linkedin: z.string().optional(),
github: z.string().optional(),
}),
}),
});
export const collections = { blog, authors };
The schemas validate frontmatter at build time. Invalid frontmatter fails the build with a clear error. TypeScript types are auto-generated and available via CollectionEntry<'blog'> for use in component props.
9.2 The getCollection and getEntry APIs
import { getCollection, getEntry } from 'astro:content';
const allPosts = await getCollection('blog', ({ data }) => !data.draft);
const seoPosts = await getCollection('blog', ({ data }) =>
data.category === 'seo' && !data.draft
);
const sortedSeo = seoPosts.sort(
(a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime()
);
const post = await getEntry('blog', 'migrating-from-hugo-to-astro');
const joseph = await getEntry('authors', 'joseph-anady');
9.3 Rendering Content
---
import { getEntry } from 'astro:content';
const post = await getEntry('blog', Astro.params.slug);
if (!post) return Astro.redirect('/404');
const { Content, headings } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<Content />
<aside>
<h2>On this page</h2>
<ul>{headings.map((h) => <li><a href={`#${h.slug}`}>{h.text}</a></li>)}</ul>
</aside>
</article>
Content is a component that renders the Markdown body with all configured remark and rehype plugins applied. headings is an array of h2-h6 useful for table-of-contents rendering.
9.4 The Content Layer API (Astro 5)
Astro 5's Content Layer abstracts the data source via a loader property in defineCollection({ loader, schema }). Three loader types: glob({ pattern, base }) matches the legacy Content Collections behavior; file('./path/to/data.json') pulls from a single JSON or YAML file; a custom async () => { ... } function pulls from any HTTP or DB source returning normalized records. The schema (Zod) validates the result. The getCollection() API is identical regardless of source.
9.5 SEO Benefits of Type-Safe Content
Type-safe content prevents common SEO bugs at build time. description: z.string().min(120).max(160) fails the build for a 70-char or 300-char description. title: z.string().max(80) fails the build for a title that would be truncated in SERP. A required-image rule like image: z.string().startsWith('/') makes OG images mandatory and never null at runtime. publishDate: z.coerce.date() ensures Article schema datePublished is never missing. draft: z.boolean().default(false) keeps drafts excluded from getCollection filters consistently.
The schemas live as TypeScript code, are versioned in git, and are reviewed alongside content changes. A schema diff is a deliberate change that the whole team sees.
10. Integrations Ecosystem
Astro integrations are installed via pnpm astro add <name>, which edits astro.config.mjs and runs post-install steps.
10.1 @astrojs/sitemap
In astro.config.mjs, import sitemap from @astrojs/sitemap and add to integrations with { changefreq, priority, lastmod, filter, serialize } options. The filter excludes paths like /admin/ and /draft/. The serialize callback boosts the homepage priority to 1.0. Install via pnpm astro add sitemap. Generates sitemap-index.xml and per-shard sitemap-0.xml files at build time.
10.2 @astrojs/rss
Place an endpoint at src/pages/rss.xml.ts exporting async function GET(context) that calls getCollection('blog', filter), sorts by publishDate desc, maps to RSS items (title, pubDate, description, link, content via sanitize-html + markdown-it, categories), and calls rss({ title, description, site: context.site, items, customData: '<language>en-us</language>' }). Link from BaseLayout via <link rel="alternate" type="application/rss+xml">.
10.3 @astrojs/mdx and @astrojs/markdoc
Install with pnpm astro add mdx or pnpm astro add markdoc. MDX lets authors embed Astro components, React components, or any imported component inside Markdown. Useful for callouts, interactive demos, and image galleries within blog posts. MDX is harder to validate than pure Markdown and slightly slower to build. Markdoc is Stripe's Notion-flavored Markdown variant. The tag syntax {% callout type="warning" %} is more author-friendly than MDX's React component syntax. Useful for documentation sites with non-technical authors.
10.4 astro:assets (Image)
astro:assets is built into Astro 5 (the prior @astrojs/image is deprecated). Usage covered in Section 7.4.
10.5 @astrojs/partytown
// astro.config.mjs
import partytown from '@astrojs/partytown';
export default defineConfig({
integrations: [partytown({ config: { forward: ['dataLayer.push'] } })],
});
Partytown moves third-party scripts (Google Analytics, Google Tag Manager) to a Web Worker. The main thread is freed, INP improves, and Lighthouse scores rise. Use with <script type="text/partytown" src="...">.
10.6 The UI Framework Integrations
pnpm astro add react vue svelte solid-js preact
Each integration enables the corresponding framework's components as Astro islands. Standardize on one framework per project (or use Preact across the board) and reserve cross-framework usage for migrating projects.
10.7 @astrojs/db
Astro DB is a libSQL (SQLite-derivative) integration. Tables are declared in db/config.ts, migrations are generated automatically, and queries use a Drizzle-style API. For Joseph's stack the use case is rare (Python form handlers on Bubbles already handle this), but the integration is the recommended path when SQL persistence is needed inside the Astro app itself.
10.8 Integration Installation Pattern
cd /var/www/sites/[domain]/
pnpm astro add sitemap rss mdx tailwind react
pnpm install
pnpm astro build
sudo systemctl reload nginx
The single command installs all integrations, edits astro.config.mjs, and runs the necessary post-install. For the staging-to-production workflow see Section 14.
11. Internationalization
Astro 4.x added i18n routing in core, and Astro 5 stabilized the API. The configuration lives in astro.config.mjs. For complex i18n the patterns are covered in framework-international.md and framework-hreflang.md.
11.1 The i18n Config
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://thatdeveloperguy.com',
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr', 'de'],
routing: { prefixDefaultLocale: false, redirectToDefaultLocale: false },
fallback: { es: 'en', fr: 'en', de: 'en' },
},
});
The settings produce /about/ for English default, /es/about/ for Spanish, /fr/about/ for French, /de/about/ for German. Missing translations fall back to English.
11.2 Hreflang Tags
---
const path = Astro.url.pathname.replace(/^\/(es|fr|de)\//, '/');
const locales = ['en', 'es', 'fr', 'de'];
const hreflangs = locales.map((locale) => ({
lang: locale,
href: locale === 'en' ? `${siteUrl}${path}` : `${siteUrl}/${locale}${path}`,
}));
---
{hreflangs.map(({ lang, href }) => <link rel="alternate" hreflang={lang} href={href} />)}
<link rel="alternate" hreflang="x-default" href={`${siteUrl}${path}`} />
The pattern emits one hreflang per locale plus an x-default pointing at the canonical English version. Each pair is reciprocal (en points to es, es points to en) which is the Google requirement. For full hreflang rules see framework-hreflang.md.
11.3 Localized Content Collections
Localized content lives in per-locale directories with parallel slugs. Declare one collection per locale with a glob loader pointing at the per-locale subdirectory:
const blogEn = defineCollection({
loader: glob({ pattern: '*.md', base: './src/content/blog/en' }),
schema: z.object({ title: z.string(), description: z.string() }),
});
const blogEs = defineCollection({
loader: glob({ pattern: '*.md', base: './src/content/blog/es' }),
schema: z.object({ title: z.string(), description: z.string() }),
});
The blog page then queries the active locale's collection. For larger sites with structured content and a translation management workflow, see framework-international.md.
12. Astro Components and Islands
The .astro component is Astro's native component format. Components are server-rendered to HTML by default and ship no JavaScript. Astro components compose with UI framework components (React, Vue, Svelte, Solid, Preact) which become islands when they need interactivity.
12.1 The .astro Component Syntax
---
interface Props { heading: string; description: string; ctaUrl: string; ctaText?: string; }
const { heading, description, ctaUrl, ctaText = 'Learn more' } = Astro.props;
const isExternal = ctaUrl.startsWith('http');
---
<section class="cta">
<h2>{heading}</h2>
<p>{description}</p>
<a href={ctaUrl} rel={isExternal ? 'noopener external' : undefined} target={isExternal ? '_blank' : undefined}>
{ctaText}
</a>
</section>
<style>
.cta { padding: 4rem 2rem; background: var(--color-surface-alt); }
h2 { font-size: clamp(1.5rem, 3vw, 2.5rem); }
</style>
The frontmatter is TypeScript. The template is HTML-like with JSX-style expression embedding via {}. The <style> block is scoped to the component by default. The component renders to HTML at build time and ships zero JavaScript.
12.2 The Framework Integration Pattern
---
import BaseLayout from '../layouts/BaseLayout.astro';
import PricingCalculator from '../components/PricingCalculator.tsx';
import LiveChat from '../components/LiveChat.vue';
import SearchBar from '../components/SearchBar.svelte';
---
<BaseLayout title="Pricing Calculator" description="Estimate your project cost">
<main>
<h1>Pricing Calculator</h1>
<PricingCalculator client:visible />
<SearchBar client:idle />
<LiveChat client:media="(min-width: 768px)" />
</main>
</BaseLayout>
Three different frameworks coexist in one page. The HTML is rendered server-side, the islands hydrate lazily, and each island's bundle is code-split.
12.3 The "Right Tool for the Right Island" Rule
For no state or minimal interaction (static card, accordion via <details>, CSS tooltip), use a plain .astro component with no client:* (JS cost: zero). For small local state under a 2 KB bundle (menu toggle, modal trigger, form field validation), use a vanilla JS or Alpine.js island (1 to 3 KB). For reactive state with 5 to 30 components (search box, filter panel, color picker), use a Preact island (3 to 8 KB). For reactive state with 30+ components or React ecosystem need (drag-and-drop builder, rich editor, multi-step wizard), use a React island (40 to 80 KB). For team preference or existing components, use Vue, Svelte, or Solid (cost varies). The recommendation tree biases toward zero JavaScript first, then vanilla JS, then Preact for most reactive needs, with React reserved for islands that benefit from the React ecosystem.
12.4 Server Islands in Detail
---
// src/components/LivePostCount.astro (runs on the server at request time)
import { getCollection } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => !data.draft);
const count = posts.length;
const lastPost = posts.sort(
(a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime()
)[0];
---
<div class="live-stats">
<p>Currently {count} posts published.</p>
{lastPost && <p>Latest: <a href={`/blog/${lastPost.slug}/`}>{lastPost.data.title}</a></p>}
</div>
The page uses the island via <LivePostCount server:defer> and provides a fallback slot rendered immediately as a placeholder. The server:defer directive marks the component for runtime rendering while the page is otherwise static. Requires output: 'hybrid' or output: 'server' and a Node-compatible adapter. SEO impact is positive: the static portion is indexed normally, the deferred portion is a progressive enhancement.
13. Migration to and from Astro
Migration into Astro is common: content-heavy sites built on Next.js (overweight), 11ty (no component model), Hugo (no JavaScript option), Jekyll (Ruby dependency), or WordPress (CMS overhead) frequently move to Astro. For the broader migration framework see framework-migration.md.
13.1 Next.js to Astro
Common when a marketing site or blog was built on Next.js because it was the default and the team realized Astro's content-first model fits better. Pages Router routes (pages/blog/[slug].tsx with getStaticProps/getStaticPaths) become src/pages/blog/[slug].astro with getStaticPaths (low effort, similar API). App Router routes with generateStaticParams translate similarly but the mental model differs (medium). app/layout.tsx with metadata exports becomes src/layouts/BaseLayout.astro with an explicit head slot (low). <Image /> from next/image becomes <Image /> from astro:assets (low). The 'use client' directive becomes a per-island <Component client:visible /> (medium effort, every island needs explicit hydration).
The 80 percent case is straightforward. The 20 percent case involves Next.js-specific features (middleware, edge functions, ISR) that need rethinking in Astro's model. Server Islands cover many of the dynamic-component cases.
13.2 11ty to Astro
11ty templates (.njk, .liquid, .ejs) become .astro components. 11ty collections become Astro Content Collections with Zod schemas (gain type safety). _data/*.json files become Content Layer entries with the file() loader. 11ty shortcodes become .astro components. 11ty users typically gain type-safe content and component composition by migrating, at the cost of slightly slower builds and a heavier toolchain.
13.3 Hugo to Astro
Less common because Hugo's strengths (very fast builds, single binary, no Node dependency) are sometimes the reason the site is on Hugo. When migration happens it is usually because the team wants client-side interactivity that Hugo cannot natively express. See framework-hugo.md.
Go templates become .astro components (high effort, different template engine). Hugo shortcodes become .astro components (medium). data/*.yaml files become Content Layer entries via the file() loader (low). Hugo taxonomies become frontmatter arrays plus Astro filtering in getCollection (medium). Build time grows from under 5 seconds for 1000 pages on Hugo to 20 to 60 seconds for 1000 pages on Astro; budget accordingly.
The build-time trade-off is real. For very large content sites Hugo remains the right answer; Astro is the right answer when 200 to 2,000 pages is the scale and component composition is wanted.
13.4 Jekyll to Astro
Common when a developer-audience blog (often on GitHub Pages) hits the limits of GitHub Pages' plugin whitelist or the team wants TypeScript. See framework-jekyll.md. _posts/YYYY-MM-DD-slug.md becomes src/content/blog/slug.md with publishDate in frontmatter (low effort). _layouts/*.html with Liquid becomes src/layouts/*.astro (medium). Liquid filters become JavaScript expressions in .astro frontmatter (medium, rewrite all filters). Deploy changes from push to gh-pages to rsync dist/ to /var/www/sites/[domain]/ on Bubbles (one-time setup).
13.5 WordPress to Astro
The biggest win. WordPress sites that are content-only move to Astro and gain Lighthouse scores in the 90s, security improvements (no PHP, no plugin attack surface), and a git-based content workflow. The trade-off is the loss of the WordPress admin UI; content authors must adopt Markdown or a headless CMS.
Content export uses wordpress-export-to-markdown (npm) to convert WordPress XML to Markdown files in src/content/blog/. URL preservation requires matching Astro slugs to WordPress permalinks and generating 301s for archive path changes. Media migrates from /wp-content/uploads/ to src/assets/ or public/uploads/. Yoast/RankMath, contact forms, and gallery plugins are replaced by BaseLayout patterns, Bubbles Python form handlers, and astro:assets galleries. The admin replacement is a git workflow plus an optional headless CMS (Decap, Tina, Sanity); this is a high-effort workflow change for non-technical authors. See framework-headless.md.
13.6 Astro to Other Frameworks
Rare but worth noting. Astro to Next.js when the project outgrows the content-first model and needs an application framework. Astro to Nuxt for the same reason in the Vue ecosystem. Astro to SvelteKit for the same reason in the Svelte ecosystem. Astro to Hugo rarely, only when build time at scale becomes a problem. In all four cases the URL structure, content, and schema can be preserved. See framework-migration.md.
14. Bubbles-Hosted Astro
Joseph's hosting standard is Bubbles, a Debian server at 192.168.1.173 (LAN) and 169.155.162.118 (public). nginx serves the document root. No third-party CDN or proxy. SSL via Let's Encrypt.
14.1 The Project Layout on Bubbles
/var/www/sites/[domain]/
src/ # Astro source (git checkout): pages/, layouts/, components/, content/, assets/
public/ # Static assets copied to dist/: robots.txt, favicon.svg, llms.txt, aeo.json, brand.json
dist/ # Build output (served by nginx): index.html, about/index.html, ...
astro.config.mjs
package.json
pnpm-lock.yaml
tsconfig.json
.env
.gitignore
The src/ directory is a git checkout. The dist/ directory is the build output and is regenerated on every deploy. nginx's document root is dist/.
14.2 The nginx Config (Static Mode)
The static-Astro server block: root /var/www/sites/[domain]/dist, index index.html, rewrite ^([^.]*[^/])$ $1/ permanent; for trailing-slash enforcement, try_files $uri $uri/index.html $uri/ =404; resolution, one-year Cache-Control: public, immutable on hashed assets (.css/.js/.woff2/images), 1-hour cache on robots.txt and sitemap-index.xml, Content-Type: text/plain; charset=utf-8 on llms.txt. Port 80 server 301s to HTTPS; www server 301s to non-www per canonical standard.
14.3 The nginx Config (SSR Mode)
The SSR config uses an upstream astro_app { server 127.0.0.1:3000; keepalive 32; } block. The server block has location /_astro/ { alias /var/www/sites/[domain]/dist/client/_astro/; expires 1y; } for hashed chunks served directly by nginx, and location / { proxy_pass http://astro_app; ... } with standard X-Forwarded-* headers proxying to the PM2-managed Node process.
14.4 The PM2 Process Definition (SSR Mode)
ecosystem.config.cjs exports an apps array with script: './dist/server/entry.mjs', instances: 2, exec_mode: 'cluster', env { NODE_ENV: 'production', PORT: 3000, HOST: '127.0.0.1' }, max_memory_restart: '512M', log files in /var/log/pm2/. Start with pm2 start ecosystem.config.cjs, reload with pm2 reload [name], persist with pm2 save && sudo pm2 startup.
14.5 The Deploy Workflow
#!/usr/bin/env bash
# /usr/local/bin/deploy-astro.sh
set -euo pipefail
DOMAIN="${1:?usage: deploy-astro.sh <domain>}"
cd "/var/www/sites/${DOMAIN}"
git pull --ff-only origin main
pnpm install --frozen-lockfile
pnpm astro build
sudo systemctl reload nginx
if pm2 describe "${DOMAIN}-astro" >/dev/null 2>&1; then
pm2 reload "${DOMAIN}-astro"
fi
echo "Deploy complete for ${DOMAIN}."
Make executable with chmod +x /usr/local/bin/deploy-astro.sh. Trigger from a git post-receive hook, a GitHub Actions self-hosted runner, or manually after pushing.
14.6 Atomic Deploys via Symlink
For SSR-mode sites where downtime is unacceptable, use the atomic-symlink pattern. Clone each release into /var/www/sites/[domain]/releases/<timestamp>/, build with pnpm install --frozen-lockfile && pnpm astro build, then swap a current symlink with ln -sfn <release> /var/www/sites/[domain]/current. PM2 reloads against the new entry path. Keep the last 5 releases via ls -1dt releases/* | tail -n +6 | xargs -r rm -rf. nginx points at current/dist/ rather than the per-deploy directory. The symlink swap is atomic; in-flight requests finish on the old release, new requests hit the new release, no downtime.
14.7 Build Performance on Bubbles
A small site of 50 pages cold-builds in 8 to 12 seconds, warm-builds in 4 to 6. A medium site of 500 pages takes 25 to 40 seconds cold, 15 to 22 warm. A large site of 2000 pages takes 90 to 150 seconds cold, 60 to 90 warm. A very large site of 5000+ pages takes 5 to 10 minutes cold; at this scale evaluate Hugo as an alternative for content-heavy sites. Image processing is the dominant cost; pre-sized source images reduce sharp's work substantially. For very large sites consider building in CI rather than on the production server.
14.8 Bubbles-Specific Patterns
Document root pattern is /var/www/sites/[domain]/dist/. No third-party CDN (Joseph's standard). SSL via Let's Encrypt (certbot, auto-renewed). HTTP/2 only for now (no HTTP/3). Brotli compression enabled via nginx-mod-brotli, gzip fallback enabled. Rate limit zone is shared, 100 r/s per IP. AI crawler policy: allowed via robots.txt and llms.txt. IPv6 enabled. HSTS max-age=63072000 (two years, preloaded). The pattern matches Joseph's other Bubbles-hosted properties (TDG, ARCW, Handled Tax, FGTP, NWA POOlice, Heritage Hardwood Floors, Eureka Bath Works). The consistency simplifies the operational surface.
15. Audit Rubric
| # | Criterion | Pass/Fail |
|---|---|---|
| AS1 | astro.config.mjs has site set | |
| AS2 | Rendering mode matches use case | |
| AS3 | BaseLayout owns all meta tags | |
| AS4 | Per-page title and description distinct | |
| AS5 | Canonical computed and overridable | |
| AS6 | OG image generated or specified per page | |
| AS7 | JSON-LD @id graph in BaseLayout | |
| AS8 | Article schema on blog posts | |
| AS9 | BreadcrumbList schema where relevant | |
| AS10 | Content Collections with Zod schemas | |
| AS11 | @astrojs/sitemap installed | |
| AS12 | sitemap-index.xml in robots.txt | |
| AS13 | @astrojs/rss installed (if blog) | |
| AS14 | astro:assets used for images | |
| AS15 | client:* directives correct (no client:only for indexed content) | |
| AS16 | trailingSlash policy matches nginx | |
| AS17 | Pagination canonical + rel=prev/next | |
| AS18 | Fonts self-hosted | |
| AS19 | No third-party CDN or proxy | |
| AS20 | llms.txt, aeo.json, brand.json present | |
| AS21 | i18n config matches hreflang | |
| AS22 | Deploy workflow git-driven and idempotent | |
| AS23 | PM2 ecosystem.config.cjs if SSR | |
| AS24 | Bubbles nginx config matches Section 14 |
Score: 24. World-class: 21+/24.
End of Framework Document
Companions: framework-cross-stack-implementation.md, framework-schema.md, framework-hreflang.md, framework-international.md, framework-migration.md, framework-pageexperience.md, framework-headless.md, framework-technicalseo.md, framework-mobileseo.md, framework-accessibility.md, framework-react.md, framework-aicitations.md, framework-aioverviews.md, framework-hugo.md.
Want this framework implemented on your site?
ThatDevPro ships these frameworks as productized services. SDVOSB-certified veteran owned. Cassville, Missouri.
See Engine Optimization service ›