Building a Lightning-Fast Portfolio with Astro and Tailwind CSS

By Cameron Marotto
Astro Tailwind CSS Tutorial Web Development Performance
Building a Lightning-Fast Portfolio with Astro and Tailwind CSS

Introduction

In today’s digital landscape, having a fast, accessible, and visually appealing portfolio website is crucial for developers and designers. This tutorial will guide you through building a modern portfolio using Astro for static site generation and Tailwind CSS for styling.

Why Astro + Tailwind CSS?

Astro’s Benefits

  • Zero JavaScript by default - Pages load instantly
  • Component Islands - Interactive components only when needed
  • Multiple framework support - Use React, Vue, Svelte components
  • Built-in optimizations - Image optimization, code splitting, and more

Tailwind CSS Benefits

  • Rapid development - Utility-first approach speeds up styling
  • Consistent design - Pre-built design system
  • Small bundle size - Only includes used utilities
  • Responsive by default - Mobile-first responsive design

Project Setup

1. Initialize Astro Project

npm create astro@latest my-portfolio -- --template minimal
cd my-portfolio

2. Install Dependencies

npm install @astrojs/tailwind @astrojs/mdx
npm install -D tailwindcss @tailwindcss/typography

3. Configure Tailwind CSS

// tailwind.config.mjs
export default {
  content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        accent: {
          DEFAULT: '#3B82F6',
          dark: '#2563EB',
          light: '#60A5FA'
        }
      }
    },
  },
  plugins: [require('@tailwindcss/typography')],
}

4. Update Astro Configuration

// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';

export default defineConfig({
  integrations: [tailwind(), mdx()],
  site: 'https://yourdomain.com',
  output: 'static'
});

Project Structure

src/
├── components/     # Reusable components
├── content/        # Markdown content
│   ├── blog/       # Blog posts
│   └── projects/   # Project pages
├── layouts/        # Page layouts
├── pages/          # Route pages
└── styles/         # Global styles

Building the Layout

1. Create Base Layout

---
// src/layouts/Layout.astro
export interface Props {
  title: string;
  description?: string;
}

const { title, description = "My portfolio website" } = Astro.props;
---

<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content={description} />
    <title>{title}</title>
  </head>
  <body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
    <slot />
  </body>
</html>

2. Create Navigation Component

---
// src/components/Navigation.astro
const navItems = [
  { href: '/', label: 'Home' },
  { href: '/projects', label: 'Projects' },
  { href: '/blog', label: 'Blog' },
  { href: '/contact', label: 'Contact' }
];
---

<nav class="bg-white dark:bg-gray-800 shadow-sm">
  <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
    <div class="flex justify-between h-16">
      <div class="flex items-center">
        <a href="/" class="text-xl font-bold text-accent">
          Your Name
        </a>
      </div>
      
      <div class="hidden md:flex items-center space-x-8">
        {navItems.map(item => (
          <a 
            href={item.href}
            class="text-gray-700 dark:text-gray-300 hover:text-accent transition-colors"
          >
            {item.label}
          </a>
        ))}
      </div>
    </div>
  </div>
</nav>

Content Management

1. Set Up Content Collections

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    author: z.string(),
    tags: z.array(z.string()).optional(),
  }),
});

const projects = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    category: z.string(),
    tags: z.array(z.string()),
    featured: z.boolean().optional(),
  }),
});

export const collections = { blog, projects };

2. Create Dynamic Routes

---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await post.render();
---

<Layout title={post.data.title}>
  <article class="max-w-4xl mx-auto px-4 py-8">
    <h1 class="text-4xl font-bold mb-4">{post.data.title}</h1>
    <div class="prose dark:prose-invert max-w-none">
      <Content />
    </div>
  </article>
</Layout>

Performance Optimizations

1. Image Optimization

---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---

<Image 
  src={heroImage} 
  alt="Hero image"
  class="w-full h-64 object-cover rounded-lg"
  format="webp"
  quality={80}
/>

2. Lazy Loading

<img 
  src="/image.jpg" 
  alt="Description"
  loading="lazy"
  class="w-full h-64 object-cover"
/>

3. Font Optimization

---
// In your layout
---

<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>

Dark Mode Implementation

1. Add Dark Mode Toggle

---
// src/components/ThemeToggle.astro
---

<button 
  id="theme-toggle"
  class="p-2 rounded-lg bg-gray-200 dark:bg-gray-700"
  aria-label="Toggle dark mode"
>
  <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
    <!-- Sun icon -->
  </svg>
</button>

<script>
  const theme = (() => {
    if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
      return localStorage.getItem('theme');
    }
    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      return 'dark';
    }
    return 'light';
  })();

  if (theme === 'light') {
    document.documentElement.classList.remove('dark');
  } else {
    document.documentElement.classList.add('dark');
  }

  window.localStorage.setItem('theme', theme);

  const handleToggleClick = () => {
    const element = document.documentElement;
    element.classList.toggle("dark");

    const isDark = element.classList.contains("dark");
    localStorage.setItem("theme", isDark ? "dark" : "light");
  }

  document.getElementById("theme-toggle").addEventListener("click", handleToggleClick);
</script>

SEO Optimization

1. Add Meta Tags

---
// In your layout
export interface Props {
  title: string;
  description: string;
  image?: string;
}

const { title, description, image } = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
---

<head>
  <title>{title}</title>
  <meta name="description" content={description} />
  <link rel="canonical" href={canonicalURL} />
  
  <!-- Open Graph -->
  <meta property="og:title" content={title} />
  <meta property="og:description" content={description} />
  {image && <meta property="og:image" content={image} />}
  
  <!-- Twitter -->
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:title" content={title} />
  <meta name="twitter:description" content={description} />
</head>

2. Generate Sitemap

// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://yourdomain.com',
  integrations: [sitemap()],
});

Deployment

1. Cloudflare Pages

# Build the project
npm run build

# Deploy to Cloudflare Pages
npx wrangler pages deploy dist

2. GitHub Actions (Optional)

# .github/workflows/deploy.yml
name: Deploy to Cloudflare Pages
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: npm run build
      - uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: your-project-name
          directory: dist

Performance Testing

1. Lighthouse Scores

After deployment, test your site with Lighthouse:

npx lighthouse https://yourdomain.com --output html

2. Core Web Vitals

Monitor your Core Web Vitals in Google Search Console and PageSpeed Insights.

Best Practices

1. Component Design

  • Keep components small and focused
  • Use TypeScript for type safety
  • Implement proper accessibility attributes
  • Test components in isolation

2. Performance

  • Optimize images and use modern formats
  • Minimize JavaScript bundle size
  • Implement proper caching strategies
  • Use lazy loading for non-critical resources

3. Accessibility

  • Use semantic HTML elements
  • Provide proper alt text for images
  • Ensure keyboard navigation works
  • Test with screen readers

Conclusion

Building a portfolio with Astro and Tailwind CSS provides an excellent foundation for a fast, maintainable, and scalable website. The combination of static generation, utility-first CSS, and modern tooling creates a developer experience that’s both productive and performant.

Key takeaways:

  • Performance first - Static generation provides excellent performance out of the box
  • Developer experience - Hot reloading and TypeScript support speed up development
  • Maintainability - Component-based architecture makes the codebase easy to maintain
  • Scalability - The architecture supports growth and new features

Resources

Start building your portfolio today and showcase your work with a lightning-fast, beautiful website!