Bringing React into your Content Sites with Astro

Matthew Phillips

Astro: Co-creator

Manager / Engineer: Platform team

Hobbies

  • Art (watercolor, paper mâché)
  • Enjoying my backyard
  • Pac-Man

Astro ♥️ Nordic

Astro Core @ Copenhagen

April 2023 - Lego meetup

Astro Speed Run

A Not-Just-Static

Site Builder

Static is a vibe

Astro

  • Component oriented
  • Content focused
  • Performance without effort
  • Use the tools you already know and love

Islands Architecture

Sound familiar?

React Server Components are basically islands

Islands Architecture

  • Coined by Katie Sylor-Miller of Etsy
  • Popularized by Jason Miller of Preact in a post in 2020
  • Other thought-leaders thought-lead
  • Experimentation and development.
  • 2021, Astro begun with the mission of using islands to reduce the amount of JS needed at runtime

The Brief History

Astro Components

---
import Counter from '../components/Counter.jsx'
const title = 'Counter'
---
<html lang="en">
  <head>
    <title>{ title }</title>
  </head>
  <body>
    <h1>Counter app</h1>

    <Counter />
  </body>
</html>

Astro Islands

---
import Counter from '../components/Counter.jsx'
const title = 'Counter'
---
<html lang="en">
  <head>
    <title>{ title }</title>
  </head>
  <body>
    <h1>Counter app</h1>

    <Counter client:idle />
  </body>
</html>

Current gen size

File-based routing

.
└── my-company-site/
    └── src/
        ├── components/
        │   ├── Counter.jsx
        │   ├── Header.jsx
        │   ├── Footer.vue
        │   └── Sidebar.astro
        └── pages/
            ├── index.astro
            ├── about.astro
            └── blog/
                ├── first-post.md
                ├── second-post.md
                └── fancy.mdx
  • /
  • /about
  • /blog/first-post
  • /blog/second-post
  • /fancy

File tree

Routes

Developer conviences

  • Backed by Vite. Support for Vite and Rollup's plugin ecosystem.
  • Fast, complete HMR.
  • Evolving ecosystem of our own. https://astro.build/integrations
  • Support for just about any framework.

Content Collections

Type-safe Markdown

.
└── my-company-site/
    └── src/
        ├── layouts/
            └── main.astro
        ├── pages/
        │   ├── index.astro
            ├── blog/
            │   ├── introduction.md
            │   ├── project-status.mdx
            │   └── migrating-to-astro.mdx
            └── team/
                ├── rachel.md
                ├── timothy.md
                └── dominique.md

Page-based content

.
└── my-company-site/
    └── src/
        ├── pages/
        │   ├── index.astro
        │   └── blog/
        │       └── [...slug].astro
        └── content/
            ├── blog/
            │   ├── introduction.md
            │   ├── project-status.mdx
            │   ├── migrating-to-astro.mdx
            │   └── another-article.mdoc
            └── team/
                ├── rachel.md
                ├── timothy.md
                └── dominique.md

Content / page split

Why content collections?

  • Make content queryable
  • Separate content from the presentation
    • Prevent "style bleeding"
  • Make it possible to pull content from CMS

Powered by Zod

Schema Validation

Collection schema

import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  schema: z.object({
    // Define your expected frontmatter properties
    title: z.string(),
    // Mark certain properties as optional
    draft: z.boolean().optional(),
    // Transform datestrings to full Date objects
    publishDate: z.string().transform(
      (val) => new Date(val)
    ),
    // Improve SEO with descriptive warnings
    description: z.string().max(
       160,
      'Short descriptions have better SEO!'
    ),
    // ...
  }),
});

export const collections = { blog };

Collection schema

---
title: Astro is Amazing
publishDate: "6/5/2023"
---

# Astro is Amazing

This is a blog post about how amazing Astro is.

Collection schema

Content collections

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

// Get all `src/content/blog/` entries
const blogPosts = await getCollection('blog');
---
 
<ul>
{ blogPosts.map(post => (
  <li>
    <a href={post.slug}>{ post.data.title }</a>
  </li>
))}
</ul>

/blog

Content collections

/blog/first-post

<Image />

Astro 2.1

Images have gotten complicated

