Astro 1.0 Hackathon submission

Overview
title published description tags cover_image
Trying out Astro SSR & Astro 1.0 Hackaton
false
astro, ssr, webcomponents, hackathon

Whew, it's been a hot minute since I blogged.

I'd played around some with Astro in it's early beginnings, and sadly I could never quite find another excuse to build something with it. Not until recently, when the Astro team released their new SSR mode. It seemed like a good time to give Astro another spin, and before I knew, I'd built half a course selling website.

In this blog I'll go over:

  • a short introduction to Astro SSR
  • my personal experience building a real application with Astro SSR
  • some of the issues I encountered in hopes of providing feedback for the official release
  • a showcase of the application I built

demo

Getting started

I started my project by running npm init astro@latest. Being myself a fan of minimal buildtooling, complicated project setups, and loads of configuration files, I was delighted to be greeted with a Minimal starter project being one of the options:

? Which app template would you like to use? › - Use arrow-keys. Return to submit.
    Starter Kit (Generic)
    Blog
    Documentation
    Portfolio
❯   Minimal

Hell yeah.

As promised, the Minimal starter gave me a super nice and clean project structure to get started with. Alright, now onto... What, exactly? Having little experience with Astro, and not being quite sure how one does a SSR, I figured I'd checkout some documentation. At the time of the release, not much documentation was available. There was the announcement blog, a very brief documentation page, and a Netlify blog. Slim pickings, so I figured I'd follow the Netlify blog, because it seemed straight forward enough to follow, and I've used Netlify very happily in the past.

As instructed, I installed the required dependencies:

npm i -S @astrojs/netlify

Updated my config:

import { defineConfig } from 'astro/config';
+ import netlify from '@astrojs/netlify/functions';

export default defineConfig({
+  adapter: netlify()
});

Ran npm start and...

error Invalid URL

Ran into an error.

The pain of the bleeding edge

issues

Ah, the pain of being on the bleeding edge. When trying out beta versions of projects, it's only natural to run into some issues here and there; in fact, its the entire point. Projects are able to gather valuable feedback from the community and catch issues early, and for users it's a great way to learn about new features. During the course of this project I ran into several issues, that will hopefully constructively contribute to the official stable release.

In the case of the Invalid URL; the Netlify blog failed to mention it, but apparently you're supposed to configure your site property in the astro.config.mjs, e.g.:

import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify/functions';

export default defineConfig({
+ site: 'https://example.com',
  adapter: netlify()
});

This was not straightforward to me, because at the time of scaffolding the project, I did not have a site yet; I'd barely started building it! Fortunately, the error message has since been made more user friendly.

Too many cookies are bad for you

Another small issue I ran into, when trying to set multiple cookies, is that only one seemed to make it to the browser for some reason.

Fortunately, the Astro discord has a very lively community where even core team members are around to help, so I created a Github issue, and the issue was very quickly resolved and released. (Thanks, Matthew!)

Custom elements always render undefined

Later on in the project, I discovered another bug when trying to render a custom element, e.g.: <my-el></my-el> would always render <my-el>undefined</my-el> in the browser, wether the custom element was upgraded or not. I created an issue for this problem here. Fortunately I was able to work around this with a super hacky solution 😄

connectedCallback() {
  this.innerHTML = '';
}

Request body unavailable after deploy

Another slightly more painful issue that I ran into, was that I found my request bodies to be unavailable only after building and deploying to Netlify. This is a problem, because my authentication and other routes depend on redirect URIs being able to handle data in the request bodies. I again created another Github issue, with a small reproduction.

SSR it up

Alright, let's dive into some of the nice features that Astro SSR comes with. There are two ways you can respond to requests:

  • index.astro
  • index.js

Using a .astro file, you can use your frontmatter to execute code on the server, and then return the HTML in your template:

[pokemon].astro:

---
// Code in the frontmatter gets executed on the server
// Note how we have access to the `fetch` api, as well as top level await here

const pokemon = await fetch(`https://pokeapi.co/api/v2/pokemon/${Astro.params.pokemon}`).then(r => r.json());
---
<html>
  <body>
    <h1>{pokemon.name}</h1>
  </body>
</html>

Or using a .js file, we can create a route handler, e.g.:

[pokemon].js:

export async function get({pokemon}, request) {
  const pokemon = await fetch(`https://pokeapi.co/api/v2/pokemon/${pokemon}`).then(r => r.json());

  return new Response(null, {
    status: 200,
    body: JSON.stringify({ pokemon })
  });
}

Note that the first argument here is the route param, which is taken from the filename: [pokemon].js. If you're using a 'regular' filename, e.g.: pokemon.js, this will be undefined. The second argument is a regular Request object, that you can use all your familiar methods on, e.g.: await request.json(), for example.

Route handlers are also useful for handling redirects, settings cookies, etc:

export function get() {
  const headers = new Headers();

  headers.append('Set-Cookie', 'foo="bar";');
  headers.append('Location', '/');

  return new Response(null, {
    status: 302,
    headers,
  });
}

Feedback

Unused route params

As mentioned above, the first argument that gets passed to your route handlers are the route params. However, if you're not using route params, it will be undefined. In my project, I've had to build quite a few route handlers, and I found I used the route param very rarely, which then kind of makes for an awkward function signature:

// Always have to ignore the first arg :(
export function get(_, request) {}

Environment variables

Another point of feedback: If you have an .env file in your project, you can access them in your astro files by using import.meta.env. This only seems to work for code that gets executed on the server, however. It doesnt seem to work for code that gets executed in the browser, for example, it would have been nice to have been able to make use of those env variables for initializing the Sign In With Google button:

---
// some frontmatter etc
---
<html>
  <body>
    <script>
      google.accounts.id.initialize({ 
        // wont work :(
        client_id: import.meta.env.GOOGLE_CLIENT_ID, 
        login_uri: `${import.meta.env.APP_URL}/auth/success`
        ux_mode: "redirect", 
      });
    </script>
  </body>
</html> 

Update: It has since been brought to my attention that environment variables prefixed with PUBLIC_ are exposed to the client. Alternatively, I could have used the define:vars Template Directive.

Redirect with error message

In some of my route handlers, I have to handle quite some error cases. Instinctively, I tried to do something like:

export function get() {
  try {
    // error prone code 
  } catch {
    return new Response(JSON.stringify({message: 'something went wrong'}), {
      status: 302,
      headers: {'Location': '/error'}
    });
  }

  // etc
}

Not by the fault of Astro, but rather the HTTP spec, this won't work. After discussing how to handle this situation gracefully on the Astro discord, Matthew suggested perhaps introducing Astro.flash:

flash

For the time being, I implemented my error redirections like this:

return new Response('', {
  status: 302,
  headers: {'Location': '/error?code=SOME_ERR_ENUM'}
});

Where, in error.astro's frontmatter, I map the SOME_ERR_ENUM to a helpful error message for the user.

This also brings me to another point: In the frontmatter of .astro files, there is an Astro global available that you can use to, for example, redirect: return Astro.redirect('/');. As far as I can tell, the Astro global is not available in route handlers. It would be really nice to have access to Astro.flash or Astro.redirect in route handlers. E.g.:

route.js:

export function get() {
  try {
    // error prone code
  } catch {
    return Astro.flash('/error', 'error message');
  }

  return Astro.redirect('/success');
}

Middleware

The biggest missing piece of the puzzle however is middleware. Several times I found a need for middleware, but being unaware how to achieve something like it. For example, there were certain assets that I only want to be served when the user is authenticated, and was hoping I could do something like:

/protected/[...assets]/index.js:

import { isLoggedIn } from '../../utils/auth.js';

export async function get(_, request) {
  const { authed } = await isLoggedIn(request);

  const url = new URL(request.url);
  const protectedRoutes = new URLPattern({pathname: '/protected/:image'});
  const match = protectedRoutes.exec(url);

  // We have a match, this is a 'protected' asset
  if(match) {
    if(authed) {
      // If user is authenticated, pass the request along
      return fetch(request);
    } else {
      // If user is not authenticated, forbidden
      return new Response(null, {status: 403});
    }
  }
  
  // request didnt match any protected assets, pass it on as normal
  return fetch(request);
}

Or perhaps something like:

export const get = [
  authMiddleware,
  async (_, request) => {
    // route handler etc
  }
];

But this turned out not to be possible yet. Hopefully something like this will be implemented in the future, I created a RFC here.

rfc

And just for the time being, (and mostly just for fun), I created a little package: https://github.com/thepassle/astro-router

/sales/[...all]/index.js:

import { router } from 'astro-router';
import { auth, logger } from './middleware.js';
import { User, Order } from './db.js';

export const get = router({
  routes: [
    {
      path: '/sales/:user/:order',
      middleware: [logger, auth],
      response({params}) {
        const user = await User.findOne({id: params.user});
        const order = await Order.findOne({id: params.order});

        return new Response(null, {status: 200});
      }
    }
  ]
})

App Showcase: Course Selling Site

Alright, enough technicality, let's take a look at the course selling website I built using Astro SSR. I've been wanting to find a nice way to create and sell some online courses, and I've been lowkey looking for a way to start doing this. This has been something thats been in the background of my mind for a while now, and Astro SSR seemed like a nice excuse for me to take some time to dive into this.

In order to build this course selling website, I used the following technologies:

At the same time, this would be a cool project to show off Astro SSRs features, such as:

  • Dynamic routes
  • Authentication
  • Route handling
  • Redirections

Homepage

homepage

Authentication

Authentication

For authentication I used google-auth-library which was a massive pain to work with, find any information about, and use. However, once I finally had my authentication set up, I was able to add some nice handling for my protected pages. For example, I only want authenticated users, and users that have an active subscription to be able to access the course material.

chapter-1.astro:

---
import { isLoggedIn } from '../pages/utils/auth.js';
const { authed, active } = await isLoggedIn(Astro.request);

if(!authed && !active) {
  return Astro.redirect('/');
}
---
<html>
  <!-- course content -->
</html>

Subscribing

subscribing

For subscriptions, I used Mollie as a payment processor. First of all, it has to be said that Mollie's API documentation is ridiculously good. I used the Mollie API to create a payment, and then used Mollie webhooks to handle the payment status updates. I also used webhooks to the handle recurring payments. Their super clear documentation made implementing this a breeze.

Mollie API

Mollie ships their own Node API client, but I used the API directly.

To create a subscription for a user with recurring payments I do the following:

  • Get the currently logged in user from the database, and see if they already have a mollieId
    • If they dont have a mollieId, I have to create a Mollie Customer, which will give me a mollieId that I then save on the user from the dabase
  • I then create a Mollie Payment using the mollieId
  • I then also create a special ActivationToken, and store it in my database, that will automatically expire in time. The reason is that the Mollie Payment will lead to a redirect URI, where I activate the user's subscription account. If somebody was to find out the redirect URI, they could just navigate to that url and get a free subscription. Checking to see if an ActivationToken exists, however, prevents this from happening.

mollie

This is mock data

Dynamic routing

This is also where I get to highlight a nice Astro SSR feature: route params. Before making the payment, I create a unique ActivationToken, that I use as part of my Mollie redirectUrl, e.g. ${import.meta.env.APP_URL}/mollie/${token}/cb.

I can now use Astro's route params to handle this:

/mollie/[token]/cb.js:

export async function get({token}, req) {
  const activationToken = await ActivationToken.findOne({token});

  if(!activationToken) {
    return new Response(null, {
      status: 302, 
      headers: {
        'Location': '/error?code=INVALID_ACTIVATION_TOKEN'
        }
      });
  }

  // Token is valid, we can activate the user
}

If the ActivationToken is valid, I can now activate the subscription on the user object in the database, which gives them access to the protected routes of my app, that contain the course content.

Testing webhooks

webhooks

As a fun little aside, Mollie's server naturally isn't able to send requests to my webhook handler when I'm running my application locally. So to work around this I hacked together a little mock API page that posts messages to my webhook handler, that I can then use to mock any requests and overwrite the transaction:

webhook-test.astro:

---
if(import.meta.env.ENV !== 'dev') {
  return Astro.redirect('/');
}
---
<html>
  <!-- buttons etc -->
</html>

And in my webhook handler:

if (import.meta.env.ENV === 'dev' && body?.mock) {
  transaction = {
    ...transaction,
    ...body.mock
  }
}

Unsubscribe

unsub

Course content

The course content consists of two different parts: theory, and interactive exercises. For the interactive exercises, I used Lit, monaco-editor and typescript. Arguably, I didnt really need Lit for this part, but I'm productive with it, so it was the easy choice.

The way I load the course content again makes nice usage of Astro SSR's dynamic routing, and I was even surprised to learn that we have access to new features like URLPattern in Astro! Using the following structure: /sw/[...i]/index.astro (note the ...) will essentially act as a catch-all, and will match any request under /sw/, so /sw/foo but also /sw/foo/bar.

