How to Refactor Astro to Improve Code Maintainability


In “How to Create a Blog with Astro,” I explained the initial steps to create your blog with Astro, and in “How to Adapt Astro to TypeScript,” I showed you how to adjust Astro’s configurations to use TypeScript and get the most out of it (even though Astro uses TypeScript by default, I recommend configuring it this way from the start).

However…

The base template:

1. Has duplicated code:

For example, src/pages/index.astro, src/pages/blog/index.astro, and src/layouts/BlogPost.astro have the following code in common:

---
import BaseHead from '@components/BaseHead.astro';
import Header from '@components/Header.astro';
import Footer from '@components/Footer.astro';
---

<!doctype html>
<html lang="en">
    <head>
        <BaseHead title={...} description={...} />
        ...
    </head>
    <body>
        <Header />
        <main>
            ...
        </main>
        <Footer />
    </body>
</html>

If we want to make a change to this code, we would have to do it in all three files… for now… because if we add more “pure” pages or use layouts similar to BlogPost, the list will grow longer.

2. Is designed for a typical blog:

That is, to have some “pure” pages (like the home page, “About Me” page, …), a blog post archive, and blog posts.

We can see this in the fact that the blog page file is specifically designed for blog posts. What if I want to add another type of “serialized” publication (for example, talks I give, events, …)?

Basically, duplicate the code and go back to point 1.

What if we want something more complex? Now, we’ll see how to refactor the generated code to be easily reusable.

3. Making changes involves copying code

Watch out here! Code is code, and content is content. It’s normal that we have to add content if we want to expand what’s on the web… but duplicating or copying code?

Refactor Components

Naming Components

If you look at the src/components folder, you’ll see components like BaseHead or FormattedDate… but there’s no other type of head or date. Additionally, in the case of the date, we assume it will always be formatted for the user.

That’s why I’m going to establish naming conventions:

  1. If there’s no other component that performs a similar function, we’ll avoid generating a compound name.
  2. If the component refers to an existing HTML tag, we’ll name it the same as the tag, respecting the casing of the language (in this case, Astro).

With this, we’re going to make some changes:

  1. src/components/BaseHead.astro will become src/components/Head.astro,
    1. everything in the component corresponds to the content of the head tag,
    2. to maintain consistency, we’ll need to make changes in:
      1. src/layouts/BlogPost.astro,
      2. src/pages/blog/index.astro,
      3. and src/pages/index.astro,
  2. src/components/FormattedDate.astro will become src/components/Date.astro,
    1. (there’s no other component representing dates),
    2. to maintain consistency, we’ll need to make changes in:
      1. src/layouts/BlogPost.astro,
      2. and src/pages/blog/index.astro,
  3. src/layouts/BlogPost.astro will become src/layouts/PageContent.astro,
    1. it’s not only used in blog posts but also in pages like src/pages/about.astro,
    2. we’ll rename it to PageContent and not Content because Content is already used by Astro,
      1. it’s true that when importing the component with Astro, we can name it whatever we want, but the goal is to reduce cognitive load to a minimum (i.e., to try to think as little as possible),
    3. to maintain consistency, we’ll need to make changes in:
      1. src/pages/about.astro,
      2. and src/pages/blog/[...slug].astro,

Cohesive Components

If you look at the code, right now, the head tag generally only has the Head component. If we make the tag part of the component, we gain readability because we go from

<head>
	<Head />
	...
</head>

to

<Head>
	...
</Head>

On one hand, we’ll need to make these changes in:

  1. src/layouts/BlogPost.astro,
  2. src/pages/blog/index.astro,
  3. and src/pages/index.astro,

On the other hand, this means that the Head component should have at least one defined slot, and after making the changes, the Head component (in src/components/Head.astro) should look like this:

---
// Import the global.css file here so that it is included on
// all pages through the use of the <Head /> component.
import '@styles/global.css';

interface Props {
	title: string;
	description: string;
	image?: string;
}

const canonicalURL = new URL(Astro.url.pathname, Astro.site);

const { title, description, image = '/blog-placeholder-1.jpg' } = Astro.props;
---

<head>
	<!-- Global Metadata -->
	<meta charset="utf-8" />
	<meta name="viewport" content="width=device-width,initial-scale=1" />
	<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
	<meta name="generator" content={Astro.generator} />

	<!-- Font preloads -->
	<link rel="preload" href="/fonts/atkinson-regular.woff" as="font" type="font/woff" crossorigin />
	<link rel="preload" href="/fonts/atkinson-bold.woff" as="font" type="font/woff" crossorigin />

	<!-- Canonical URL -->
	<link rel="canonical" href={canonicalURL} />
	<slot />

	<!-- Primary Meta Tags -->
	<title>{title}</title>
	<meta name="title" content={title} />
	<meta name="description" content={description} />

	<!-- Open Graph / Facebook -->
	<meta property="og:type" content="website" />
	<meta property="og:url" content={Astro.url} />
	<meta property="og:title" content={title} />
	<meta property="og:description" content={description} />
	<meta property="og:image" content={new URL(image, Astro.url)} />

	<!-- Twitter -->
	<meta property="twitter:card" content="summary_large_image" />
	<meta property="twitter:url" content={Astro.url} />
	<meta property="twitter:title" content={title} />
	<meta property="twitter:description" content={description} />
	<meta property="twitter:image" content={new URL(image, Astro.url)} />
</head>

Centralize Configurations