---
import { Image } from 'astro:assets';
import localImage from '../assets/logo.png';
const localAlt = 'The Astro Logo';
---

<Image
  src={localImage}
  width={300}
  height={350}
  alt={localAlt}
/>

Usage

<Image /> component

---
import { getImage } from "astro:assets"
import myImage from "../assets/penguin.png"
const bg = await getImage({
  src: myImage
})
---

<div style={`background-image: url(${bg.src})`}></div>

Usage

getImage()

Automatic layout shift prevention

Important metric for Core Web Vitals

Content collections integration

post.md

---
title: "My first blog post"
cover: "./firstpostcover.jpeg"
coverAlt: "A photograph of a sunset behind a mountain range"
---

This is a blog post
const blogCollection = defineCollection({
  schema: ({ image }) => z.object({
    title: z.string(),
    cover: image(),
    coverAlt: z.string(),
  }),
});

config.ts

Missing image

post.md

---
title: "My first blog post"
cover: "./firstpostcover.jpeg"
coverAlt: "A photograph of a sunset behind a mountain range"
---

This is a blog post
const blogCollection = defineCollection({
  schema: ({ image }) => z.object({
    title: z.string(),
    cover: image().refine(
      (img) => img.width == 800 && img.height == 600, {
      message: "Cover needs to be 800x600 pixels!",
    }),
    coverAlt: z.string(),
  }),
});

config.ts

Incorrect dimensions

Use image

---
import { getEntryBySlug } from 'astro:content'
import { Image } from 'astro:assets'

const post = await getEntryBySlug('blog', Astro.params.slug)
---
 
<h1>{ post.data.title }</h1>

<Image
  src={post.data.cover}
  alt={post.data.coverAlt} />

Recent updates

Since 2.1 (March)

Middleware

Redirects

CDN hosted assets

HTML minification

CSS specifity control

Custom client directives

Hybrid rendering

Vercel image service

Images in MDX

Markdoc

Image build cache

Data collections

Collection references

CSS inlining

SSR in sitemaps

Markdoc

  • Created by Stripe to help scale their documentation platform.
  • Use components via custom "tags".
  • Markdoc is a completely declarative system.
  • Focus on scaling and performance over expressiveness.
  • Can be rendered at runtime; not dependent on module loading pipeline.
  • Supported within content collections in Astro.
  • Great fit for a CMS. 👀

Title Text

---
title: Welcome to Markdoc 👋
---

This simple starter showcases Markdoc with Content Collections. All Markdoc features are supported, including this nifty built-in `{% table %}` tag:

{% table %}
* Feature
* Supported
---
* `.mdoc` in Content Collections
* ✅
---
* Markdoc transform configuration
* ✅
---
* Astro components
* ✅
{% /table %}

{% aside title="Code Challenge" type="tip" %}

Markdoc

Content

Title Text

import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import Aside from './src/components/Aside.astro';

export default defineMarkdocConfig({
  tags: {
    aside: {
      render: Aside,
      attributes: {
        type: { type: String },
        title: { type: String },
      },
    },
  },
});

Markdoc

Configuration

markdoc.config.mjs

CDN hosted assets

<link href="/_astro/assets/home.css" rel="stylesheet">
<link href="https://cdn.example.com/_astro/assets/home.css" rel="stylesheet">
export default defineConfig({
  build: {
    assetsPrefix: 'https://cdn.example.com'
  }
});

Before

After

HTML minification

<html lang="en" class="astro-J7PV25F6"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>minimum html</title><link rel="stylesheet" href="/_astro/index.1a1a72db.css" /></head><body class="astro-J7PV25F6"><div>2</div><main class="astro-J7PV25F6"></main><div>3</div></body></html>
---
import Aside from './aside.astro'
import Page from './page.astro'
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>minimum html</title>
  <body>
    <Aside/>
 
    <Page />
  </body>
</html>

Parallelized rendering

---
import AsyncComponent from '../AsyncComponent.astro'
---
<AsyncComponent delay={1000} />
<AsyncComponent delay={1000} />
<AsyncComponent delay={1000} />

SSR support in sitemaps

@astrojs/sitemaps

import { defineConfig } from 'astro/config';
import sitemaps from '@astrojs/sitemaps';

export default defineConfig({
  integrations: [sitemaps()]
});

