Michal Jarnot8 min

Astro.js & the Quest for Simplicity

EngineeringJun 21, 2023

Engineering

/

Jun 21, 2023

Michal JarnotFrontend Engineer

Share this article

There’s a running joke that a new JavaScript framework is created every week. The joke’s origin likely dates way back to when React was in its infancy and web developers were migrating from PHP and Python multi-page applications, starting to experiment with Node.js and other JavaScript tools (like Ember, Backbone, Knockout or Meteor) that helped them write complex SPAs efficiently.

By the end of that era, we mostly got stuck with React and Angular — and, later, Vue. Life was good and we didn't have to experiment with frameworks for a while, except when people started inventing state management libraries.

However, it seems that with state management mostly solved — thanks to tools like TanStack Query — web app development is going through a renaissance, and quite a few interesting alternative frameworks have appeared in the past few years. Remix, SolidJS, Qwik and Astro are just a few examples.

Nope, Too Long Won't Read

If this article gets too long, I want to reward you for opening it and making it all the way here by summarizing why I decided to give Astro.js a try over other alternatives.

Firstly, it’s “Zero JS, by default,” which means that no JavaScript is included unless you explicitly include it. This way, you can use your favorite tools to write websites (such as React or Vue) and not have to worry about shipping unnecessary JavaScript to production.

It also offers an interesting "component islands" architecture that, paired with the framework itself being "UI-agnostic," could result in some cool micro-frontend applications built from different frameworks — whatever the reason for doing so might be. In most cases, mixing "technologies” is a terrible idea. However, theoretically, it could be pretty fun to be able to try different tools without worrying about rewriting or breaking some old stuff we can't touch.

Introduction

I’m a huge fan of robust frameworks that provide all the tools I need to be productive and force me to follow best practices. So, naturally, when I wanted to refresh my knowledge and see what's going on in the chaotic frontend world, I decided to explore Astro.js — which is mostly useful for static and content-focused websites written in pure HTML and CSS.

The Typical App

There are too many things to learn. When it comes to new frontend technologies, I tend to be skeptical and protective of my time. After all, if I have a free weekend, I don't want to spend it learning a framework that might not be relevant in two years — and even worse, I may end up being the one who has to maintain it when it fades into obscurity because I was the one who brought it to the project in the first place.

As mentioned, I like robust tools that force me to use best practices and come with a toolbox. Angular is on the heavier side of frameworks that provide many tools people don't necessarily need (from custom build configuration all the way to UI animation helpers). Although to be fair, even React apps come with their own set of dependencies that people have to use one way or another:

  • React
  • Router
  • Forms
  • Validation library
  • Styling (CSS-in-JS or alternatives)
  • API Client
  • State management (react-query, zustand, ...)
  • Dates
  • Various 3rd-party UI components for accessibility (downshift and others)

If anyone asks whether we really need to install 3rd-party libraries to solve these problems, 9 times out of 10 my answer is yes. We want to have our applications performant, accessible and secure. More importantly, maintaining and writing new features becomes easier. Many of you remember when, a long long time ago, we used Redux. I believe most people took for granted that every React codebase was pretty much the same. Switching between projects, onboarding or offboarding was easy.

The Problem

The obvious problem is that every app has a different size and complexity, and ultimately tries to solve different problems. It doesn't make sense to set up Next.js and use Redux, along with all the tooling we're used to, just to build one static html file. It would make sense to use Gatsby or something similar — although, for some reason (static websites are likely too boring), you then also need to learn about GraphQL and spend a considerable amount of time figuring out how to configure everything properly, how the plugin ecosystem works…

On the other hand, if you’re working on a complex web application that communicates with multiple APIs over HTTP and websockets, handles complex user interaction and saves a lot of data in the internal state, then it would make sense to install a robust state management library, spend a lot of time on project setup and implement advanced patterns — like DDD or micro-frontends — that help you split your application and business logic into smaller, meaningful blocks of code.

Build System

