NexusCS

Astro

Web Frameworks
Astro is a modern web framework for building fast, content-focused websites with zero JS by default and Islands Architecture for interactive components.
astro
web
framework
islands
ssg
ssr
static-site

Getting started

Installation

# Create new project
npm create astro@latest

# Choose template
npm create astro@latest -- --template portfolio

# Manual installation
npm install astro

Quick Start

# Start dev server
npm run dev

# Build for production
npm run build

# Preview production build
npm run preview

# Check for errors
npm run astro check

Project Structure

src/
  pages/         # File-based routing
    index.astro
    about.astro
  layouts/       # Reusable layouts
  components/    # Astro/framework components
  content/       # Content collections
public/          # Static assets
astro.config.mjs # Configuration

Astro Components

Component Structure

---
// Component Script (frontmatter)
// Runs at BUILD TIME, not runtime
const greeting = "Hello";
const items = await fetchData();
---

<!-- Component Template -->
<div class="container">
  <h1>{greeting} World</h1>
  <ul>
    {items.map(item => <li>{item}</li>)}
  </ul>
</div>

<style>
  /* Scoped styles by default */
  .container {
    padding: 2rem;
  }
</style>

<script>
  // Runs in BROWSER
  console.log('Client-side code');
</script>

Component Props

---
interface Props {
  title: string;
  description?: string;
  items: string[];
}

const { title, description, items } = Astro.props;
---

<article>
  <h2>{title}</h2>
  {description && <p>{description}</p>}
  <ul>
    {items.map(item => <li>{item}</li>)}
  </ul>
</article>

Dynamic Expressions

---
const name = "Astro";
const show = true;
const items = [1, 2, 3];
---

<!-- Variables -->
<h1>{name}</h1>

<!-- Conditional rendering -->
{show && <p>Visible</p>}
{show ? <p>Yes</p> : <p>No</p>}

<!-- Lists -->
<ul>
  {items.map(n => <li>{n}</li>)}
</ul>

<!-- Dynamic attributes -->
<div class:list={['base', { active: show }]}>
  Content
</div>

<!-- Set directives -->
<div set:html={htmlString} />
<div set:text={textString} />

Layouts

Basic Layout

---
// src/layouts/BaseLayout.astro
interface Props {
  title: string;
  description?: string;
}

const { title, description } = Astro.props;
---

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width">
    <title>{title}</title>
    {description && <meta name="description" content={description}>}
  </head>
  <body>
    <header>
      <nav><!-- Navigation --></nav>
    </header>
    <main>
      <slot />
    </main>
    <footer>
      <slot name="footer">
        <!-- Default footer content -->
      </slot>
    </footer>
  </body>
</html>

Using Layouts

---
import BaseLayout from '../layouts/BaseLayout.astro';
---

<BaseLayout title="Home Page" description="Welcome">
  <h1>Main Content</h1>
  <p>This goes in the default slot</p>

  <div slot="footer">
    <p>Custom footer content</p>
  </div>
</BaseLayout>

Nested Layouts

---
// src/layouts/BlogLayout.astro
import BaseLayout from './BaseLayout.astro';

interface Props {
  title: string;
  author: string;
  pubDate: Date;
}

const { title, author, pubDate } = Astro.props;
---

<BaseLayout title={title}>
  <article>
    <header>
      <h1>{title}</h1>
      <p>By {author} on {pubDate.toLocaleDateString()}</p>
    </header>
    <slot />
  </article>
</BaseLayout>

Pages & Routing

File-Based Routing

