Hybrid Frontend Architecture with Astro and Starlight

Building a fully-feature docs site with Astro and Starlight, including authentication and CMS support.

By Maxi Ferreira /

A couple of weeks ago, Ben Holmes joined me on stream to chat about application design and architecture with Astro. We looked at a fictitious (but realistic) project, and we implemented a quick prototype using Starlight, Astro’s official docs template.

This article recaps our discussion and expands our prototype to meet all of our imaginary (but again, totally reasonable) project requirements. If you’re looking to build a similar project, I hope you can use this write-up as inspiration for your own designs.

Let’s dive in 🏊‍♂️

The Project

The Project

Our challenge was to design a solution for a fully-featured documentation site similar to Stripe Docs. Astro is a perfect fit for building a docs site like this one, but since this project had some particularly advanced use cases, it was a great opportunity to learn more about how to implement them.

Here are the project requirements, which, as we mentioned, are completely made up but are based on what a real-life project might look like:

Wireframe of our site, showing some of the features we need to support
Some of the features our site needs to support

Our goal for the stream was to develop an MVP for this project. We weren’t looking to fully implement a solution, but we wanted to have a design that could be extended to meet all of the project requirements.

Initial Design

Initial Design

The first thing we needed to decide was whether to use Astro by itself or start the project with Starlight, Astro’s official template for building documentation sites.

Starlight seemed like a clear winner since it came with many of the features we needed for this project out-of-the-box: search, internationalization, support for Markdown, Markdoc, and MDX, and plenty of customization options.

However, one limitation of Starlight is that it only supports SSG (Static-Site Generation) at the moment, which is typically a deal-breaker when your site needs authentication or managing content via a CMS, as our project does. During the stream, Ben and I discussed ways around that limitation (more on that below), so we decided to go with Starlight for our MVP and figure out the missing pieces later.

As I was writing this article, Astro released v3.5 which includes experimental i18n routing. This would have made our decision a bit harder had it been available at the time of the stream, but I still think Starlight was a great choice, especially with how easy it was to get started with it.

Diagram showing the initial design of our docs site
Our project's initial design

Once we were happy with our design, we put it to the test by building a quick prototype. Thanks to Starlight’s built-in features, we went from a blank project to a docs site with articles in multiple languages and a fully functional search UI in a matter of minutes. We even had some time to implement some View Transitions, making our static docs site work just like an SPA—even though that wasn’t in the original requirements 😉

Fully Featured

Our initial design was a great start, but before we could put the approval seal on it, we needed to prove that we could extend it to meet all of our requirements, including the two missing pieces: authentication and CMS support.

So, after the stream ended, I spent some time playing with our prototype and exploring different ways to support these features.

CMS Support

CMS Support

Astro supports a wide range of headless CMS products, including Storyblok, Contentful, and many more. But one that works particularly well for our project is TinaCMS.

Tina is an open-source headless CMS that supports authoring content directly in our repository, so we can keep the experience of editing Markdown or MDX files directly while still providing a visual CMS for those who prefer it.

We can install Tina in our project using their CLI:

npx @tinacms/cli@latest init

The CLI will ask us a few questions about our site and create a few configuration files. Once it’s done, we need to define our docs schema in tina/config.ts, which should look something like this:

tina/config.ts
import { defineConfig } from 'tinacms'
 
export default defineConfig({
  ...
  schema: {
    collections: [
      {
        name: 'docs',
        label: 'Docs',
        path: 'src/content/docs',
        fields: [
          {
            type: 'string',
            name: 'title',
            label: 'Title',
            isTitle: true,
            required: true,
          },
          {
            type: 'string',
            name: 'description',
            label: 'Description',
          },
          {
            type: 'rich-text',
            name: 'body',
            label: 'Body',
            isBody: true,
          },
        ],
      },
    ],
  },
})

This basic setup only supports .md files in our content folder. To support .mdx files as well, we’ll need to do some additional work to register the available components. You can read more about how to do that here.

Once that’s done, we can start our project with npx tinacms dev -c "npm run dev" and we’ll have access to our CMS locally by appending /admin/index.html to our site’s URL.

Screenshot showing TinaCMS editing our site's content
TinaCMS in action (on the left), editing our Starlight's site content

This is the most basic of setups, but it works really well for our little MVP. If you’re looking for more advanced functionality, Tina has tons of customization options that let you deploy and run the CMS in production, enable visual editing, and much more. I highly recommend checking out their docs.

Authentication

Authentication

The key part of our auth requirement is that we don’t need to restrict access to non-authenticated users—all of our docs are publicly available, but authenticated users get some extra features (like using an API Playground to make actual requests using their account’s API keys.)

For this, we can take advantage of Astro’s hybrid rendering mode, which allows us to serve some of our content via SSG and some via SSR. This means we can continue using Starlight to build our static docs content and add any features that need an actual server (like authentication) via server-side rendering—all in the same project.

We can enable hybrid rendering in astro.config.mjs. We also need to provide an adapter for the type of server we’ll be using (e.g. Node, Cloudflare Workers, etc.)

astro.config.mjs
import { defineConfig } from "astro/config";
import nodejs from "@astrojs/node";
 
export default defineConfig({
  adapter: nodejs({
    mode: "middleware",
  }),
  output: "hybrid",
});

Now, our docs site content is still being generated at build time via SSG, but we can create routes in the pages directory that are generated at runtime by opting out of pre-rendering. For instance, this is how we could create a login page:

