Introduction ๐Ÿ‘‹

To provide some context, in 2022, I used Hugo as the static site generator for my blog.

In 2023, I conducted a technological review of various static site generators. This led me to adopt Astro, and I wrote an article about it.

In this blog post, Iโ€™ll show you how I organized my code and share my workflows.

From Hugo to Astro ๐Ÿ’ซ

I chose to migrate from Hugo to Astro to gain more control over my blog.

While Hugo is excellent for websites with extensive content like documentation, my needs were different: I wanted to build a comfortable site that reflected my personality.

I preferred Astro for several reasons:

  • Hugoโ€™s syntax can be cumbersome: I found the syntax of shortcodes to be quite awkward, especially for generating diagrams. I prefer not to use custom syntax for my needs, as it would make migrating from Hugo more difficult in the future, requiring me to rewrite my content.
  • Astro is built with JavaScript and ViteJS. I am very familiar with these technologies. They are mature, highly customizable, and supported by a large community. Astro also supports TypeScript, a tool I love to use too! This stack gives me 100% control over how my blog is built.

Folder structure ๐Ÿ› ๏ธ

Letโ€™s dive into my code organization ๐Ÿ˜ฎ ! The structure I use looks like this:

.
โ”œโ”€โ”€ .astro/
โ”œโ”€โ”€ .git/
โ”œโ”€โ”€ dist/
โ”œโ”€โ”€ node_modules/
โ”œโ”€โ”€ public/
โ”œโ”€โ”€ src/
โ”‚ย ย  โ”œโ”€โ”€ assets/
โ”‚ย ย  โ”œโ”€โ”€ components/
โ”‚ย ย  โ”œโ”€โ”€ content/
โ”‚ย ย  โ”œโ”€โ”€ functions/
โ”‚ย ย  โ”œโ”€โ”€ pages/
โ”‚ย ย  โ”œโ”€โ”€ plugins/
โ”‚ย ย  โ””โ”€โ”€ env.d.ts
โ”œโ”€โ”€ .env
โ”œโ”€โ”€ .env.example
โ”œโ”€โ”€ .gitignore
โ”œโ”€โ”€ .prettierignore
โ”œโ”€โ”€ .prettierrc.mjs
โ”œโ”€โ”€ Makefile
โ”œโ”€โ”€ README.md
โ”œโ”€โ”€ astro.config.ts
โ”œโ”€โ”€ package-lock.json
โ”œโ”€โ”€ package.json
โ”œโ”€โ”€ tailwind.config.cjs
โ””โ”€โ”€ tsconfig.json

Public directory (./public/)

This directory contains all the files I want to push to the server without any processing.

.
โ”œโ”€โ”€ .htaccess
โ”œโ”€โ”€ robots.txt
โ””โ”€โ”€ pdf/

Assets (./src/assets/) ๐Ÿ–ผ๏ธ

This directory contains all the compiled assets, such as images and icons.

.
โ””โ”€โ”€ img/
โ”œโ”€โ”€ hero.svg
โ”œโ”€โ”€ icon.png
โ”œโ”€โ”€ icon.svg
โ”œโ”€โ”€ profile.jpg
โ””โ”€โ”€ icons/

Components (./src/components/) ๐Ÿงฉ

This directory contains all the components of the website.

I use atomic design to organize my components.

.
โ””โ”€โ”€ components/
ย ย  โ”œโ”€โ”€ atoms/
ย ย  โ”œโ”€โ”€ molecules/
ย ย  โ”œโ”€โ”€ organisms/
ย ย  โ”œโ”€โ”€ templates/
ย ย  โ””โ”€โ”€ index.ts

This is why I donโ€™t have a ./src/layouts/ directory, even though it is recommended in the documentation. Instead, all my layouts are in the ./src/components/templates/ directory.

Youโ€™ll also notice an index.ts file, which I use to re-export all the components. And I avoid using export default to keep the variable names clean.

I style my components with TailwindCSS.

I donโ€™t have CSS files to build my blog. Everything is managed with TailwindCSS, and I am very happy with it! The documentation recommends having a ./src/styles/ directory, but I donโ€™t need it.

Note: to use components with dark mode, I have configured Tailwind like this:

tailwind.config.cjs
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
darkMode: "class",
theme: {
extend: {},
},
plugins: [
require("@tailwindcss/typography"),
require("@tailwindcss/aspect-ratio"),
require("@tailwindcss/forms"),
],
};