Ironically, if we want to do something minimal, it doesn't make much sense to configure Webpack ourselves. With Next.js, Vite or Remix, we don't have to worry about bundler configuration, yet we can still use the latest JavaScript features, write code in TypeScript and easily optimize and bundle our code for production.

But we can do better. Astro builds on top of Vite and supports both Vite and Rollup plugins, allowing us to use the tools that we like and pretend that our project has only one initial production dependency:

{
 "name": "example-blog-astro",
 "type": "module",
 "version": "0.0.1",
 "scripts": {
   "dev": "astro dev",
   "start": "astro dev",
   "build": "astro build",
   "preview": "astro preview",
   "astro": "astro"
 },
 "dependencies": {
   "astro": "2.1.9"
 }
}

UI Framework

By default, Astro ships with zero JavaScript. That means our website will literally have 0 JavaScript unless we enable it with client directives. I wanted to try going the pure HTML way, but the complexity that comes with document queries for simple DOM interactions didn't seem worth it. Luckily, Astro supports many popular frameworks, like React, Vue and SolidJS (but not Angular). You’re free to choose the tool you like and, even better, can combine multiple frameworks and use them together in one application.

Page components may look like this:

// server side javascript
import Author from '../components/AuthorWidget/AuthorWidget.tsx';
import List from '../components/List/List.vue';
import Layout from '../layouts/Layout.astro';
<Layout title={texts.TITLE}>
 <Author client:load />
 <List client:load />
</Layout>

React is a good choice, but if we don't need advanced React features and only care about JSX to help us write clean code for UI interaction, then we can get away with Preact instead. This is perhaps the first time I have found a valid use case for Preact. We only need to give up 3kb of our JavaScript budget to make our app interactive and development experience enjoyable.

To add Preact to our project, we need to add preact and @astrojs/preact integration to our dependencies.

{
 "name": "example-blog-astro",
 "type": "module",
 "version": "0.0.1",
 "scripts": {
   "dev": "astro dev",
   "start": "astro dev",
   "build": "astro build",
   "preview": "astro preview",
   "astro": "astro"
 },
 "dependencies": {
   "@astrojs/preact": "2.1.9",
   "astro": "2.1.3",
   "preact": "10.6.5"
 }
}

Styling

When it comes to styling, we can choose pretty much whatever we’re comfortable with. Some people look down on Sass and Less, but I don't see it that way. Still, it arguably doesn't make much sense to use React without a CSS-in-JS solution because React’s main selling point is that everything runs in JavaScript — HTML, logic and styles, which can be easily interactive.

If we want to be minimalistic, we can go with Tailwind, which has become wildly popular in the past few years. In this case, the selling point is that it gives us a sense of simplicity because we don't need many dependencies to make Tailwind work — and when it comes to using it, we don't need to worry about maintaining CSS files because all of our CSS code lives inside utility classes. 

Of course, this is mostly just an illusion because our HTML now has a bunch of classes that we should maintain. But the reality is that most of the time, if we make major changes to CSS, we rewrite components from scratch anyway. Everything else should be easily maintainable and configurable from the Tailwind configuration file.

