Astro in action: A developer's perspective

Astro in action: A developer's perspective

Let’s discover Astro: framework for web development. We’ll cover middleware usage, type-safe internationalization, security, and HTMX with Astro.

Over the past year, Astro framework has been my go-to framework for web development due to its special developer experience (DX) and intuitive nature. It simply “just works,” which is exactly what I need. I’m excited to share my experiences with you.

I already had experience with Next.js and SvelteKit, but when I discovered Astro, I quickly recognized its potential. Astro got me interested in “Islands Architecture”. I've heard about this approach before, but I've never tried it.

The general idea of an “Islands” architecture is deceptively simple: render HTML pages on the server, and inject placeholders or slots around highly dynamic regions […] that can then be “hydrated” on the client into small self-contained widgets, reusing their server-rendered initial HTML.

— Jason Miller, Creator of Preact

While there is plenty of content about Astro, I want to share my personal findings. I encountered and solved various problems. If you don't want to read the whole article, you can jump straight to the source code. Explore the repository.

Exploring Astro

Astro is a modern web framework for content-driven websites that reduces JavaScript overhead and complexity. It offers fast loading, excellent search engine optimization (SEO), and great DX. Astro combines the power of static site generators with the flexibility of server-rendered frameworks.

Astro components can be rendered to HTML either during build-time or with server-side rendering (SSR), eliminating the need for client-side rendering. However, if you need a component that is hydrated on the client-side, it is still possible using Client Directives.

Another significant aspect of Astro is its "UI-agnostic" nature. This means that you can write layout and client code using various UI frameworks and libraries. It has a variety of popular frameworks official integrations React, Preact, Svelte, Vue, SolidJS, AlpineJS and Lit.

Personal findings

Let's delve into solving web development challenges with Astro. I will showcase a few examples that demonstrate advanced usage and provide clear code snippets for practical application.

Middleware usage

Similar to Next.js and SvelteKit, Astro boasts two powerful features: middleware and API endpoints. API endpoints are handy when you need a route to serve as a backend endpoint. On the other hand, middleware are perfect for executing server tasks prior to running your application code.

Middleware provides the ability to intercept requests and responses, allowing you to dynamically inject behaviors before rendering a page or endpoint. It also enables you to set and share request-specific information across endpoints and pages by modifying a locals object that is accessible in all Astro components and API endpoints.

Personally I use middleware as a centralized hub for various important functionalities:

  • Authentication and secure HTTP headers

  • A/B testing and internationalization support

  • Static redirects and request logging

Let's take an example that illustrates authentication logic.

// src/middleware.ts
import { Routes } from '@utils/routing'
import { CookieKeys, clearCookies, } from '@utils/cookies'
import { defineMiddleware, sequence } from 'astro/middleware'

// ...

const googleLogin = defineMiddleware(async (context, next) => {
    const code = context.url.searchParams.get(Routes.GOOGLE_CODE_KEY)
    if (!code) {
        return next()
    }

    try {
        // 1. call backend with auth code from google
        // 2. set auth cookies on success
        // ...
        return next()
    } catch (err) {
        return context.redirect(Routes.LOGIN)
    }
})

// ...

const logout = defineMiddleware(async (context, next) => {
    const hasLogoutKey = context.url.searchParams.has(Routes.LOGOUT_KEY)
    if (!hasLogoutKey) {
        return next()
    }

    clearCookies(context, CookieKeys.ACC)
    clearCookies(context, CookieKeys.JID)

    return context.redirect(Routes.HOME)
})

// ...

export const onRequest = sequence(
    // The order is IMPORTANT!
    googleLogin,
    logout,
)

Next, we will explore middleware further and use it to address internationalization and security.

Type-safe internationalization

Nowadays, in the world of the Internet and resulting globalization more and more businesses serve customers almost anywhere in the world. To succeed they need to provide accessibility through internationalization.

In general, this task is quite common. However, I still prefer the solution to be lightweight, fast, efficient, type-safe (to prevent mistakes), and SSR-friendly, among other considerations. After conducting some research, I stumbled upon a promising library called "typesafe-i18n".

Once installed and set up, the tool creates a .typesafe-i18n.json file. In this file, you specify the adapter. In our case, it's "node" since we are using Astro. Additionally, the tool requires an opinionated folder structure for your locales. All localization files should be placed inside the src/i18n folder.

In real-world examples, it is often a good idea to use a service for storing translations. However, in our case, let's create a script called pull-locale-json.sh to demonstrate the process of creating JSON files.