Next, I add the dark class to the <html> element. This enables dark mode without needing any contexts or global variables (we reduce dependencies this way)!

Translations (I donโ€™t have any specific directory ๐Ÿ˜‹) ๐Ÿˆ‚๏ธ

To set up internationalization (i18n), I followed this recipe and used the Astro APIs directly.

I forced the URLs to include the language, like โ€œ/en/about-meโ€.

If users navigate to โ€/โ€, they will be redirected to the URL โ€œ/en/โ€.

To achieve this, I use this code:

./src/pages/index.astro
---
import { getAbsoluteLocaleUrl } from "astro:i18n";
const url = getAbsoluteLocaleUrl("en");
---
<!doctype html>
<html lang="en">
<head>
<title>Redirection to {url}</title>
<link rel="canonical" href={url} />
<meta name="robots" content="noindex" />
<meta charset="utf-8" />
<meta http-equiv="refresh" content={`0; url=${url}`} />
</head>
</html>

Note: Since I use Apache with a .htaccess file, I added a 301 redirect header like this:

<IfModule mod_rewrite.c>
RewriteEngine On
RedirectMatch 301 "^/$" "https://www.alexandre-hublau.com/en/"
</IfModule>

I donโ€™t have a specific /i18n/ directory for translations. Instead, I use a function to print the correct translation like this:

./src/pages/[locale]/posts/index.astro
---
const { translate } = useTranslation(Astro.currentLocale);
---
<div>
{translate({en: "Hello !", fr: "Salut !", de: "Hallo !"})}
</div>

This approach avoids managing files with numerous key/value translations, as described here.

Content (./src/content/) ๐Ÿ“

This directory contains all the siteโ€™s content, stored as md or mdx files.

Astro natively manages content.

I use these conventions/tools:

  • Content files are named with a format like โ€œ2024-08-22-my-slug.md(x)โ€. Example:
.
โ”œโ”€โ”€ 2021-09-25-technological-watch-react-js.mdx
โ”œโ”€โ”€ 2021-10-12-technological-watch-alpine-js.md
โ”œโ”€โ”€ 2021-10-15-technological-watch-ansible.mdx
โ”œโ”€โ”€ 2021-10-31-code-share-docker-apache-mysql-php.md
โ”œโ”€โ”€ 2021-11-21-technological-watch-cassandra.md
...

I use this file naming convention because it automatically sorts posts by date when I look in the file explorer, ensuring the newest content is always at the bottom. However, it has a drawback: releasing two articles on the same day can disrupt the order.

  • I use Expressive Code to render code blocks
  • I use Mermaid to render diagrams
  • Content is typed with TypeScript. You can generate types with Astro with the command: astro sync. You can find more information in the documentation

Everything is versioned with Git as part of the source code!

Functions (./src/functions/) โš™๏ธ

All the functions are implemented in TypeScript, which provides strong typing to minimize errors and prevent bugs. Astroโ€™s type-checking (astro check) generates reports for type errors, enhancing code reliability. Additionally, TypeScript can identify breaking changes introduced by package updates, ensuring smoother maintenance and updates.

This directory contains all functions shared across components. It is organized by type.