With Tailwind, our package.json file would look like this. (Maybe we could also add Tailwind Merge to easily solve specificity issues, but it's not necessary.)

{
 "name": "example-blog-astro",
 "type": "module",
 "version": "0.0.1",
 "scripts": {
   "dev": "astro dev",
   "start": "astro dev",
   "build": "astro build",
   "preview": "astro preview",
   "astro": "astro"
 },
 "dependencies": {
   "@astrojs/preact": "2.1.9",
   "@astrojs/tailwind": "3.1.1",
   "astro": "2.1.3",
   "clsx": "1.2.1",
   "preact": "10.6.5",
   "tailwindcss": "3.0.24"
 }
}

Forms & Validation

Actually, in some cases, we don't need any dependencies to work with forms. All we need to do is style native HTML form elements. It's pretty easy to use native client-side forms to make sure we’re sending the correct data to the server. We just need to make sure that our forms are A11y.

Static Data Collections

For static websites and microsites that only need to serve a few blog pages, we can use Astro's content collections. The idea is that if we follow a specific folder structure and keep our data in src/content, then Astro will help load the correct data.

src
└── content
   ├── blog
   │   ├── first-post.md
   │   ├── markdown-style-guide.md
   │   ├── second-post.md
   │   ├── third-post.md
   │   └── using-mdx.mdx
   └── config.ts

All we have to do is configure the config.ts file:

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


const blog = defineCollection({
  // Type-check frontmatter using a schema
  schema: z.object({
    title: z.string(),
    description: z.string(),
    // Transform string to Date object
    pubDate: z
      .string()
      .or(z.date())
      .transform((val) => new Date(val)),
    updatedDate: z
      .string()
      .optional()
      .transform((str) => (str ? new Date(str) : undefined)),
    heroImage: z.string().optional(),
  }),
});


export const collections = { blog };

Then we can load the collection entry in our .astro component and pass it to HTML/Preact.

---
import { CollectionEntry, getCollection } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';


export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: post,
  }));
}
type Props = CollectionEntry<'blog'>;


const post = Astro.props;
const { Content } = await post.render();
---


<BlogPost {...post.data}>
</BlogPost>

Dynamic Data

In case we want to do something more complicated, it usually makes sense to install TanStack Query (React Query). When it comes to small applications, we can get away with an auto-generated API client — as long as our BE API can generate schema in the OpenAPI specification.

We need to make a few changes to our package.json.

{
 "name": "example-blog-astro",
 "type": "module",
 "version": "0.0.1",
 "scripts": {
   "dev": "astro dev",
   "start": "astro dev",
   "build": "astro build",
   "preview": "astro preview",
   "astro": "astro"
   "generate:api": "rm -rf src/api && openapi -i http://localhost:8080/docs-json -o src/api"
 },
 "dependencies": {
   "@astrojs/preact": "2.1.9",
   "@astrojs/tailwind": "3.1.1",
   "astro": "2.1.3",
   "clsx": "1.2.1",
   "preact": "10.6.5",
   "tailwindcss": "3.0.24"
 }
 "devDependencies": {
   "openapi-typescript": "6.2.0",
   "openapi-typescript-codegen": "0.23.0"
 }
}

This way, we can automatically generate models inside the src/api/models folder and relevant API client classes in the src/api/services folder.

src
├── api
   ├── core
   │   ├── ApiError.ts
   │   ├── ApiRequestOptions.ts
   │   ├── ApiResult.ts
   │   ├── CancelablePromise.ts
   │   ├── OpenAPI.ts
   │   └── request.ts
   ├── index.ts
   ├── models
   │   ├── BlogPost.ts
   │   └── CreateBlogPostDTO.ts
   └── services
       └── Blog.ts

Then all we have to do is either use the generated API service file (BlogService) inside our component with useEffect and useState hooks, or we can write custom useGETApi hooks to simplify API loading/error/data handling and pass whatever we get from our API to our UI components:

import { BlogPost, BlogService } from 'src/api'
import BlogPost from 'src/components/blog/Post'
import { getQueryParam } from 'src/utils/url'
import { useGETApi } from 'src/utils/useApi'


export default () => {
 const { data } = useGETApi<BlogPost>(() =>
   Blog.blogControllerList(Number(getQueryParam('id')))
 )


 return (
   <BlogPost data={data} />
 )
}

Conclusion

Ideally, we should always add code quality tools (ESLint, Prettier, CommitLint, ...) and maybe even set up Storybook and Jest to improve the development experience. But in this case, it’s not necessary. We can build a simple blog application using 3 dependencies: Astro, Preact and Tailwind.

Of course, we still have about 200MB of  node_modules … but no one needs to know.

Share this article



Sign up to our newsletter

Monthly updates, real stuff, our views. No BS.