Stronger CSS scoping

<style>
  article {
    margin-block: 2rem;
  }
</style>

<article></article>
article:where(.astro-1234) {
  margin-block: 2rem;
}

Current default

article.astro-1234 {
  margin-block: 2rem;
}

Class strategy

<Image /> Updates

Automatic Markdown and MDX

# Favorite paintings

![A starry night sky](../../assets/stars.png)

Build time caching

  • Builds are cached in your node_modules/.cache folder
  • Cache is preserved in most CI environments

Vercel Image Service

// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';

export default defineConfig({
  output: 'server',
  adapter: vercel({
    imageService: true
  })
});
  • Automatic caching in Vercel Edge Network
  • Enables fast usage of dynamic image creation

Vercel Image Service

// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';

export default defineConfig({
  output: 'server',
  adapter: vercel({
    imageConfig: {
      sizes: [320, 640, 1280]
    }
  })
});

Full control over image configuration

Data collections + relations

Problem

  • Each post has 1 or more authors
  • We want to show author's image
  • We want to show all posts by an author
  • We want to show related posts

Authors are data

Posts are related

.
└── my-company-site/
    └── src/
        ├── pages/
        │   ├── index.astro
        │   └── blog/
        │       └── [...slug].astro
        └── content/
            ├── blog/
            │   ├── introduction.md
            │   ├── project-status.mdx
            │   ├── migrating-to-astro.mdx
            │   └── another-article.mdoc
            └── team/
                ├── rachel.json
                ├── timothy.json
                └── dominique.json

Data collections

Data collections

  • Data used in a variety of places.
  • Doesn't exist (primarily) for reading in one place. Not an article.
  • Formats like JSON and YAML.

Define schema

import { defineCollection, z } from 'astro:content';

const authors = defineCollection({
  type: 'data',
 
  schema: z.object({
    name: z.string(),
    portfolio: z.string().url(),
  }),
});

export const collections = { authors };

Data collections

Define data

{
  "name": "Matthew Phillips",
  "portfolio": "https://matthewphillips.info"
}

Data collections

.
└── my-company-site/
    └── src/
        └── content/
            ├── blog/
            └── authors/
                ├── matthew.json
                ├── fred.json
                └── erika.json

Content relations

Relationship types

  • One to one - Blog post has one author
  • One to many - Blog post has many related posts
  • Many to many - Blog posts with multiple authors; authors have multiple blog posts

One-to-one

Blog with one author

Define schema

import { defineCollection, z, reference } from 'astro:content'

const authors = defineCollection({
  type: 'data',
 
  schema: z.object({
    name: z.string(),
    portfolio: z.string().url(),
  }),
})

const blog = defineCollection({
  schema: z.object({
    title: z.string(),
    author: reference('authors'),
  })
})

export const collections = { authors, blog }

One-to-one

Define relation

---
title: Welcome Post
author: matthew
---
  
# Welcome to the Site!

One-to-one

Use data

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

const welcomePost = await getEntry('blog', 'welcome');
const author = await getEntry(welcomePost.data.author);
---
  
<h1>Author: {author.data.name}</h1>

<a href={author.data.portfolio}>Portfolio</a>

One-to-one

One-to-many

Blog with related posts

Define schema

import { defineCollection, z, reference } from 'astro:content'

const blog = defineCollection({
  schema: z.object({
    title: z.string(),
    relatedPosts: z.array(reference('blog')).optional(),
  })
})

export const collections = { authors, blog }

One-to-many

Define relation

---
title: Welcome Post
relatedPosts:
- related-1
- related-2
---
  
# Welcome to the Future!

One-to-many

Use data

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

const welcomePost = await getEntry('blog', 'welcome');
const relatedPosts = await getEntries(welcomePost.data.relatedPosts ?? []);
---
                                      
<h1>Related posts</h1>
                                      
<ul class="related-posts">
  {
    relatedPosts.map((post) => (
      <li>
        <a href={`/blog/${post.slug}`}>{post.data.title}</a>
      </li>
    ))
  }
</ul>

One-to-many

Astro 2.6

Hybrid rendering

Mixed SSR and SSG

Static by default

import { defineConfig } from 'astro/config';