I can then use a URLPattern to extract the chapter and the lesson:

/sw/[...i]/index.astro:

const urlPattern = new URLPattern({pathname: '/sw/chapter/:chapter/lesson/:lesson'});
const match = urlPattern.exec(new URL(Astro.request.url));
const { chapter, lesson } = match.pathname.groups;

const currentLesson = courseIndex[chapter].lessons[lesson];

I also make sure the user is authenticated, and has an active subscription, and then I render the corresponding content page: either <Theory/> containing some markdown or an <InteractiveExercise/>. I also pass along some additional information about the current lesson, like a title, but also some information about the next lesson.

demo

The interactive exercises use Typescript to create an AST of the code that gets input by the user, and then I do some static analysis on the code to verify wether or not the user has completed all the tasks in the exercise:

function isServiceWorkerRegisterCall(ts, node) {
  if (
    ts.isCallExpression(node) &&
    ts.isPropertyAccessExpression(node.expression) &&
    node?.expression?.expression?.expression?.getText?.() === 'navigator' && 
    node?.expression?.expression?.name?.getText?.() === 'serviceWorker' &&
    node?.expression?.name?.getText?.() === 'register'
  ) {
    return true;
  }
  return false;
}

export const validators = [
  {
    title: 'Register a service worker',
    validate: ({ ts, node, context }) => isServiceWorkerRegisterCall(ts, node)
  },
  {
    title: 'Register "./sw.js"',
    validate: ({ts, node}) => {
      if(isServiceWorkerRegisterCall(ts, node)) {
        if (node?.arguments?.[0]?.text === './sw.js') {
          return true;
        }
      }
      return false;
    }
  }
]

Since monaco-editor and typescript (even when bundled) are fairly large files, I also whipped up a simple service worker to cache these large files, and make sure performance stays good:

// etc

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHENAME).then((cache) => {
      return cache.addAll([
        './monaco-editor.js',
        './ts.worker.js',
        './typescript.js',
      ]);
    })
  );
});

Server rendered web components?!

I also added some interactivity in the form of quizzes, by using server rendered web components. Using Astro's SSR integration for Lit made this super easy:

import lit from '@astrojs/lit';

export default {
  // ...
  integrations: [lit()],
}

quiz

Admin Panel

Finally, I created an admin panel to easily be able to see how many active subscriptions there are, as well as get the status and information for a user's subscriptions and payments.

To do this, I again made use of dynamic routing, e.g.:

  • /admin/index.astro -> lists users, with links to specific users
  • /admin/[user].astro -> user details

admin admin-details

Conclusion

Working with Astro SSR has a been a blast. Being mostly a frontend developer, it was nice to get out of my comfort zone a little bit and do more server-side work. As an added nice result, I found that I barely ended up using any client side JS for the most part of the site, but just HTML and CSS. Obviously the interactive editor uses some client side JS, but thats only a small part of the application. Additionally, Astro SSR was very straightforward to pick up, start using, and be productive with. Before I realized it, I had half the course selling site put together, and I'm really glad that I did.

Currently the project is a minimum viable product, and all the features (like recurring payments, etc) are actually functional — not 'faked', but I'll be working more on it over time to polish it, improve styling, and add more features. Trying out Astro SSR was just the push I needed to get started on building it, however.

Astro 1.0 Hackathon

Oh, and one more thing...

This is also my submission to the Astro Hackathon! I'll be wrapping up some things here and there, improve some styling etc, and then deploy the app to Netlify and open source the code on Github.

You might also like...

🖼️ Bringing Material Design 3 to the Astro Blog. [WIP]

🖼️ Gumori You [WIP] Bringing Material Design 3 to the Astro Blog. 👥 Contributing If you're interested in contributing to Gumori You, pls read the fo

Oct 16, 2022

🚀 A (still experimental) Lyra integration for Astro

Lyra's Astro Plugin This package is a (still experimental) Lyra integration for Astro. Usage Configuring the Astro integration // In `astro.config.mjs

Dec 13, 2022

Render arbitrary Markdown content in Astro, optionally integrating with any existing configuration.

Astro Markdown Astro Markdown lets you render arbitrary Markdown content in Astro, optionally integrating with any existing configuration. --- import

Dec 22, 2022