.
โ”œโ”€โ”€ index.ts
โ”œโ”€โ”€ date/
โ”‚ย ย  โ”œโ”€โ”€ calculateYearInterval/
โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ calculateYearInterval.test.ts
โ”‚ย ย  โ”‚ย ย  โ””โ”€โ”€ calculateYearInterval.ts
โ”‚ย ย  โ””โ”€โ”€ isDateWithinDays/
โ”‚ย ย  โ”œโ”€โ”€ isDateWithinDays.test.ts
โ”‚ย ย  โ””โ”€โ”€ isDateWithinDays.ts
โ”œโ”€โ”€ hooks/
โ”‚ย ย  โ”œโ”€โ”€ useTranslation.test.ts
โ”‚ย ย  โ””โ”€โ”€ useTranslation.ts
โ”œโ”€โ”€ post/
โ”‚ย ย  โ”œโ”€โ”€ calculateRecommendedPosts/
โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ calculateRecommendedPosts.test.ts
โ”‚ย ย  โ”‚ย ย  โ””โ”€โ”€ calculateRecommendedPosts.ts
โ”‚ย ย  โ””โ”€โ”€ generateSummary/
โ”‚ย ย  โ”œโ”€โ”€ generateSummary.test.ts
โ”‚ย ย  โ””โ”€โ”€ generateSummary.ts
โ””โ”€โ”€ string/
โ”œโ”€โ”€ addNonBreakingSpacePunctuation/
โ”‚ย ย  โ”œโ”€โ”€ addNonBreakingSpacePunctuation.test.ts
โ”‚ย ย  โ””โ”€โ”€ addNonBreakingSpacePunctuation.ts
โ”œโ”€โ”€ capitalize/
โ”‚ย ย  โ”œโ”€โ”€ capitalize.test.ts
โ”‚ย ย  โ””โ”€โ”€ capitalize.ts
โ”œโ”€โ”€ extractUrlInfo/
โ”‚ย ย  โ”œโ”€โ”€ extractDateFromFileName.test.ts
โ”‚ย ย  โ”œโ”€โ”€ extractSlugFromFileName.ts
โ”‚ย ย  โ””โ”€โ”€ extractUrlInfo.ts
โ””โ”€โ”€ parseMarkdown/
โ”œโ”€โ”€ parseMarkdown.test.ts
โ””โ”€โ”€ parseMarkdown.ts

All the functions are paired with a corresponding test to ensure thorough testing and to prevent any missing tests. I use Vitest to run these tests.

Youโ€™ll also notice an index.ts file, which I use to re-export all the functions. And I avoid using export default to keep the variable names clean.

I always try to use the readonly keyword in my functions to avoid side effects. For example:

type Post = {
readonly id: string;
readonly data: {
readonly tags: readonly string[];
};
};
// -----
/**
* Calculates a list of recommended posts based on the current post and a list of available posts.
*/
export function calculateRecommendedPosts(
currentPost: Readonly<Post>,
listOfPosts: readonly Post[],
minArticles: number,
maxArticles: number,
): Post[] {
// This action is not allowed:
// currentPost.push(/* */)
return []
}

If I want to have more information about this function, I can look at the tests !

Pages (./src/pages/) ๐Ÿ“‘

This directory contains all the routes of my application. These files serve two main roles:

  • Prepare the routing using the getStaticPaths function.
  • Execute queries to fetch content, images, and translations (if needed).
.
โ”œโ”€โ”€ index.astro
โ”œโ”€โ”€ _routes.ts
โ”œโ”€โ”€ de/
โ”‚ย ย  โ””โ”€โ”€ uber-mich.astro
โ”œโ”€โ”€ en/
โ”‚ย ย  โ”œโ”€โ”€ 404.astro
โ”‚ย ย  โ”œโ”€โ”€ about-me.astro
โ”‚ย ย  โ”œโ”€โ”€ ui.astro
โ”‚ย ย  โ””โ”€โ”€ posts/
โ”‚ย ย  โ””โ”€โ”€ rss.xml.ts
โ”œโ”€โ”€ fr/
โ”‚ย ย  โ”œโ”€โ”€ a-propos.astro
โ”‚ย ย  โ””โ”€โ”€ posts/
โ”‚ย ย  โ””โ”€โ”€ rss.xml.ts
โ””โ”€โ”€ [locale]/
โ”œโ”€โ”€ index.astro
โ””โ”€โ”€ posts/
โ”œโ”€โ”€ it/
โ”‚ย ย  โ”œโ”€โ”€ index.astro
โ”‚ย ย  โ”œโ”€โ”€ [slug].astro
โ”‚ย ย  โ””โ”€โ”€ tags/
โ”‚ย ย  โ””โ”€โ”€ [tag].astro
โ””โ”€โ”€ me/
โ”œโ”€โ”€ index.astro
โ””โ”€โ”€ [slug].astro

You can also notice a _routes.ts file. This file is not present in the dist folder. It simply re-exports the routes and connects similar pages together. This is particularly useful in the navbar for language switching. For example, if you are on the /en/about-me/ page and want to switch to French, this dictionary helps query the corresponding French page (in this case, /fr/a-propos/).