#!/bin/bash
create_json_file() {
    FILE_PATH=$1

    mkdir -p $(dirname "$FILE_PATH")

    echo '{"error": "Oops, something went wrong!", "success": "Success", "seo": { "title": "Astro advanced POC", "description": "Astro advanced POC" }}' > "$FILE_PATH"
}

EN_FILE_PATH="src/i18n/locales/en.json"
RU_FILE_PATH="src/i18n/locales/ru.json"

create_json_file "$EN_FILE_PATH"
create_json_file "$RU_FILE_PATH"

Now it's time to implement the Importer. We need to write our own logic to retrieve the data, map it to a dictionary representation, and then call the storeTranslationToDisk function. Here's an example of how this could be done:

// src/i18n/import.ts
import en from '@i18n/locales/en.json'
import ru from '@i18n/locales/ru.json'
import type { Locales } from '@i18n/i18n-types'
import type { BaseTranslation } from 'typesafe-i18n'
import {
    storeTranslationToDisk,
    type ImportLocaleMapping
} from 'typesafe-i18n/importer'

const getDataFromAPI = async (_locale: Locales): Promise<BaseTranslation> => {
       switch (_locale) {
           case 'ru':
               return ru
           default:
               return en
       }
}

const importTranslationsForLocale = async (locale: Locales) => {
    const translations = await getDataFromAPI(locale)

    const localeMapping: ImportLocaleMapping = {
        locale,
        translations
    }

    // The crucial aspect here is the `storeTranslationToDisk` function.
    // It accepts JSON data and generates TypeScript boilerplate code.
    const result = await storeTranslationToDisk(localeMapping)

    console.log(`translations imported for locale '${result}'`)
}

for (const locale of ['en', 'ru'] as Locales[]) {
    importTranslationsForLocale(locale)
}

To complete the task, we need to add locale detection logic to middleware.ts. The locale can be stored either in the locals object, which is accessible in all Astro components and API endpoints, or in cookies, as shown in the example below.

// src/middleware.ts
import { setLocaleCookie } from '@utils/cookies'
import { defineMiddleware } from 'astro/middleware'
import {
    getLocaleFromUrl,
    getPreferredLocale,
    getLocaleFromCookie
} from '@i18n/utils'

// ...

const i18nDetectLocale = defineMiddleware(async (context, next) => {
    const pathname = context.url.pathname

    const isApiRoute = pathname.includes('/api/')
    if (isApiRoute) {
        return next()
    }

    let possibleLocale = getLocaleFromUrl(pathname)
    if (possibleLocale) {
        setLocaleCookie(context, possibleLocale)
        return next()
    }

    possibleLocale = getLocaleFromCookie(context)
    if (possibleLocale) {
        setLocaleCookie(context, possibleLocale)
        return context.redirect(
            `/${possibleLocale}${context.url.pathname}${context.url.search}`
        )
    }

    possibleLocale = getPreferredLocale(context)
    setLocaleCookie(context, possibleLocale)

    return context.redirect(
        `/${possibleLocale}${context.url.pathname}${context.url.search}`
    )
})

// ...

Now that we've addressed internationalization, let's move on to another task: improving site security and patching up vulnerabilities.

HTTP security headers

I'm not a web security specialist, so I rely on publicly available services like csp-evaluator.withgoogle.com, securityheaders.com, and observatory.mozilla.org to find vulnerabilities. I acknowledge that these tools may not be sufficient to address all vulnerabilities, but they provide a solid starting point.

Modern browsers offer extensive support for various HTTP headers, which play a crucial role in enhancing web application security. These headers help protect against common attacks such as clickjacking and cross-site scripting. Let's use third-party services to find weak spots and patch them.

Using middleware is an excellent approach for configuring HTTP security headers. It allows us to intercept requests before they reach our application logic, which is precisely what we need. To achieve this, we should define the middleware and position it at the top of the sequence.

First, let's define the middleware securityHeaders, include it in sequence, and set our first security header Strict-Transport-Security. This header is responsible for enforcing HTTP Strict Transport Security (HSTS), which is a policy mechanism that ensures the use of TLS in compliant User Agents (UAs) like web browsers. HSTS ensures secure communication and mitigates man-in-the-middle attacks.

// src/middleware.ts

// ...

const securityHeaders = defineMiddleware(async (context, next) => {
    const response = await next();

    response.headers.set(
        "Strict-Transport-Security",
        "max-age=31536000; includeSubDomains"
    );

    // .. set other HTTP security headers here ..

    return response;
});

// ...

export const onRequest = sequence(
    // The order is IMPORTANT!
    securityHeaders,
    // ..
)

