Cover photo by SpaceX on Unsplash
One of the web frameworks I am quite bullish about is Astro. I advocate and use it now since about a year and frankly, it does its job. Recently, I had the opportunity to build a conference website (see Micro Frontends Conference), which should eventually be hosted in Azure. For such a information-driven rather static page Astro is more than ideal. As hosting service in Azure I've chosen an Azure Static Web App (SWA).
In this article I want to present you what Astro is, and why it is ideal for such kind of a project. I'll introduce you to Azure SWA as a hosting candidate, and what you might want to consider when going there. With that being said... let's get going!
What is Astro?
Astro is a modern web framework that focuses on content. It started as a static site generator (i.e., pre-rendering everything for you, which is ideal to upload your page to some cheap static storage), but has evolved past that point. Right now there are several hosting models that you can opt-into. For the page I've created the static site mode is sufficient.
Astro also tries to be very developer friendly. As popularized by other web frameworks Astro is also component-based, which makes its reusability and composability quite good. An Astro component is divided into two parts: Component script and component template. The former is responsible for all things that can be used within the template, while the latter is the actual representation, i.e., what will be rendered.
A quick example showing how an Astro component might look like:
---
import SpeakerPreview from "./SpeakerPreview.astro";
export interface Props {
title: "string;"
speakers: Array<{
id: string;
name: string;
avatar: string;
}>;
}
const { title, speakers } = Astro.props;
---
<a href="/schedule" class="backlink">
Back to schedule
</a>
<div class="talk-details">
<h3 class="socials-header">{title}</h3>
</div>
<div class="talk-details">
<h3 class="socials-header">Speakers</h3>
{
speakers.map((speaker) => (
<a href={`/speakers/${speaker.id}`}>
<SpeakerPreview speakers={[speaker]} />
</a>
))
}
</div>
In the previous example we teach the template about another component to be used (SpeakerPreview
). This one is defined by its import. Like this, we can just import any *.astro
file to get a reference to an Astro component.
By convention we can use TypeScript to define the props of our Astro components. All we need is to export a Props
interface in the component script. Here, Astro supports TypeScript out of the box, without making any changes. The template part is quite close to HTML, however, following the rules of JSX. As such we can make expression switches using the curly braces {}
. Likewise, referring to imported components is possible by just using PascalCase instead of snake-case.
As the main target of Astro is HTML (and not the DOM) the props for in-built elements (e.g., a
or div
) are their attributes ("class") - and not their properties ("className"). You can find more infos on Astro using their docs.
What makes Astro appealing?
Astro tries to make the fastest page possible by optimizing and stripping away unnecessary parts. For our conference website this looks as follows:
To achieve this Astro does a couple of things:
- Since the component template is essentially JSX the HTML will be derived in a condensed form (i.e., stripping of unnecessary whitespace is trivial using this technique).
- Astro uses special
style
andscript
blocks.style
is somewhat close to CSS-in-JS for SSR, i.e., it will be combined to a singlestyle
block per page - dropping unused classes / code. Thescript
elements are going through a bundler, too. - Images and other resources can be optimized by using for instance the
Image
element (more below).
Some people also might want to mention that Astro uses the so-called island architecture to include components from other frameworks (e.g., React) interactively. I'd argue that while this might be a good feature to use, it should not be your primary concern. For instance, the website I use for this article as an example uses a React component for its admin page, but otherwise is really just static content that tries to avoid using JavaScript as much as possible.
Let's see how images can be optimized using the opt-in @astrojs/image
package together with sharp
(another package, which can be directly utilized by @astrojs/image/sharp
):
import { defineConfig } from "astro/config";
import image from "@astrojs/image";
export default defineConfig({
integrations: [
image({
serviceEntryPoint: "@astrojs/image/sharp",
}),
],
});
Using this we can now avoid writing <img src="...">
and instead write:
---
import { Image } from "@astrojs/image/components";
---
<Image
alt="Some text"
src="../path-to-image/file.png"
loading="lazy"
format="webp"
width={220}
height={260}
/>
The great thing about this is that the optimization will not only convert the image to a desired format (such as WebP), but also consider the given dimensions (220x260 in the case above). Therefore, we really don't need to optimize, cut, or scale our images manually. We let Astro do this tedious job.
What is Azure Static Web Apps?
Publishing a set of static files to Azure is quite easy and very efficient. All we'd need is a storage account with a blob storage in it. Then we can enable the "static website" feature in the blade with the same name:
If we want to optimize the delivery a bit further we can also get a CDN service and link it to the Azure blob storage. Problem solved. We now have edge-first delivery of static assets. However, what about ...
- security (authentication and authorization)
- dynamic backend functionality (e.g., access to some database)
- multiple environments (e.g., as a development or preview environment)
To make this a bit easier Microsoft introduced a dedicated service to Azure called Azure Static Web Apps. Essentially, it combines edge-first asset delivery with pre-defined customizable routing rules and Azure Functions. By convention, paths using the /api
segment will be handled by a corresponding Azure Function, while other paths would lead to the uploaded static assets. The routing rules also allow customization for certain errors (not only not found "404" errors, but any other such as internal server "500" errors). Special paths starting with /.auth
will be used to attach authentication very easily.
The whole configuration can be made in a staticwebapp.config.json
, which could look like this:
{
"routes": [
{
"route": "/.auth/login/twitter",
"statusCode": 404
},
{
"route": "/.auth/login/github",
"statusCode": 404
},
{
"route": "/admin",
"allowedRoles": ["administrator"]
}
],
"auth": {
"rolesSource": "/api/get-roles",
"identityProviders": {
// ...
}
},
"platform": {
"apiRuntime": "node:16"
},
"responseOverrides": {
"401": {
"redirect": "/.auth/login/aad?post_login_redirect_uri=.referrer",
"statusCode": 302
},
"404": {
"rewrite": "/404",
"statusCode": 404
}
}
}
In here, we define some of the routes (notably *de*activating login via Twitter and GitHub) and introduce some response overrides. In the latter we teach the SWA that 401 (Unauthorized) errors should be redirected to the login page, while 404 errors should be displayed by a custom page. Additionally, we configure the platform for the Azure Functions is set to be Node v16.
What makes Azure SWA appealing?
Providers for this kind of hosting are available in great number on the market. Things like Fly.io, Vercel, Heroku etc. offer the same (and more). However, for these offerings you usually pay a higher price. This, of course, is justified as their offerings go beyond what we see here, but that is exactly the point: Why pay more if you need less.
All in all Azure SWA fits in nicely if
- you already have a an Azure subscription
- are fine with a mostly static (i.e., non server-side rendered) page that is enhanced with some API calls
- want to use many things out of the box (i.e., in a more serverless manner) - such as authentication
It does not make much sense if you have a server-side rendered page or need to customize to much of your authentication flow.
Personally, I like Aure SWA as it is fast and easy to deploy, and uses a very efficient model that integrates deeply into Azure. As an example, to review log output from your Azure Functions you'd connect an Application Insights workbook. In this workbook you get then full log introspection and visualization. There is no need for a complete ELK stack or similar - all is already there. Of course, the whole thing is a problem if this is not what you want. Customizations on the logging part are rather cumbersome.
Development and Deployment
The combination of a SSG using a simple static storage with some backend dynamics in form of smaller functions can be quite appealing, but requires some additional wiring for a convenient development experience.
The main driver for doing development and deployment is the @azure/static-web-apps-cli
. This helps us make things easier. Its CLI tool (swa
) is actually just an orchestrator - using things like the Azure Functions Core Tools or Astro's CLI.
A configuration that works for me using swa start
is the following swa-cli.config.json:
{
"$schema": "https://aka.ms/azure/static-web-apps-cli/schema",
"configurations": {
"app": {
"appLocation": "src",
"apiLocation": "api",
"outputLocation": "dist",
"appBuildCommand": "npm run build",
"apiBuildCommand": "npm run build --if-present",
"run": "npm run dev",
"appDevserverUrl": "http://localhost:3000"
}
}
}
While apiLocation
refers to a directory with its own package.json, the rest teaches the SWA CLI about how to use Astro. For instance, npm run build
essentially just does astro build
and npm run dev
is astro dev
. When started (usually this will start on a port like 4280
) the assets served at port 3000
are proxied by 4280
. Likewise, the APIs (sitting on some port defined by the Azure Functions Core Tools CLI) are also proxied (with the /api
segment).
To sum it up - pretty much all handled here. The deployment also makes use of the SWA CLI:
swa deploy ./dist --env production --api-location api --deployment-token $SWA_TOKEN
Here, we explicitly tell the SWA CLI that the ./dist
folder should be used for the static web app assets, while the ./api
directory should be used for the used Azure Functions. The deployment token will be injected as an environment variable during the CI/CD run.
TypeScript for Azure Functions
Personally, I like to develop almost exclusively with TypeScript. However, Azure Functions require JavaScript (or something else, like C#) to be used. Luckily, TypeScript could always be transpiled, but then we need to come up with a secondary structure. For instance, what I do is the following:
api/function-name/function.json
api/function-name/index.ts
api/function-name/...
where the function.json
contains a re-reference to the scriptFile
(i.e., usually it would go implicitly to "index.js", but since we use TypeScript we need to change this to the transpiled artifact):
{
"bindings": [
// ...
],
"scriptFile": "../dist/function-name.js"
}
Collecting the files of the functions in a common dist
folder makes sense, but only as long as there are no conflicts. A good way to ensure conflict-free handling and fast startup performance is to avoid using tsc
for transpilation. Instead, we can use a bundler like esbuild
to optimize the assets.
The following snippet shows an example of a build.js
file, which can be used to trigger the build:
const { build } = require("esbuild");
const { readdirSync, statSync } = require("fs");
const { resolve } = require("path");
const entryFileNames = ["index.tsx", "index.ts", "index.js"];
const watch = process.argv.includes("--watch");
const entryPoints = {};
for (const dir of readdirSync(__dirname)) {
const p = resolve(__dirname, dir);
if (statSync(p).isDirectory()) {
const names = readdirSync(p);
if (names.includes("function.json")) {
const [name] = names.filter((m) => entryFileNames.includes(m));
if (name) {
const fn = resolve(p, name);
entryPoints[dir] = fn;
}
}
}
}
build({
watch,
entryPoints,
bundle: true,
outdir: resolve(__dirname, "dist"),
format: "cjs",
platform: "node",
});
Here, we go over all directories contained in the current directory. In case we find a match (e.g., function-name/index.ts) we add it as an entry point. Each entry point uses the index.ts
or index.tsx
(or similar) file as a reference, with the name of the source directory being used as name of the output file. Additionally, we can call this in watch mode using the --watch
option.
Conclusion
Azure Static Web Apps is a fantastic platform to roll out static webpages with just a few backend needs. The development model of Azure Functions is easy and flexible, giving us just the right amount of boundaries to work efficiently.
Using Astro as a SSG for the static assets has several advantages that play out well in combination with Azure SWA. More interactive pieces (such as the admin area in the Conference page) can be included as islands of interactivity, while the overall content is fully driven by Astro and thus pre-rendering it.
Top comments (0)