A plugin for creating hierarchical navigation in Astro projects. Supports breadcrumbs too!

astro-navigation A plugin for creating hierarchical navigation in Astro projects. Supports breadcrumbs too! Full docs coming soon! Basic usage This pa

Dec 19, 2022

End-to-end typesafe APIs in Astro wesbites made easy

End-to-end typesafe APIs in Astro wesbites made easy

Astro x tRPC 🚀 End-to-end typesafe APIs in Astro wesbites made easy View Demo · Report Bug · Request Feature 👋 Introducing astro-trpc astro-trpc is

Dec 30, 2022

Type-safe session for all Astro SSR project

Astro Session Why use Astro Session? When building server application with Astro, you will often need session system to identify request coming from t

Dec 19, 2022

This project aims for Road to web3 Hackathon powered by Polygon

BlogStream A blog site where users directly pay the writers for only what they are reading This is a project created for Road to Web3 hackathon by Web

Sep 12, 2022

Svaasthy project - a part of the Electrothon-4.0 Hackathon

Svaasthy project - a part of the Electrothon-4.0 Hackathon

Svaasthy View the presentaton The problems we address... The covid-19 crisis is drawing attention to the already overburdened public health systems in

Jun 16, 2022

Our project for The Microsoft Azure Trial Hackathon on Dev.to

Our project for The Microsoft Azure Trial Hackathon on Dev.to

Moodflix your mood, our suggestions. 🎯 About Overview of our project We have started this project with the purpose of participating to the Microsoft

Dec 22, 2022
Owner
Pascal Schilp
Front-end Developer at ING
Pascal Schilp
womenify - Submission for HackViolet '22

?? Inspiration There are several websites dedicated to fashion, beauty, health care, and other topics. However, there is no dedicated website for wome

Sahil Jain 2 Feb 13, 2022
Calculates dependencies for a Go build-target and submits the list to the Dependency Submission API

Go Dependency Submission This GitHub Action calculates dependencies for a Go build-target (a Go file with a main function) and submits the list to the

GitHub Actions 33 Dec 7, 2022
This is for homework submission of Filecoin Chinese Education Series - Coding with Filecoin.

Coding-with-Filecoin-Homework 课程简介 随着互联网和大数据技术的发展,我们正愈发依赖中心化的服务来存储和处理相关数据。但这背后有两个潜在的问题:用户不能完全控制自身数据的使用与传播,且很难验证公开数据的完整性与可靠性。为了解决这两个问题,新一代的协议和点对点网络已经问世

IPFS & Filecoin 15 Jul 14, 2022
Using Webpack and external API, this website saves and shows players' scores and allows the submission of new scores.

Microverse Students Leaderboard Microverse Students Leaderboard project that displays scores submitted by different students. All data is preserved in

Romina Patiño 5 Aug 19, 2022
👌A useful zero-dependencies, less than 434 Bytes (gzipped), pure JavaScript & CSS solution for drop an annoying pop-ups confirming the submission of form in your web apps.

Throw out pop-ups confirming the submission of form! A useful zero-dependencies, less than 434 Bytes (gzipped), pure JavaScript & CSS solution for dro

Vic Shóstak 35 Aug 24, 2022
A speedrun event submission manager.

split-decision This is a Next.js site for managing speedrun marathon event submissions. It does these things: Allow users to authenticate via Discord,

May 5 Oct 31, 2022
A tiny script and component intended to be used with Astro for generating images with eleventy-img.

Astro + eleventy-img A tiny script and component intended to be used with Astro for generating images with eleventy-img. It also supports creating blu

Erika 36 Dec 16, 2022
🦔 AstroJS GoogleChromeLabs critters integration. Inline your critical CSS with Astro.

astro-critters ?? This Astro integration brings critters to your Astro project. Critters is a plugin that inlines your app's critical CSS and lazy-loa

Nikola Hristov 33 Dec 11, 2022
Make an Astro site with content from Notion (and more)

Astronotion ⚠️ It is strongly recommended to upgrade to 0.0.7 (what has been fixed?) npm install -D astronotion@latest (or other package managers' equ

Eka 14 Dec 19, 2022
Auto-import components in Astro projects

Astro Auto Import ?? Looking for the main package? Jump to astro-auto-import → ?? Project Structure This project uses workspaces to develop a single p

Chris Swithinbank 22 Dec 30, 2022