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.
Bringing React into your Content Sites with Astro
By Matthew Phillips
Bringing React into your Content Sites with Astro
- 386