src/pages/
  index.astro           → /
  about.astro           → /about
  blog/
    index.astro         → /blog
    [slug].astro        → /blog/:slug
  posts/
    [page].astro        → /posts/1, /posts/2, ...
  [...path].astro       → /* (catch-all)
  api/
    posts.json.ts       → /api/posts.json

Dynamic Routes

---
// src/pages/blog/[slug].astro
export async function getStaticPaths() {
  const posts = await fetch('https://api.example.com/posts')
    .then(res => res.json());

  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

const { post } = Astro.props;
---

<article>
  <h1>{post.title}</h1>
  <div>{post.content}</div>
</article>

Pagination

---
// src/pages/posts/[page].astro
export async function getStaticPaths({ paginate }) {
  const posts = await fetchAllPosts();

  return paginate(posts, { pageSize: 10 });
}

const { page } = Astro.props;
---

<div>
  {page.data.map(post => (
    <article>{post.title}</article>
  ))}

  <nav>
    {page.url.prev && <a href={page.url.prev}>Previous</a>}
    <span>Page {page.currentPage} of {page.lastPage}</span>
    {page.url.next && <a href={page.url.next}>Next</a>}
  </nav>
</div>

Rest Parameters

---
// src/pages/[...path].astro
// Matches /any/path/here

const { path } = Astro.params;
// path = "any/path/here"
---

<h1>Path: {path}</h1>

Islands Architecture

Client Directives

---
import Counter from '../components/Counter.jsx';
import Carousel from '../components/Carousel.vue';
import Search from '../components/Search.svelte';
---

<!-- Load immediately on page load -->
<Counter client:load />

<!-- Load when browser is idle -->
<Carousel client:idle />

<!-- Load when visible in viewport -->
<Search client:visible />

<!-- Load when media query matches -->
<Sidebar client:media="(max-width: 768px)" />

<!-- Skip SSR, render only on client -->
<Chart client:only="react" />

Directive Comparison

Directive When Use Case Priority
client:load Page load Critical UI High JS
client:idle After load Non-critical Medium JS
client:visible In viewport Below fold Low JS
client:media Media query Responsive Conditional
client:only Client-side No SSR Client-only

Passing Props to Islands

---
import InteractiveWidget from '../components/Widget.tsx';

const data = await fetchData();
const config = { theme: 'dark', lang: 'en' };
---

<InteractiveWidget
  client:load
  data={data}
  config={config}
  onEvent={() => console.log('clicked')}
/>

Zero JS by Default

---
// This ships ZERO JavaScript to browser
const data = await fetchData();
---

<div>
  <h1>Static Content</h1>
  {data.map(item => <p>{item}</p>)}
</div>

UI Framework Integration

Setup Integrations

# Add React
npx astro add react

# Add Vue
npx astro add vue

# Add Svelte
npx astro add svelte

# Add Solid
npx astro add solid

# Add Preact
npx astro add preact

# Add Lit
npx astro add lit

Configuration

// astro.config.mjs
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import vue from "@astrojs/vue";

export default defineConfig({
  integrations: [react(), vue()],
});

Using Framework Components

---
// Mix frameworks in one page
import ReactCounter from '../components/Counter.tsx';
import VueCarousel from '../components/Carousel.vue';
import SvelteSearch from '../components/Search.svelte';
---

<div>
  <!-- Static Astro component (no JS) -->
  <header>Static Header</header>

  <!-- Interactive React component -->
  <ReactCounter client:load initial={0} />

  <!-- Interactive Vue component -->
  <VueCarousel client:visible items={items} />

  <!-- Interactive Svelte component -->
  <SvelteSearch client:idle />
</div>

React Example

// src/components/Counter.tsx
import { useState } from "react";

interface Props {
  initial?: number;
}

export default function Counter({ initial = 0 }: Props) {
  const [count, setCount] = useState(initial);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

Content Collections

Define Collection Schema

// src/content/config.ts
import { defineCollection, z } from "astro:content";

const blogCollection = defineCollection({
  type: "content",
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    author: z.string(),
    tags: z.array(z.string()),
    draft: z.boolean().default(false),
    image: z.string().optional(),
  }),
});

export const collections = {
  blog: blogCollection,
};

Content Structure

src/content/
  blog/
    post-1.md
    post-2.mdx
    post-3/
      index.md
      cover.jpg
  config.ts

Query Collections

---
import { getCollection, getEntry } from 'astro:content';

// Get all entries
const allPosts = await getCollection('blog');

// Filter entries
const publishedPosts = await getCollection('blog', ({ data }) => {
  return data.draft !== true;
});

// Sort entries
const sortedPosts = allPosts.sort((a, b) =>
  b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);

// Get single entry
const post = await getEntry('blog', 'post-1');
---

<ul>
  {publishedPosts.map(post => (
    <li>
      <a href={`/blog/${post.slug}`}>
        {post.data.title}
      </a>
    </li>
  ))}
</ul>

Render Content

---
import { getEntry } from 'astro:content';

const post = await getEntry('blog', Astro.params.slug);
const { Content } = await post.render();
---

<article>
  <header>
    <h1>{post.data.title}</h1>
    <p>{post.data.description}</p>
    <time>{post.data.pubDate.toLocaleDateString()}</time>
  </header>

  <Content />
</article>

Data Collections

// src/content/config.ts
const authorsCollection = defineCollection({
  type: "data", // JSON/YAML files
  schema: z.object({
    name: z.string(),
    bio: z.string(),
    avatar: z.string().url(),
    social: z.object({
      twitter: z.string().optional(),
      github: z.string().optional(),
    }),
  }),
});

export const collections = {
  blog: blogCollection,
  authors: authorsCollection,
};

Styling

Scoped Styles

---
const title = "Styled Component";
---

<div class="container">
  <h1>{title}</h1>
</div>

<style>
  /* Automatically scoped to this component */
  .container {
    padding: 2rem;
    background: #f0f0f0;
  }

  h1 {
    color: #333;
    font-size: 2rem;
  }
