Skip to content

Astro

From their site:

Astro is the web framework for building content-driven websites like blogs, marketing, and e-commerce.

Note that “content-driven” is as opposed to a web application, e.g. social networks, TODO lists, dashboards, etc (reference). It’s not that Astro can’t make non-content-driven sites, it just apparently makes some trade-offs so that the focus is for content-driven sites.

Other frameworks might use a single-page app (SPA) approach, but Astro uses a multi-page-app (MPA) approach.

Astro uses a new front-end architecture called “islands”. An island refers to any interactive UI component (as opposed to static UI) on the page (reference). Islands are marked by the use of client:* directives (reference, all client directives). In other words, they’re components that require scripting to work, and the directives tell Astro when to send the JavaScript to the client.

  • Just follow the tutorial
    • Note: if you already know Astro and just want to add it to a new project, start with this page (I use this command, which even creates package.json: pnpm create astro@latest -- --template with-tailwindcss). The target directory needs to be empty.
    • Maybe install the VSCode extension before that if you want
  • Starting a project based on a template (reference)
    • A template is basically just any folder or repo that follows the Astro folder/naming conventions. The official list is here. You can easily create a project from an official template with pnpm create astro@latest -- --template TEMPLATE_NAME (where TEMPLATE_NAME is something like with-tailwindcss).
  • Folder structure (reference)
    • The reference link is good, so I’ll just highlight some things here
    • /
      • Config files (like astro.config.mjs, tsconfig.json, package.json)
      • public
        • Assets like favicon.svg, fonts, etc. e.g. for use from astro files
      • src
        • components, layouts, pages, styles, etc.
        • lib or libs: this isn’t an “official” convention, but it’s commonly the location for any TypeScript you need, e.g. for API endpoints.
  • Change the port that Astro listens on
    • Modify astro.config.mjs:
import { defineConfig } from "astro/config"
export default defineConfig({
server: { port: 3000, },
})

Example using variables, conditional rendering, and a .map call:

---
// Note: none of this JS/TS is sent to the client
const colors = ["red", "green", "blue"];
const renderColors = true;
---
<html lang="en">
<body>
{
renderColors && (
<ol>
{colors.map((color) => (
<li>{color}</li>
))}
</ol>
)
}
</body>
</html>

Specifying types for Astro.props:

---
interface Props {
platform: string;
username: string;
}
const { platform, username } = Astro.props;
---
<a href={`https://www.${platform}.com/${username}`}>{platform}</a>

Slots (reference) are how you pass children to different components:

Layout.astro
<div>
<Header />
<h1>Title goes here</h1>
<slot /> <!-- children will go here -->
<Footer />
</div>
// UserOfLayout.astro
---
import Layout from './Layout.astro';
---
<Layout>
<h2>This will be rendered in place of the slot above</h2>
<p>(this too)</p>
</Layout>

Passing variables from frontmatter to a <script> tag

Section titled Passing variables from frontmatter to a &lt;script&gt; tag

There are two ways to do this:

  • Use data-* attributes (reference).
    • If you don’t want to define a custom element, then you could do something like this:
<span style="display: none" id="data" data-message={message}></span>
<script>
console.log(document.getElementById("data")?.dataset.message)
</script>
  • Use define:vars (reference)
    • (I don’t recommend using this over data-* attributes)
    • Use of define:vars implies the use of is:inline, so you may hit this issue.
      • Also, if you’re going to use define:vars, don’t import scripts from npm since that code won’t be bundled.
  • Could also use a UI framework like React and pass the commands as props to the framework itself

There’s no reason not to use these UI frameworks, but if all you really need is a click handler, you can accomplish this with a custom element (reference).

The tutorial has you create a set of .md files that represent blog posts. This is a great use for content collections. They’re raw content (typically .md, .mdx, .json, etc.) that can be validated for types, length, and other properties of the data itself using Zod.

This is why you’ll rarely find Astro.glob calls in production code; collections are used instead.

Server endpoints, AKA “API routes”, are endpoints that are built when they’re requested. To do this, you need an adapter, which is what tells Astro how to actually do the building (e.g. “use Node” vs. “use Netlify’s serverless functions”).

To use it:

  • Write the endpoint itself:
src/pages/helloworld.txt.ts
export async function GET({ params }) {
// The "random" makes it dynamic (◕‿◕✿)
return new Response("hello world: " + Math.random(), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
}
  • Add the adapter: pnpm astro add node → follow the instructions that print out
    • (you can do pnpm astro add --help to see all possible adapters)
    • The node adapter’s documentation is here
  • Choose which default you want: on-demand rendering (output: "server") or static rendering (output: "hybrid") and modify astro.config.mjs with that option.

You can generate a file at build time by naming a file pages/FILE.EXT.ts, where the .ts is going to be stripped from the URL that a user needs to type. E.g.:

src/pages/test.txt.ts
// Navigating to localhost:3000/test.txt will return "hello world"
export async function GET({ params, request }) {
return new Response("hello world");
}

This recipe is very straightforward and easy to understand. Everything takes place in just a single file. Make sure that you have server-side rendering enabled.

---
const errors = {}; // the name "errors" is not important; it could be named anything
if (Astro.request.method === "POST") {
try {
const data = await Astro.request.formData();
const fruit = data.get("fruit");
if (fruit !== "apple") {
errors.message = "That is a gross fruit";
} else {
errors.message = "That is a good fruit";
}
} catch (error) {
throw error;
}
}
---
<h1>Tell me about your favorite fruit</h1>
<form method="POST">
{errors.message && <p>{errors.message}</p>}
<label>
Favorite fruit:
<input type="text" name="fruit" required />
</label>
<button>Submit</button>
</form>

Make the comment like this:

{/* eslint-disable-next-line astro/jsx-a11y/no-autofocus */}
<input type="text" name="command" required autofocus />

The <!---style comments will be emitted into the client-side HTML.

Rather than using the define-vars directive as discussed in the tutorial, it’s more common to just use CSS variables directly. The define-vars bit is really pulling values from JavaScript that isn’t ever sent to the client.

Example CSS:

// (NotesComponent.astro)
// CSS can appear before or after any HTML elements defined
<a href="https://notes.adamlearns.com/">Notes</a>
// This CSS is scoped to this component
<style>
a { background-color: #4c1d95; }
a:hover { background-color: #7050a0; }
</style>

Note that this should probably be separated into its own note if this gets big enough.

  • Lucia is an authentication library for TypeScript. It aims to abstract away the complexity of authentication.
  • It’s pronunced “loo-shya” (it says at the bottom of this page
  • Note that v3 is very different from v2. v2 is now old news.
  • Follow this guide for Astro
  • Install stuff
    • pnpm install lucia@beta oslo
    • pnpm install @lucia-auth/adapter-postgresql@beta
    • pnpm install pg @types/pg
  • Make a database
    • pgcli foo -p 5432 -h localhost -u postgres
    • create database learning_lucia
  • Create schemas
    • Just copy/paste these (you may need to do them one at a time)

VSCode doesn’t autocomplete as you’d expect

Section titled VSCode doesn’t autocomplete as you’d expect

Assuming you have the VSCode extension, this is typically just a result of needing to restart the extension host (which you can do through the command palette).