Right now, the main header menu and the social menus in the header and footer are hardcoded in the files.

Therefore, when making any changes, we’ll need to modify the files by copying and adapting the code. This practice is prone to errors, so let’s refactor to avoid these errors by centralizing the menu configurations in external files.

To do this:

  1. Install ReactJS for Astro:
    yarn astro add react
    
  2. Create the MenuItem type in a specific file we’ll call src/types/menus.d.ts. A menu item needs at least a display text (label) and a path to go to (path). In our case, since we’re also managing social menus, we’ll have an optional icon. This definition should look something like this (but feel free to modify it as needed):
    import { FC, SVGProps } from 'react';
    
    export type MenuItem = Readonly<{
        label: string;
        path: string;
        icon?: FC<SVGProps<SVGSVGElement>>;
    }>;
    

    Note:

    By defining the MenuItem type as Readonly, we are enforcing the immutability of the object. That is, we can initialize it without issues, but after that, changing its values will trigger a compile-time error. This ensures that the object does not change at any time.

  3. Create the Menu type and add it to the src/types/menus.d.ts file. This definition should look something like this (but feel free to modify it as needed):
    export type Menu = MenuItem[];
    

    Note:

    Although we could avoid this definition (in the end, we’re just defining an array of MenuItem), any changes to this definition would require changing imports. This way, we can avoid that change in the future, which is important if every need for change is an invitation to generate errors.

  4. Create a folder to store the configurations. In my case, it’s the src/config folder.
  5. Extract the icons to their own files in a subfolder of src/components (I placed them in src/components/icons). The GitHub icon should look like this (and will serve as an example for the others):
    import { type SVGProps } from 'react';
    
    const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
        <svg
            viewBox="0 0 16 16"
            aria-hidden="true"
            width="32"
            height="32"
            {...props}
        >
            <path
                d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
                fill="currentColor"
            />
        </svg>
    );
    
    export default GithubIcon;
    
  6. Configure shortcuts to these two new paths in the tsconfig.json file. After the changes, it should look like this:
    {
        "extends": "astro/tsconfigs/strict",
        "compilerOptions": {
            "baseUrl": "src/",
            "paths": {
                "@/*": ["./*"],
                "@components/*": ["components/*"],
                "@config/*": ["config/*"],
                "@icons/*": ["components/icons/*"],
                "@layouts/*": ["layouts/*"],
                "@styles/*": ["styles/*"]
            },
            "strictNullChecks": true
        }
    }
    
  7. Create the menu configuration file. In my case, it’s the src/config/menus.config.ts file.
  8. Extract the header menu configuration into this file. The code should look like this:
    import type { Menu } from '@/types/menus.d';
    
    export const mainMenu: Menu = [
        { label: 'Home', path: '/' },
        { label: 'Blog', path: '/blog' },
    ];
    
  9. Now extract the social menu configuration to the src/config/menus.config.ts file. It should look like this:
    import type { Menu } from '@/types/menus.d';
    import GithubIcon from '@/components/icons/GithubIcon';
    
    export const mainMenu: Menu = [
        { label: 'Home', path: '/' },
        { label: 'Blog', path: '/blog' },
    ];
    
    export const socialMenu: Menu = [
        {
            label: "Go to Template's GitHub repo",
            path: "https://github.com/borjalofe/astro-blog-template",
            icon: GithubIcon,
        },
    ];
    
  10. Refactor src/components/Header.astro to use this configuration. The code in src/components/Header.astro should look like this:
    ---
    import HeaderLink from '@components/HeaderLink.astro';
    import { mainMenu, socialMenu } from '@config/menus.config';
    import { SITE_TITLE } from '@/consts';
    ---
    
    <header>
        <nav>
            <h2><a href="/">{SITE_TITLE}</a></h2>
            <div class="internal-links">
                {mainMenu.map(
                    ({label, path}) => <HeaderLink href={path}>{label}</HeaderLink>
                )}
            </div>
            <div class="social-links">
                {socialMenu.map(
                    ({icon: Icon, label, path}) => (
                        <a href={path} target="_blank">
                            <span class="sr-only">{label}</span>
                            {Icon && <Icon />}
                        </a>
                    )
                )}
            </div>
        </nav>
    </header>
    <style>
        ...
    </style>
    
  11. Refactor src/components/Footer.astro to use this configuration. The code in src/components/Footer.astro should look like this:
    ---
    import { socialMenu } from '@config/menus.config';
    
    const today = new Date();
    ---
    
    <footer>
        &copy; {today.getFullYear()} Your name here. All rights reserved.
        <div class="social-links">
            {socialMenu.map(
                ({icon: Icon, label, path}) => (
                    <a href={path} target="_blank">
                        <span class="sr-only">{label}</span>
                         {Icon && <Icon />}
                    </a>
                )
            )}
        </div>
    </footer>
    <style>
        ...
    </style>
    

With the changes we just made:

  1. we can add tags to the head simply by modifying src/components/Head.astro (for tags that affect the entire website) or by using the named slot as “head” (for tags that affect a specific page or layout),
  2. we can modify the menu elements simply by changing the content of the src/config/menus.config.ts file.

We can still do more to simplify future work… but that’s for another article.

A Little Trick

Thanks for making it this far. In upcoming articles, I’ll continue explaining how to create a custom template.

But if you don’t want to create one, you can use mine (the one you’ll have, more or less, if you follow all the steps in this series of articles) with the following command:

yarn create astro -- --template borjalofe/astro-blog-template