</style>

Global Styles

<style is:global>
  /* Global styles */
  body {
    font-family: system-ui;
    margin: 0;
  }

  .global-class {
    color: red;
  }
</style>

Tailwind CSS

# Install Tailwind
npx astro add tailwind
---
// Tailwind works automatically
---

<div class="container mx-auto px-4">
  <h1 class="text-4xl font-bold text-gray-900">
    Title
  </h1>
  <p class="mt-4 text-gray-600">
    Description
  </p>
</div>

CSS Modules

---
import styles from './Component.module.css';
---

<div class={styles.container}>
  <h1 class={styles.title}>Title</h1>
</div>

Sass/SCSS

# Install Sass
npm install sass
<style lang="scss">
  $primary-color: #3273dc;

  .container {
    background: $primary-color;

    h1 {
      color: white;
    }
  }
</style>

CSS Variables

<style>
  :root {
    --color-primary: #3b82f6;
    --color-secondary: #8b5cf6;
    --spacing: 1rem;
  }

  .button {
    background: var(--color-primary);
    padding: var(--spacing);
  }
</style>

Build & Deploy

Build Configuration

// astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  site: "https://example.com",
  base: "/my-app",
  output: "static", // or 'server', 'hybrid'
  build: {
    assets: "_assets",
    inlineStylesheets: "auto",
  },
});

Output Modes

Mode Description Use Case
static Pre-rendered at build Default, best performance
server On-demand rendering Dynamic content, SSR
hybrid Mix static + SSR Selective rendering

SSR Adapters

# Node.js
npx astro add node

# Vercel
npx astro add vercel

# Netlify
npx astro add netlify

# Cloudflare
npx astro add cloudflare

# Deno
npx astro add deno

SSR Configuration

// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  output: "server",
  adapter: node({
    mode: "standalone",
  }),
});

Hybrid Rendering

// astro.config.mjs
export default defineConfig({
  output: "hybrid", // SSG by default
});
---
// Opt-in to SSR for this page
export const prerender = false;
---

<div>This page is server-rendered</div>
---
// Force static in server mode
export const prerender = true;
---

<div>This page is pre-rendered</div>

Environment Variables

# .env
PUBLIC_API_URL=https://api.example.com
SECRET_KEY=secret123
---
// Access public vars (client + server)
const apiUrl = import.meta.env.PUBLIC_API_URL;

// Access private vars (server-side only)
const secret = import.meta.env.SECRET_KEY;
---

<div data-api={apiUrl}></div>

<script>
  // Only PUBLIC_ vars available here
  const url = import.meta.env.PUBLIC_API_URL;
</script>

Assets & Images

Image Component

---
import { Image } from 'astro:assets';
import myImage from '../assets/photo.png';
---

<!-- Optimized local image -->
<Image
  src={myImage}
  alt="Description"
  width={800}
  height={600}
/>

<!-- Remote image -->
<Image
  src="https://example.com/image.jpg"
  alt="Remote image"
  width={800}
  height={600}
  format="webp"
/>

<!-- With loading priority -->
<Image
  src={myImage}
  alt="Hero"
  loading="eager"
  quality={90}
/>

Picture Component

---
import { Picture } from 'astro:assets';
import myImage from '../assets/photo.png';
---

<Picture
  src={myImage}
  formats={['avif', 'webp']}
  alt="Responsive image"
  widths={[400, 800, 1200]}
  sizes="(max-width: 768px) 100vw, 800px"
/>

getImage API

---
import { getImage } from 'astro:assets';
import myImage from '../assets/photo.png';

const optimizedImage = await getImage({
  src: myImage,
  width: 800,
  format: 'webp',
  quality: 80,
});
---

<img src={optimizedImage.src} alt="Optimized" />

Static Assets

---
// Files in public/ are served as-is
---

<!-- Favicon -->
<link rel="icon" href="/favicon.ico" />

<!-- Logo -->
<img src="/logo.svg" alt="Logo" />

<!-- Font -->
<link href="/fonts/custom.woff2" rel="preload" as="font" />

API Routes

JSON Endpoint

// src/pages/api/posts.json.ts
import type { APIRoute } from "astro";