export default defineConfig({
  output: 'hybrid'
});

Hybrid rendering

Server rendered

---
export const prerender = false
---
      
<!-- ... -->

Hybrid rendering

index.astro

export const prerender = false

export function post({ request }) {
  /* ... */
}

api.ts

Middleware

Modify the request and response

Middleware

Concepts

  • src/middleware.ts - Entrypoint for defining middleware
  • locals - A mutable object that middleware can add to
  • sequence - A way to combine middleware together into a chain

Dev-only route

export const onRequest = ({ url }, next) => {
  if(url.pathname === '/testing' && import.meta.env.MODE !== 'development') {
    return new Response(null, {
      status: 405
    });
  }
  return next();
};

Middleware

Authentication

const auth = async ({ cookies, locals }, next) => {
  if(!cookies.has('sid')) {
    return new Response(null, {
      status: 405 // Not allowed
    });
  }
  
  let sessionId = cookies.get('sid');
  let user = await getUserFromSession(sessionId);
  if(!user) {
    return new Response(null, {
      status: 405 // Not allowed
    });
  }
  
  locals.user = user;
  return next();
};

export {
  auth as onRequest
};

Middleware

Authentication

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

const { user } = Astro.locals;
---
  
<UserProfile user={user} client:load />

(Usage in component)

Middleware

Protected routes

import { sequence } from 'astro/middleware';
import { auth } from './auth';

const allowed = async({ locals, url }, next) => {
  if(url.pathname === '/admin') {
    if(locals.user.isAdmin) {
      return next();
    } else {
      return new Response('Not allowed', {
        status: 405
      });
    }
  }
  
  return next();
};

export const onRequest = sequence(auth, allowed);

Middleware

Minify response

export const onRequest = async (context, next) {
  let response = await next();
  
  if(response.headers.get('content-type') === 'text/html') {
    let html = await response.text();
    let minified = await minifyHTML(html);
    return new Response(minified, {
      status: 200,
      headers: response.headers
    });
  }

  return response;
}

Middleware

Custom client directives

Custom client directives

<Counter client:load />
<Counter client:idle />
<Counter client:visible />
<Counter client:media="(max-width: 50em)" />
  • load - When page loads
  • idle - When the CPU is idle
  • visible - When the component is scrolled into view
  • media - When the media query matches

Built-in directives

Custom client directives

<Counter client:click />

Custom directives

export default (load, opts, el) => {
  addEventListener('click', async () => {
    const hydrate = await load()
    await hydrate()
  }, { once: true })
}

1. Define behavior

import { defineConfig } from 'astro/config'
import click from '@matthewp/astro-click'

export default defineConfig({
  integrations: [click()]
})

2. Add integration

3. Use anywhere

Redirects

Integrated with your host

Configuration

import { defineConfig } from 'astro/config';

export default defineConfig({
  redirects: {
    '/home': '/welcome',
    '/blog/[..slug]': '/articles/[...slug]'
  }
});

Redirects

Netlify

/other              /                            301
/home               /welcome                     301
/team/articles/*    /blog/*                      301

Redirects

Vercel

{"version":3,"routes":[
  {"src":"/\\/blog(?:\\/(.*?))?","headers":{"Location":"/team/articles/$1"},"status":301},
  {"src":"/\\/two","headers":{"Location":"/"},"status":301},
  {"src":"/\\/one","headers":{"Location":"/"},"status":301}
]}
_redirects
config.json

Inline stylesheets

<link rel="stylesheet" href="/main.css">
<link rel="stylesheet" href="/shared-one.css">
<link rel="stylesheet" href="/shared-two.css">
<link rel="stylesheet" href="/shared-two.css">
<link rel="stylesheet" href="/main.css">
<link rel="stylesheet" href="/shared-one.css">
<style>.shared-two{/* ... */}</style>
<style>.shared-three{/* ... */}</style>

inlineStylesheets: 'never'

inlineStylesheets: 'auto'

<style>body{/* ... */}</style>
<style>.shared-one{/* ... */}</style>
<style>.shared-two{/* ... */}</style>
<style>.shared-three{/* ... */}</style>

inlineStylesheets: 'always'

Coming up...

Advanced querying of content

View transitions

Astro 3.0

with Vite 5

That's the talk.