./pages/_routes.ts
export type Language = "fr" | "en" | "de";
export const ROUTES = {
rss: {
fr: "/posts/rss.xml",
en: "/posts/rss.xml",
},
about: {
fr: "/a-propos/",
en: "/about-me/",
de: "/uber-mich/",
},
posts_it: {
fr: "/posts/it/",
en: "/posts/it/",
},
posts_me: {
en: "/posts/me/",
},
welcome: {
fr: "/",
en: "/",
de: "/",
},
} as const;

Plugins ๐Ÿ”Œ

This directory contains the plugins and integrations I want to add. For example, I have a mermaid integration. You can find more information here.

Code Tools ๐Ÿ”ง

I donโ€™t use many code tools. I primarily use git to version the code and the blog content.

Node Packages ๐Ÿ“ฆ

Here is the list of the Node packages I use:

  • Astro Integration: astro, astro-expressive-code, @astrojs/alpinejs, @astrojs/check, @astrojs/markdown-remark, @astrojs/mdx, @astrojs/rss, @astrojs/sitemap, @astrojs/tailwind
  • TypeScript: typescript, @types/alpinejs, @types/node
  • TailwindCSS: tailwindcss, @tailwindcss/aspect-ratio, @tailwindcss/forms, @tailwindcss/typography, clsx
  • Alpine: alpinejs
  • Knip (detect unused code): knip
  • Mermaid (draw diagrams): mermaid, unist-util-visit
  • Prettier (format the code): prettier, prettier-plugin-astro, prettier-plugin-astro-organize-imports, prettier-plugin-tailwindcss
  • Vitest (run tests): vitest

TypeScript configuration ๐Ÿ‡น๐Ÿ‡ธ

Here is my TypeScript configuration:

tsconfig.json
{
"extends": "astro/tsconfigs/strictest",
"types": ["vite/client"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"exclude": ["dist", "public"]
}

I use the strictest configuration to adhere to good coding practices and avoid bugs.

I have to exclude the dist folder; otherwise, it crashes for some reason with Mermaid JS.

Prettier configuration โœจ

.prettierrc.mjs
/** @type {import("prettier").Config} */
export default {
tailwindFunctions: ["clsx"],
plugins: [
"prettier-plugin-astro",
"prettier-plugin-tailwindcss",
"prettier-plugin-astro-organize-imports",
],
overrides: [
{
files: "*.astro",
options: {
parser: "astro",
},
},
],
};

Workflows ๐Ÿ”€

In the package.json file, I have several scripts to handle the building of the site:

package.json
{
// ...
"scripts": {
"disable_telemetry": "astro telemetry disable",
"test": "vitest run",
"check": "astro check",
"dev": "astro dev",
"start": "astro dev",
"build": "npm run check && npm run test && astro build",
"preview": "astro preview",
"astro": "astro",
"prettify": "prettier --write './src/'",
"knip": "knip"
},
// ...
}

In the Makefile, I have higher level helpers:

SHELL := /bin/bash
include .env
help:
# ...
#-- update blog
npm_update: ## Update the node_modules
# ...
commit_update_node_modules: ## Commit updated node_modules
# ...
#-- stats
get_blog_size: ## Calculate the blog size
# ...
logs: ## git logs
# ...
#-- admin section
envoy: ## Build and deploy the application
# ...
@echo "Application deployed !"

Choices ๐Ÿ’ญ

I want to depend to the less node packages as possible.

I am using Alpine integration to manage user interactions. Before, I used Svelte, but I got difficulties to manage translations, display resized images, and implementing Markdown syntax highlighting. With Alpine, I can use even in .astro file, so I can have the full Astro API.

To make my website look like an SPA, I can use the transition API: (https://astro.build/blog/future-of-astro-zero-js-view-transitions/)

No big framework is needed !

In the future: whatโ€™s next? โณ

Looking ahead, I have several plans to evolve my website:

  • Design Improvements: I aim to enhance the design of my website.
  • Component UI Page: I have a UI page to check my components. Iโ€™m considering integrating it with StoryBook once an integration becomes available. The progress can be followed here.
  • Astro 5: with Astro 5 on the horizon, Iโ€™m excited to see how it evolves. Some file scripts might become obsolete, simplifying the code further?
  • Tailwind 4: the release of Tailwind 4 is also on the horizon.
  • Code Hike Compatibility: Iโ€™m hopeful that libraries like Code Hike will become compatible with Astro. You can read more about it here.

Letโ€™s see what the future holds! ๐Ÿ”ฎ


Recommended articles