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 withclient: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 inlocalStorage
.
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
andbase
inastro.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!