Jan 3, 2025

Astro + Tailwind + React: A Simple Link-in-Bio Project

Astro + Tailwind + React: A Simple Link-in-Bio Project

Exploring Astro for the first time by building a minimal link-in-bio site with Tailwind CSS, Astro Icon, and React components.

  • Jorge Perez Avatar
    Jorge Perez
    9 min read
  • Getting started with Astro can be surprisingly smooth, even if you need interactive elements and want to keep performance in mind. Recently, I decided to give Astro a try by building a simple link-in-bio page. Along the way, I discovered how to integrate Tailwind CSS, add local/custom icons using Astro Icon, and sprinkle in some React components for the finishing touches.

    Why Astro?

    Astro focuses on shipping less JavaScript by default. It’s a static site builder that only hydrates what’s necessary, which was perfect for this link-in-bio page where I mostly needed static content but also wanted a few dynamic features:

    • Performance: Astro ships minimal JavaScript by default, enhancing load times.
    • Flexibility: Seamlessly integrates with frameworks like React for interactive components.
    • Simplicity: Intuitive file-based routing and straightforward project structure.
    • Selective Hydration: Only hydrate components that require interactivity, keeping the rest of the site static.

    Astro’s partial hydration concept meant I could keep the rest of the page static and only ship JavaScript for the parts that genuinely need it.

    Live Demo

    Check out the live project on GitHub Pages: https://jperez00.github.io/astrolinks/

    GitHub Repo

    Star the project on GitHub! https://github.com/JPerez00/astrolinks

    Core Technologies

    • Astro – The framework powering the site’s structure and routing.
    • Tailwind CSS – For utility-first, responsive styling.
    • Astro Icon – Simplifies the use of local and library icons.
    • React – Powers interactive elements like the typewriter effect and dark mode toggle.

    Project Structure

    A clear and organized folder structure was key to this project:

    /
    ├── public/
       ├── astrolinks-hero.png
       ├── portrait.webp
       └── favicon.svg
    ├── src/
       ├── layouts/
          └── Layout.astro
       ├── pages/
          └── index.astro
       ├── icons/
          ├── github.svg
          ├── linkedin.svg
          ├── twitter.svg
          ├── instagram.svg
          └── terminal.svg
       ├── components/
          ├── TypewriterText.jsx
          ├── ToggleTheme.jsx
          └── Footer.astro
       └── styles/
           └── global.css
    └── package.json

    Summary:

    • Layout.astro: Defines the overall HTML structure.
    • index.astro: The main page displaying links and interactive elements.
    • TypewriterText.jsx: React component for the animated bio text.
    • ToggleTheme.jsx: React component for switching between light and dark modes.
    • Footer.astro: Simple footer component.

    1. Building the Layout

    Astro encourages using a dedicated layout file for consistent HTML structure. In Layout.astro, I imported Tailwind’s global styles, added a script to handle initial theme setting, and included the toggle and footer components.

    ---
    import "../styles/global.css";
    import Footer from "../components/Footer.astro";
    import ToggleTheme from "../components/ToggleTheme.jsx";
    ---
    <!DOCTYPE html>
    <html lang="en" class="h-full bg-zinc-100 antialiased">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width" />
        <link rel="icon" href="/astrolinks/favicon.svg" />
        <title>{Astro.props.title || 'AstroLinks'}</title>
        <script>
          if (
            localStorage.theme === 'dark' ||
            (!('theme' in localStorage) &&
              window.matchMedia('(prefers-color-scheme: dark)').matches)
          ) {
            document.documentElement.classList.add('dark');
          }
        </script>
      </head>
      <body class="min-h-screen flex flex-col m-0 mx-4 lg:mx-auto dark:bg-zinc-900">
        <slot />
        <ToggleTheme client:load />
        <Footer />
      </body>
    </html>

    2. Creating Interactive Components

    • TypewriterText uses the typewriter-effect library in a simple React component. We hydrate it with client:load in Astro so that JavaScript only runs when needed.
    • ToggleTheme is another React component with some inline logic to switch .dark on <html> and store the preference in localStorage.

    Because Astro partial hydration means we only send JS for these two dynamic components, we keep everything else static, leading to faster loads.

    Typewriter Effect:

    Using the typewriter-effect React library, I created an animated bio text component.

    // src/components/TypewriterText.jsx
    import React from 'react';
    import Typewriter from 'typewriter-effect';
    
    export default function TypewriterText() {
      return (
        <div className="text-lg text-zinc-600 h-6">
          <Typewriter
            options={{
              strings: [
                "Software developer, writer & photographer.",
                "Frontend, Backend, everything in between!",
                "Creating content, building solutions."
              ],
              autoStart: true,
              loop: true,
              delay: 55,
              deleteSpeed: 35
            }}
          />
        </div>
      );
    }

    Dark Mode Toggle

    A sleek toggle switch allows users to switch between light and dark themes, with preferences saved in localStorage.

    // src/components/ToggleTheme.jsx
    import React, { useEffect, useState } from 'react';
    
    /* Sun Icon */
    function SunIcon(props) {
      return (
        <svg viewBox="0 0 24 24" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" fill="none" {...props}>
          <path d="M8 12.25A4.25 4.25 0 0 1 12.25 8v0a4.25 4.25 0 0 1 4.25 4.25v0a4.25 4.25 0 0 1-4.25 4.25v0A4.25 4.25 0 0 1 8 12.25v0Z" />
          <path d="M12.25 3v1.5M21.5 12.25H20M18.791 18.791l-1.06-1.06M18.791 5.709l-1.06 1.06M12.25 20v1.5M4.5 12.25H3M6.77 6.77 5.709 5.709M6.77 17.73l-1.061 1.061" />
        </svg>
      );
    }
    
    /* Moon Icon */
    function MoonIcon(props) {
      return (
        <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" {...props}>
          <path
            ="M17.25 16.22a6.937 6.937 0 0 1-9.47-9.47 7.451 7.451 0 1 0 9.47 9.47ZM12.75 7C17 7 17 2.75 17 2.75S17 7 21.25 7C17 7 17 11.25 17 11.25S17 7 12.75 7Z"
            strokeWidth="1.5"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
      );
    }
    
    export default function ToggleTheme() {
      const [mounted, setMounted] = useState(false);
      const [isDark, setIsDark] = useState(false);
    
      useEffect(() => {
        setMounted(true);
        const storedTheme = localStorage.getItem("theme");
        if (
          storedTheme === 'dark' ||
          (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
        ) {
          document.documentElement.classList.add('dark');
          setIsDark(true);
        } else {
          document.documentElement.classList.remove('dark');
          setIsDark(false);
        }
      }, []);
    
      const handleToggle = () => {
        if (isDark) {
          document.documentElement.classList.remove('dark');
          localStorage.setItem('theme', 'light');
          setIsDark(false);
        } else {
          document.documentElement.classList.add('dark');
          localStorage.setItem('theme', 'dark');
          setIsDark(true);
        }
      };
    
      if (!mounted) return null;
    
      return (
        <button
          type="button"
          aria-label={`Switch to ${isDark ? 'light' : 'dark'} mode`}
          onClick={handleToggle}
          className="fixed top-4 right-4 flex items-center justify-center w-12 h-6 rounded-full bg-gray-300 dark:bg-gray-600 transition-colors"
          style={{ zIndex: 9999 }}
        >
          <SunIcon className={`h-5 w-5 text-yellow-500 ${isDark ? 'hidden' : 'block'}`} />
          <MoonIcon className={`h-5 w-5 text-gray-800 ${isDark ? 'block' : 'hidden'}`} />
        </button>
      );
    }

    3. Implementing the Main Page

    In index.astro, I structured the main content with a profile image, name, typewriter bio, and social links.

    ---
    import Layout from "../layouts/Layout.astro";
    import { Icon } from "astro-icon/components";
    import TypewriterText from "../components/TypewriterText.jsx";
    ---
    <Layout>
      <main class="mt-10 lg:mt-14 mx-auto w-full max-w-lg text-center bg-white dark:bg-zinc-800 rounded shadow-md py-6 px-4">
        <img src="/astrolinks/portrait.webp" alt="Profile Picture" class="w-32 h-32 rounded-full mx-auto mb-4 object-cover shadow" />
        <h1 class="text-3xl font-bold">Jorge Perez</h1>
        <TypewriterText client:load />
        <div class="mt-8 flex flex-col gap-4">
          <a href="https://github.com/JPerez00" class="flex items-center justify-center gap-1.5 px-4 py-3 rounded-full border bg-white hover:bg-zinc-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 transition-all text-zinc-700 dark:text-zinc-200 font-medium shadow">
            <Icon name="github" class="w-5 h-5" />
            GitHub
          </a>
          <!-- Additional links... -->
        </div>
      </main>
    </Layout>

    4. Adding Icons with Astro Icon

    Placing local SVGs in src/icons/ makes them available to <Icon name="some-icon" />. For instance, github.svg can be used by referencing name="github". If we want a library-based icon, we can do name="mdi:linkedin" or something similar from astro-icon’s icon sets.

    5. Deploying to GitHub Pages

    Deploying with Astro is straightforward, I just followed the documentation page.

    Configure your astro.config.mjs

    But in short, you just need to configure the URL path and BASE path inside your astro.config.mjs file. Here’s the example provided in their documentation:

    import { defineConfig } from 'astro/config'
    
    export default defineConfig({
      site: 'https://astronaut.github.io',
      base: 'my-repo',
    })

    That said, I wanted to use a custom repo name and URL. Here’s what my configuration looks like:

    import { defineConfig } from 'astro/config';
    import tailwind from '@astrojs/tailwind';
    import icon from 'astro-icon';
    import react from '@astrojs/react';
    
    export default defineConfig({
      integrations: [tailwind(), icon(), react()],
      site: 'https://jperez00.github.io/astrolinks',
      base: '/astrolinks',
    });

    Set Up GitHub Actions

    Create a new file in your project at .github/workflows/deploy.yml and paste in the YAML below.

    name: Deploy to GitHub Pages
    
    on:
      # Trigger the workflow every time you push to the `main` branch
      # Using a different branch name? Replace `main` with your branch’s name
      push:
        branches: [ main ]
      # Allows you to run this workflow manually from the Actions tab on GitHub.
      workflow_dispatch:
    
    # Allow this job to clone the repo and create a page deployment
    permissions:
      contents: read
      pages: write
      id-token: write
    
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout your repository using git
            uses: actions/checkout@v4
          - name: Install, build, and upload your site
            uses: withastro/action@v3
            # with:
              # path: . # The root location of your Astro project inside the repository. (optional)
              # node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 20. (optional)
              # package-manager: pnpm@latest # The Node package manager that should be used to install dependencies and build your site. Automatically detected based on your lockfile. (optional)
    
      deploy:
        needs: build
        runs-on: ubuntu-latest
        environment:
          name: github-pages
          url: ${{ steps.deployment.outputs.page_url }}
        steps:
          - name: Deploy to GitHub Pages
            id: deployment
            uses: actions/deploy-pages@v4

    Astro makes static generation easy, so I simply:

    • Add the right site and base in astro.config.mjs (e.g., site: 'https://username.github.io/astrolinks', base: '/astrolinks/').
    • Configure a GitHub Actions workflow to build and deploy the dist/ folder to Pages.

    The result is a quick, static site with interactive bits. For reference, here's my astro.config.mjs file:

    import { defineConfig } from 'astro/config';
    import tailwind from '@astrojs/tailwind';
    import icon from 'astro-icon';
    import react from '@astrojs/react';
    
    export default defineConfig({
      integrations: [tailwind(), icon(), react(),],
      site: 'https://jperez00.github.io/astrolinks',
      base: '/astrolinks',
    });

    The result is a fast, static site with dynamic enhancements only where needed, ensuring optimal performance.

    Considerations

    • Heavy Interactivity: For sites with extensive dynamic features, Astro’s partial hydration might be less efficient compared to full client-side frameworks.
    • Learning Curve: While Astro is intuitive, integrating multiple technologies (React, Tailwind, etc.) requires familiarity with each.

    Final Thoughts

    Astro let me quickly assemble a modern link-in-bio page, with minimal overhead and neat partial hydration for the fun stuff. Tailwind handled my styling, React gave me typewriter/dark-mode toggles, and Astro Icon kept my icons tidy.

    If you’re looking for a framework that delivers blazing fast static pages and can still handle interactivity where needed, Astro is definitely worth a try!

    You Might Also Like...