export const GET: APIRoute = async ({ params, request }) => {
  const posts = await fetchPosts();

  return new Response(JSON.stringify(posts), {
    status: 200,
    headers: {
      "Content-Type": "application/json",
    },
  });
};

Dynamic Routes

// src/pages/api/posts/[id].json.ts
import type { APIRoute } from "astro";

export const GET: APIRoute = async ({ params }) => {
  const { id } = params;
  const post = await getPost(id);

  if (!post) {
    return new Response(null, {
      status: 404,
      statusText: "Not found",
    });
  }

  return new Response(JSON.stringify(post));
};

POST Requests

// src/pages/api/subscribe.json.ts
import type { APIRoute } from "astro";

export const POST: APIRoute = async ({ request }) => {
  const data = await request.json();
  // or: const data = await request.formData();

  const email = data.email;

  // Process subscription
  await subscribe(email);

  return new Response(
    JSON.stringify({
      message: "Subscribed successfully",
    }),
    {
      status: 201,
    },
  );
};

Static Paths for APIs

// src/pages/api/users/[id].json.ts
export async function getStaticPaths() {
  const users = await fetchUsers();

  return users.map((user) => ({
    params: { id: user.id.toString() },
    props: { user },
  }));
}

export const GET: APIRoute = ({ props }) => {
  return new Response(JSON.stringify(props.user));
};

Advanced Features

Middleware

// src/middleware.ts
import { defineMiddleware } from "astro:middleware";

export const onRequest = defineMiddleware(async (context, next) => {
  // Before request
  console.log("Request:", context.url.pathname);

  const response = await next();

  // After request
  response.headers.set("X-Custom-Header", "value");

  return response;
});

Middleware Sequence

// src/middleware.ts
import { sequence } from "astro:middleware";

const auth = defineMiddleware(async (context, next) => {
  // Auth logic
  return next();
});

const logging = defineMiddleware(async (context, next) => {
  // Logging logic
  return next();
});

export const onRequest = sequence(auth, logging);

View Transitions

---
import { ViewTransitions } from 'astro:transitions';
---

<html>
  <head>
    <ViewTransitions />
  </head>
  <body>
    <!-- Animated page transitions -->
    <a href="/about">About</a>
  </body>
</html>

Custom Transitions

---
import { fade, slide } from 'astro:transitions';
---

<div transition:animate={fade({ duration: '0.5s' })}>
  Fade in/out
</div>

<div transition:animate={slide({ duration: '0.3s' })}>
  Slide in/out
</div>

<!-- Persist element across pages -->
<div transition:persist>
  <video>...</video>
</div>

Prefetch Links

---
import { ViewTransitions } from 'astro:transitions';
---

<head>
  <ViewTransitions />
</head>

<!-- Prefetch on hover -->
<a href="/about" data-astro-prefetch>
  About
</a>

<!-- Prefetch immediately -->
<a href="/blog" data-astro-prefetch="load">
  Blog
</a>

<!-- Prefetch when visible -->
<a href="/docs" data-astro-prefetch="visible">
  Docs
</a>

Configuration

astro.config.mjs

import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import tailwind from "@astrojs/tailwind";

export default defineConfig({
  // Site config
  site: "https://example.com",
  base: "/subpath",
  trailingSlash: "ignore", // 'always', 'never', 'ignore'

  // Build config
  output: "static", // 'server', 'hybrid'
  build: {
    format: "directory", // or 'file'
    assets: "_assets",
  },

  // Dev server
  server: {
    port: 3000,
    host: true,
  },

  // Integrations
  integrations: [react(), tailwind()],

  // Markdown
  markdown: {
    shikiConfig: {
      theme: "dracula",
    },
  },

  // Vite config
  vite: {
    plugins: [],
  },
});

TypeScript Config

// tsconfig.json
{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "react"
  }
}

Path Aliases

// tsconfig.json
{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"]
    }
  }
}

Performance Best Practices

Zero JS by Default

---
// This component ships ZERO JavaScript
const data = await fetchData();
---

<div>
  <h1>Static Content</h1>
  {data.map(item => <p>{item}</p>)}
</div>

This is Astro's killer feature - static by default.

Selective Hydration

---
import Heavy from '../components/Heavy.tsx';
---

<!-- Only load when visible -->
<Heavy client:visible />

<!-- Only load on mobile -->
<MobileMenu client:media="(max-width: 768px)" />

<!-- Defer until idle -->
<Analytics client:idle />

Use client:visible for below-the-fold content.

Optimize Images

---
import { Image } from 'astro:assets';
import hero from '../assets/hero.jpg';
---