Step two: Set the X-Frame-Options header to protect visitors from clickjacking attacks. Valid values are: DENY (site can't be framed), SAMEORIGIN (frame your own site), and ALLOW-FROM https://example.com/ (specify permitted framing sites).

// src/middleware.ts → securityHeaders middleware
response.headers.set("X-Frame-Options", "SAMEORIGIN");

Step three: Set the X-Content-Type-Options header to "nosniff". It prevents Chrome and IE from incorrectly interpreting the content-type, reducing risks of drive-by downloads and mistaken content-type identification.

// src/middleware.ts → securityHeaders middleware
response.headers.set("X-Content-Type-Options", "nosniff");

Step four: Set the Referrer-Policy header to strict-origin-when-cross-origin value. It controls the referer header in links away from the site, sending the full URL for same-origin requests and only the origin for cross-origin requests. No information is sent during scheme downgrades (HTTPS to HTTP).

// src/middleware.ts → securityHeaders middleware
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

Step five: Set the Content Security Policy (CSP) header. It specifies the trusted sources of content that a browser can load. CSP serves as an effective countermeasure against Cross-Site Scripting (XSS) attacks and enjoys broad support, making it easy to deploy. The configuration varies from site to site. To provide a minimal example, I will demonstrate how to allow HTMX library.

// src/middleware.ts → securityHeaders middleware
response.headers.set(
    "Content-Security-Policy",
    "default-src 'self'; script-src 'self' https://unpkg.com/htmx.org@1.9.5; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; frame-src 'self';"
);

Last step: Set the Permissions-Policy header. It defines restrictions on website functionality, specifying what APIs can be accessed and modifying browser behavior for certain features. Enforces best practices and enhances security when integrating third-party content. By default, I disable almost all the features and only enable them if I need to.

// src/middleware.ts → securityHeaders middleware
response.headers.set(
    "Permissions-Policy",
    "accelerometer=(), ambient-light-sensor=(), autoplay=(self), battery=(), camera=(), display-capture=(self), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(self), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), local-fonts=(self), magnetometer=(), midi=(), payment=(), picture-in-picture=(self), publickey-credentials-get=(), screen-wake-lock=(), serial=(), speaker-selection=(), usb=(), web-share=(self), microphone=()"
);

By utilizing services like securityheaders.com, you can effortlessly identify and resolve most security vulnerabilities. Now, let's move on to our final topic: using HTMX with Astro.

HTMX with Astro

HTMX is an excellent tool for adding interactivity to your web pages without the need to write client-side JavaScript. In general, Astro and a bit of client-side JS are sufficient for creating impressive web applications. However, I particularly appreciate HTMX for its elegant solutions to various problems. The fee is small, only around 14k min.gz’d

The core idea behind HTMX is to challenge the commonly used approach in modern web applications. These days, applications typically communicate with a server using JSON, primarily for building the user interface on the client-side. However, HTMX takes a different approach by sending HTML from the server to the client instead of JSON. For more information, please refer to "Hypermedia as the Engine of Application State" (HATEOAS).

To start using HTMX with Astro, include a script tag that brings in HTMX.

<head>
    <script src="https://unpkg.com/htmx.org@1.9.5"></script>
</head>

While there is already a lot of material on using HTMX, here's a simple example. Imagine a user profile page where you can change user data. I'll make a request to the API endpoint, update the data, and return a response instructing the browser to reload the page. It may not be the optimal solution, but keeping this example simple and clear is my goal.

// src/pages/api/htmx/example.ts
import type { APIRoute } from 'astro'
import { Routes } from '@utils/routing'
import { getLocale } from '@i18n/utils'
import { isAuthorized } from '@utils/client'

export const POST: APIRoute = async (context) => {
    const locale = getLocale(context)

    const authorized = await isAuthorized(context)
    if (!authorized) {
        return new Response(null, {
            status: 403,
            headers: { 'HX-Redirect': `/?${Routes.LOGOUT_KEY}` }
        })
    }

    try {
        // await doSomeWork(context)
        return new Response(null, {
            status: 200,
            headers: { 'HX-Refresh': `true` }
        })
    } catch (err) {
        return new Response(null, {
            status: 504,
            headers: { 'HX-Redirect': `/${locale}` }
        })
    }
}

And that concludes our exploration of Astro. Now it's time to sum it up.

Wrapping up

Astro is an outstanding tool for website development. Personally, I heavily rely on it and it has become my preferred choice for building websites nowadays. Its unique developer experience (DX) ensures a remarkably smooth process of building and maintaining a website.

Furthermore, Astro provides the flexibility to build a wide range of web applications. The possibilities are almost limitless with Astro's versatility! I'd love to hear your thoughts on Astro. Have you used it in one of your own projects? Let me know in the comments section below.