Astro Hackathon Showcase

šŸ“” SSR ā­ļø Category Winner ā­ļø

passle-courses

by Pascal Schilp

GitHub Live Preview

README.md

Astro Hackathon winnerā€™s statuette

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.

Extra links

Description

Iā€™ve wanted to build this project for a long time now, the release of Astro SSR finally gave me the excuse/push to start building it. Blog here: https://dev.to/thepassle/trying-out-astro-ssr-astro-10-hackaton-3h0g

Lots to unpack in the blog - app showcase, my dev experience using Astro, and some feedback