<!-- Automatic optimization -->
<Image
  src={hero}
  alt="Hero"
  loading="eager"  # for above-fold
  format="avif"    # modern format
  quality={80}     # balance size/quality
/>

<!-- Lazy load below fold -->
<Image
  src={feature}
  alt="Feature"
  loading="lazy"
/>

Content Optimization

---
// Fetch at build time, not runtime
const posts = await getCollection('blog');

// Cache expensive operations
const cached = await cache.get('key') ??
  await expensiveOperation();
---

Common Patterns

SEO Component

---
// src/components/SEO.astro
interface Props {
  title: string;
  description: string;
  image?: string;
  type?: 'website' | 'article';
}

const { title, description, image, type = 'website' } = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const imageURL = image ? new URL(image, Astro.site) : undefined;
---

<head>
  <title>{title}</title>
  <meta name="description" content={description} />
  <link rel="canonical" href={canonicalURL} />

  <!-- Open Graph -->
  <meta property="og:type" content={type} />
  <meta property="og:title" content={title} />
  <meta property="og:description" content={description} />
  <meta property="og:url" content={canonicalURL} />
  {imageURL && <meta property="og:image" content={imageURL} />}

  <!-- Twitter -->
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:title" content={title} />
  <meta name="twitter:description" content={description} />
  {imageURL && <meta name="twitter:image" content={imageURL} />}
</head>

Reading Time

---
import { getEntry } from 'astro:content';

const post = await getEntry('blog', Astro.params.slug);
const { Content } = await post.render();

// Calculate reading time
const wordCount = post.body.split(/\s+/g).length;
const readingTime = Math.ceil(wordCount / 200);
---

<article>
  <header>
    <h1>{post.data.title}</h1>
    <p>{readingTime} min read · {wordCount} words</p>
  </header>
  <Content />
</article>

RSS Feed

// src/pages/rss.xml.ts
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: "A blog about web development",
    site: context.site,
    items: posts.map((post) => ({
      title: post.data.title,
      pubDate: post.data.pubDate,
      description: post.data.description,
      link: `/blog/${post.slug}/`,
    })),
    customData: `<language>en-us</language>`,
  });
}

Sitemap

# Install sitemap integration
npx astro add sitemap
// astro.config.mjs
import sitemap from "@astrojs/sitemap";

export default defineConfig({
  site: "https://example.com",
  integrations: [sitemap()],
});

Gotchas & Tips

Component Script Runs at Build Time

---
// This runs during BUILD, not in browser
console.log('Build time'); // Prints during npm run build

const data = await fetch('https://api.example.com');
// Fetched at build time, not on page load
---

<script>
  // This runs in BROWSER
  console.log('Runtime');
</script>

The frontmatter is server-side/build-time code.

No Client State in .astro Files

---
// ❌ This won't work - no reactivity
let count = 0;
---

<!-- ❌ Won't update on click -->
<button onclick={() => count++}>Count: {count}</button>

<!-- ✅ Use framework component instead -->
<Counter client:load />

Astro components are static - use framework components for interactivity.

Client Directives Required for Interactivity

---
import Counter from '../components/Counter.jsx';
---

<!-- ❌ Won't work - no interactivity -->
<Counter />

<!-- ✅ Correct - interactive -->
<Counter client:load />

Slots vs Props

<!-- ✅ Use slots for HTML/components -->
<Card>
  <h2>Title</h2>
  <p>Content with <strong>markup</strong></p>
</Card>

<!-- ✅ Use props for data -->
<Card title="Title" description="Plain text" />

Import Paths Must Include Extension

---
// ✅ Correct
import Component from '../components/Component.astro';

// ❌ Wrong - missing extension
import Component from '../components/Component';
---

getStaticPaths Must Return Array

---
// ❌ Wrong - missing return
export async function getStaticPaths() {
  const posts = await getCollection('blog');
  posts.map(post => ({ params: { slug: post.slug } }));
}

// ✅ Correct
export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post }
  }));
}
---

Migration Guide

From Next.js

Next.js Astro
pages/index.js src/pages/index.astro
getStaticProps Frontmatter code
getStaticPaths getStaticPaths()
_app.js Layout components
getServerSideProps export const prerender = false
next/image astro:assets
useRouter Astro.url

From Gatsby

Gatsby Astro
gatsby-node.js getStaticPaths()
GraphQL queries getCollection()
gatsby-config.js astro.config.mjs
Plugins Integrations
gatsby-image astro:assets

From Jekyll

Jekyll Astro
_posts/ src/content/blog/
Front matter Frontmatter with schema
_layouts/ src/layouts/
_includes/ src/components/
Liquid JSX-like syntax

Also see