What is Astro?
Astro is a web framework designed for building high-performance, content-driven websites such as blogs, documentation, marketing pages, and e-commerce sites. What sets Astro apart from other static site generators is its use of the islands architecture, shipping zero js by default but also allowing you to import components from other frameworks with plugins. It strikes a great balance between something like Next.js which offers a lot of great features (but also ships tons of JS) and totally static like handlebars. It provides built-in features like content collections, TypeScript type-safety for Markdown content, and extensive customization options with integrations like Tailwind and MDX and a bunch of other helpful features.
File layout
Astro files use the .astro file extension and use a format similar to Svelte, where any initial data handling is at the top of the page (with no indenting!). So this section of the file ends up being the equivalent to Next’s `getServerSideProps/getStaticProps`
, or the main body of a server component if you’re using the App router, but also is where any imports for other UI components happen. Here is a very simple page
---
import Layout from "../layouts/Layout.astro"
import Footer from "../components/Footer.jsx"
import Modal from "../components/Modal.svelte"
const data = await fetchSomeData(...)
---
<Layout>
<ul>
{data.map(...)}
</ul>
<Modal alert="Using Svelte!" client:visible />
<Footer client:load />
</Layout>
Here you can see how easy it is to import components from various frameworks! Using the Astro client directives you can also control exactly when their respective bundles are loaded! Unlike Next.js there is no _app and layouts have to be added to every page. The templating syntax is the same curly bracket syntax used in JSX. Components follow the same format and props are passed via the Props interface.
---
interface Props {
someProp: string | null
...
}
const { someProp, ... } = Astro.props
---
<div>
{someProp ?? "default value"}
<slot />
<slot name="footer" />
</div>
Instead of the {children}
prop, children are passed through slots very similarly to Svelte. You can have multiple slots and also named slots. Here is a very simple component. Using plugins you can also directly import React, Svelte and components from many other frameworks, they even come with SSR support and hydration, magic!
Scripting/interactivity
Scripts can be included simply with the <script>
tag. In .astro files there is no inbuilt reactivity as in other frameworks as this is not the aim of the framework so if you’re manipulting the dom in astro files you’re going to have to go back to the old `document.querySelector`
ways. Astro does still make the experience a whole lot better by letting you use TypeScript and imports by default
<button id="alert">Click me!</button>
<script>
import { doSomething } from "../lib/my-alert"
const button = document.getElementById("button") as HTMLDivElement
button.addEventListener('click', doSomething)
</script>
This may be a little scary to us React devs but it’s honestly not that bad especially for simple components and the benefits are much much smaller bundle sizes. Do we really need to add 80kb of JS for a counter app?
Layouts
Like other popular modern frameworks Astro provides support for layouts, i.e. parts of the UI which are shared across routes. Other parts of the html which are shared, such as the head. Here’s a very simple example layout
---
interface Props {
title: string;
}
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
</nav>
<h1>{title}</h1>
<article>
<slot /> <!-- your content is injected here -->
</article>
</body>
</html>
Routing
Like Next.js Astro uses file system routing. The usual project structure looks something like this
public/
favicon.svg
social-image.png
src/
components/
Header.astro
Button.jsx
layouts/
PostLayout.astro
pages/
posts/
index.astro
[...slug].astro
user/
[id].astro
index.astro
styles/
global.css
Coming from Next.js this should look very familiar, Astro even uses the same […slug]
convention for dynamic routes. Astro doesn’t have a client side router but it does have an inbuilt implementation of the ViewTransitions API.
SSG/SSR
SSG is handled almost identically to Next. Astro’s getStaticPaths
function is functionally Next’s own getStaticPaths
and getStaticProps
rolled into one function.
.../posts/[...slug].astro
---
import { GetStaticPaths } from "astro"
export const getStaticPaths: GetStaticPaths = async () => {
async function getPosts(...
...defined above but this time fetching from /posts
}
const posts = await getPosts()
return posts.map(post => ({
params: { slug: post.slug },
props: {
body: post.body,
title: post.title,
},
}))
}
const { body, title } = Astro.props
---
<div>
<h1>{title}</div>
<div set:html={body}></div>
</div>
Astro also also has great inbuilt support for markdown which I won’t go into in this post and the content collection API is a great way to organise static site content. SSR is similarly simple. You can get the params or path of a route in the initial script via Astro.params, etc.
Styling
Styles can simply be added by adding a <style>
tag to the component/page. Styles are automatically scoped to the file but this can be opted out of with the `is:global`
directive. And of course there is support for everyone’s favourite TailwindCSS
Building the blog
To show all this off we’re going to create a very simple blog using Substack as our CMS. Alternatively you could use one of Astro’s best features which is the content collections API along with markdown
Why would you even want to do this? Mostly for fun but also: Substack has a nice editor. Plus, having your blog directly on your site just makes life easier. It's like killing two birds with one stone—easy management and more visibility since everything's in one place. Substack also takes care of nifty stuff like image processing and CMS tasks. And hey, if Substack is missing some features your readers want (seriously, no dark mode?), this gives you more options for customisation. Bonus: If you're already using a Substack blog, you don't have to go through the hassle of moving all your articles. Easy peasy!
Setup
Like Next, setting up an Astro project is super simple. run
pnpm/npm create astro@latest
and in the project directory run
pnpm/npm run dev
to start an automatically updating development server
Create a Layout file /src/layouts/Layout.astro which will be shared by all our pages. You can follow the above simple layout template. Along with the layout page create a file /src/pages/blog/[...page].astro for the index pages and /src/pages/blog/posts/[...slug].astro for the individual post pages.
Substack’s API
Did you know Substack has a public(ish) API? You can use URL/api/v1/archive
and URL/api/v1/posts
with a few handy query parameters to easily fetch posts. There is a limit of 50 per request so will have to fetch posts 50 at a time and stitch them together to get all the results which is easy enough to do
async function getPosts(
offset: number = 0,
totalPosts: ArchivePost[] = [],
): Promise<ArchivePost[]> {
const substackUrl = import.meta.env.SUBSTACK_URL
const data = await fetch(
`${substackUrl}/api/v1/archive?sort=new&offset=${offset}&limit=50`
)
const newPosts = (await data.json()) as ArchivePost[]
const newTotalPosts = totalPosts.concat(newPosts)
if (newPosts.length !== 50) return newTotalPosts
return getPosts(offset + 50, newTotalPosts)
}
Here is the code to generate the pages of posts using Astro’s handy paginate api to create pages of posts. You could of course use an infinite scroll, that’s what the API was designed for afterall. Here I’m using the /archive
api route because it does not send the bodies of the posts, which you may not want if you’re just showing a list of posts for example. You can use /posts
route if you do need the post body.
/src/pages/blog/[...page].astro
---
import type { GetStaticPaths, Page } from "astro"
import Layout from "../../layouts/Layout.astro"
import { ArchivePost } from "../../types/substack"
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
async function getPosts( ...defined above
}
const posts = await getPosts()
return paginate(posts, { pageSize: 10 })
}
const page = Astro.props.page as Page<ArchivePost>
---
The page object gives you a few handy utilities such as links to the next and previous pages, the index of the first and last items on the current page, etc. Here’s a simple template for the post page
<Layout title="You | Blog">
<main>
<div>
<div>
{page.url.prev && <a href={page.url.prev}>prev</a>}
{page.url.prev && page.url.next && <span>|</span>}
{page.url.next && <a href={page.url.next}>next</a>}
</div>
</div>
<ul>
{page.data.map((post) => (
<li>
<a href={`/blog/posts/${post.slug}`}>
{post.cover_image && (
<Image
src={post.cover_image}
alt={post.title + " image"}
width="50"
height="50"
/>
)}
<h3>{post.title}</h3>
<div>{post.description}</div>
<ul>
{post.postTags.map((tag) => (
<li>{tag.name}</li>
))}
</ul>
</a>
</li>
))}
</ul>
</main>
</Layout>
To generate the actual pages I’ve used the URL/api/v1/posts
API since it includes the html body which we do need now. Here I’m using Astro to prerender all the pages at build time. If you’re coming from Next.js, Astro’s getStaticPaths
function is functionally Next’s own getStaticPaths
and getStaticProps
rolled into one
.../blog/posts/[...slug].astro
---
import { GetStaticPaths } from "astro"
import BlogLayout from "../../../layouts/BlogLayout.astro"
import { PostsPost } from "../../../types/substack"
export const getStaticPaths: GetStaticPaths = async () => {
async function getPosts(...
...defined above but this time fetching from /posts
}
const posts = await getPosts()
return posts.map(post => ({
params: { slug: post.slug },
props: {
body: post.body_html,
title: post.title,
subtitle: post.subtitle
},
}))
}
const { body, title, subtitle } = Astro.props
---
Substack maps your posts directly to their html tags, i.e. Heading 1 => <h1>
, paragraphs => <p>
which makes styling incredibly easy. There are some more complex elements such as the captioned image. Feel free to take the styles from my portfolio’s repo to fix these
One big downside
Unfortunately Astro doesn’t provide a native way to handle ISR. I.e. how does Astro know when to render new pages when we add them to Substack, or update them if they’re edited. The short answer is you can’t… nicely. This article gives two ways to get around this first using the `Cache-Control`
http header, and the other using in memory caching. In memory caching won’t work well if you’re using a serverless platform such as Vercel since the cache can only last as long as the instance its created on. Therefore the `Cache-Control`
method would work better for us. The big downside is cold starts; the server won’t generate pages for the cache until a request is made.
To do this simply add the header, either via Astro.response
API or, if using Vercel, via /vercel.json. Remove the getStaticPaths functions from both the blog/[…page] and blog/posts/[…slug] files and use a regular SSR data fetching scheme. You’ll have to handle the pagination yourself now but that’s incredibly simple.
.../blog/[page].astro
---
...
const postsPerPage = 10
const pageNo = Astro.params.page
const substackUrl = import.meta.env.SUBSTACK_URL
const offset = postsPerPage * pageNo
// fetch an extra post to see if there should be a page after the current
const data = await fetch(
`${substackUrl}/api/v1/archive?sort=new&limit=${postsPerPage + 1}`
)
const posts = await data.json() as PostsPost[]
const prevUrl = pageNo !== "1" ? `/blog/${parseInt(pageNo) - 1}` : null
const nextUrl = posts.length === 11 ? `/blog/${parseInt(pageNo) + 1}` : null
posts.splice(postsPerPage)
---
...
The data fetching for the posts pages is very simple now.
.../blog/posts/[slug].astro
---
...
const substackUrl = import.meta.env.SUBSTACK_URL
const data = await fetch(
`${substackUrl}/api/v1/posts/${Astro.params.slug}`
)
const post = await data.json() as PostsPost
const { body_html: body, title, subtitle } = post
---
...
There is a very hacky third option for ISR which is to just create a script which tells Vercel, or whatever cloud provider to rebuild the whole site periodically. If you’re running your own server this wouldn’t be too hard to implement. Astro really should add ISR support…
Conclusion
Astro is a great alternative to Next.js for static sites and definitely worth checking out! You can view all of the code for the above here.