pages/login.astro
---
export const prerender = false;
 
// This will run on the server at request-time. We can read URL params,
// make API calls, query DBs, read and write sessions, etc.
---
 
<h1>Sign in</h1>
<form>...</form>
Diagram showing the design of our site with hybrid rendering enabled
Our project's design with hybrid rendering enabled

With hybrid mode enabled, we now have all the pieces in place to add authentication to our site. We have a few options here as well—we could use a third-party service like Auth0, install an authentication library, or even roll our own auth.

The approach that I chose for this project was to set up Lucia, an open-source auth library that handles all the complicated parts of authentication and integrates really well with Astro.

As a proof of concept, I set up Lucia on our site in its simplest form: username + password login, using an in-memory SQLite database to store user information (here’s a guide with instructions for how to do that.) This is a basic setup, but it could be extended to support a large number of OAuth providers like Google, GitHub, and Twitter, and to use a database of any kind.

Diagram showing how Lucia integrates into our hybrid rendering desing to provide authentication
Integrating Lucia and a user database for authentication

Any routes and pages we create in our project’s pages directory can access the current user’s session. Users can signup, login, logout, and access their profile like in any regular application.

The last piece of the puzzle is integrating our static docs pages with our dynamic backend. Our approach here is simple: we’ll create API endpoints in the pages directory that can respond with the logged-in user’s information, and we’ll make requests to these endpoints from client-side components (e.g., a React component) running on our static docs pages.

For instance, here’s an API route that returns the logged-in user’s username or a 401 status code if there’s no logged-in user:

pages/api/username.ts
import type { APIRoute } from "astro";
 
export const prerender = false;
 
export const get: APIRoute = async function (context) {
  const session = await context.locals.auth.validate();
 
  if (!session) {
    return new Response(JSON.stringify({ error: "Unauthozied" }), {
      status: 401,
    });
  }
 
  return new Response(
    JSON.stringify({
      username: session.user.username,
    }),
    {
      headers: {
        "content-type": "application/json",
      },
    }
  );
};

And here’s a client-side React component that makes a request to this endpoint when it renders on the page and shows the response to the user:

components/Username.jsx
import { useState, useEffect } from "react";
 
export default function Username() {
  const [username, setUsername] = useState(null);
 
  useEffect(() => {
    fetch("/api/username")
      .then((res) => res.json())
      .then((data) => setUsername(data.username));
  }, []);
 
  if (!username) {
    return <p>Loading...</p>;
  }
 
  return <p>Logged in as {username}</p>;
}

We can use this component anywhere we’d like, including the .mdx files in our content directory that are generated at build time by Starlight:

content/docs/tutorial.mdx
---
title: Tutorial
---
 
import Username from "../../components/Username.jsx";
 
<Username client:load />

With authentication in place, we have now met all of our project requirements. We have a fully-featured docs site powered by Starlight, CMS support for Markdown and MDX files via TinaCMS, and auth features thanks to Lucia. Here’s what our final design looks like:

Diagram showing final design of our docs site
Our project's final design

Design Limits

Design Limits

This design meets all of our requirements and it’s a great fit for our particular use case, but it has a few limitations that are worth talking about.

A git-based CMS like Tina works really well in this project, but if we ever wanted to use a more traditional CMS that saves content in a database, serving pages exclusively via SSG might become impractical. With content served from a database, we’ll probably have an easier time using server-side rendering instead.

Our auth strategy is also a big limitation. With our approach, showing any information about the logged-in user always requires a network request initiated from the client side. So even if we make those requests at page load, the initial state will always be an empty or loading state.

This is OK for our interactive playgrounds, but if we ever wanted to do something like showing the logged-in username in the header, it’ll probably result in a clunky UX where we’ll get a flash of empty state while the request resolves.

Also, if we end up with dozens of these interactive components on the page, each one will make its own request, which is again, not optimal. The alternative would be to batch these requests somehow, but that would add extra complexity that we just wouldn’t have in a full SSR mode.

With these constraints in mind, it is entirely possible that we might need to “eject” out of Starlight and adopt a full SSR approach rather than a hybrid one. Fortunately, this wouldn’t be a big migration—all of your building blocks (Astro components, Markdown files, API routes) will continue to work just the same in a plain Astro project.

Wrapping up

Wrapping up

If you’re looking for a fully-featured solution for your docs site that is easy to get started with and scales really well, Starlight is a fantastic choice. You’ll get a ton of powerful features out-of-the-box, powerful integrations, and plenty of customization options.

Thanks to projects like TinaCMS and Lucia, you can go beyond the limits of a traditional docs site to add CMS support and authentication as well. And if your project ever outgrows the limits of Starlight, migrating to a full Astro setup would be relatively straightforward.

Resources

Resources
Illustration of a woman thinking deeply about something

“A software architecture newsletter that won't put me immediately to sleep? Is that even possible?”

Well, there's only one way to find out!

Join 4,000+ developers getting the latest insights from the world of software design and architecture—tailored specifically to frontend engineers. Delivered right to your inbox every two weeks.

    “Maxi's newsletter is a treasure trove of wisdom for software engineers with a growth mindset. Packed with valuable insights on software design and curated resources that keep you at the forefront of the industry, it's simply a must-read. Elevate your game, one issue at a time.”

    Addy Osmani
    Addy Osmani
    Engineering Lead, Google Chrome