Chapter 06: Routing and Navigation System

Haiyue
18min
Learning Objectives
  • Deeply understand Astro’s file-system routing
  • Implement dynamic routing and nested routing
  • Build navigation components and breadcrumb systems
  • Optimize page transitions and loading performance

Knowledge Points

Astro Routing System

Astro uses a file-system-based routing mechanism with the following features:

  • Zero-config routing: File paths directly map to URL paths
  • Static-first: Generates static pages by default, supports dynamic routing
  • Nested routing: Implement route nesting through folder structure
  • Flexible matching: Support dynamic parameters and catch-all routes

Routing Hierarchy Structure

🔄 正在渲染 Mermaid 图表...
  • Prefetch strategy: Intelligently preload linked resources
  • View transitions: Smooth page transition animations
  • Route caching: Client-side route state management
  • Code splitting: Split JavaScript bundles by route

File System Routing

Basic Route Structure

src/pages/
├── index.astro          → /
├── about.astro          → /about
├── contact.astro        → /contact
├── blog/
│   ├── index.astro      → /blog
│   ├── [slug].astro     → /blog/:slug
│   └── category/
│       ├── index.astro  → /blog/category
│       └── [name].astro → /blog/category/:name
├── products/
│   ├── [id].astro       → /products/:id
│   └── [...slug].astro  → /products/* (catch-all)
└── api/
    ├── posts.json.ts    → /api/posts.json
    └── users/
        └── [id].json.ts → /api/users/:id.json

Dynamic Route Parameters

---
// src/pages/blog/[slug].astro

// Get route parameters via Astro.params
const { slug } = Astro.params;

// Get complete URL info via Astro.url
const url = Astro.url;
const searchParams = url.searchParams;

// Example: /blog/my-post?category=tech&featured=true
console.log(slug);                    // "my-post"
console.log(searchParams.get('category')); // "tech"
console.log(searchParams.get('featured')); // "true"
---

<h1>Post: {slug}</h1>
<p>Current URL: {url.pathname}</p>

Nested Route Layout

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

const { frontmatter } = Astro.props;
---

<html>
<head>
  <title>{frontmatter?.title || 'Blog'}</title>
  <meta name="description" content={frontmatter?.description || ''} />
</head>
<body>
  <div class="blog-layout">
    <!-- Blog navigation -->
    <nav class="blog-nav">
      <a href="/blog">All Posts</a>
      <a href="/blog/category">Categories</a>
      <a href="/blog/tags">Tags</a>
      <a href="/blog/archive">Archive</a>
    </nav>

    <!-- Main content area -->
    <main class="blog-content">
      <slot />
    </main>

    <!-- Sidebar -->
    <aside class="blog-sidebar">
      <div class="recent-posts">
        <h3>Recent Posts</h3>
        <!-- Recent posts list -->
      </div>

      <div class="categories">
        <h3>Categories</h3>
        <!-- Category list -->
      </div>
    </aside>
  </div>
</body>
</html>

Catch-all Routes

---
// src/pages/docs/[...slug].astro

export async function getStaticPaths() {
  // Generate all possible documentation paths
  const docs = [
    { slug: 'getting-started' },
    { slug: 'api/introduction' },
    { slug: 'api/authentication' },
    { slug: 'guides/deployment' },
    { slug: 'guides/best-practices' },
  ];

  return docs.map(doc => ({
    params: { slug: doc.slug },
    props: { doc }
  }));
}

const { slug } = Astro.params;
const { doc } = Astro.props;

// slug might be a nested path like "api/introduction"
const pathSegments = slug.split('/');
const section = pathSegments[0];     // "api"
const page = pathSegments[1];        // "introduction"
---

<div class="docs-page">
  <!-- Breadcrumb navigation -->
  <nav class="breadcrumb">
    <a href="/docs">Documentation</a>
    {pathSegments.map((segment, index) => {
      const path = pathSegments.slice(0, index + 1).join('/');
      return (
        <>
          <span>/</span>
          <a href={`/docs/${path}`}>{segment}</a>
        </>
      );
    })}
  </nav>

  <!-- Documentation content -->
  <article class="doc-content">
    <h1>{doc.title}</h1>
    <!-- Documentation content -->
  </article>
</div>

Smart Navigation Menu

---
// src/components/Navigation.astro

interface NavItem {
  label: string;
  href: string;
  children?: NavItem[];
  icon?: string;
}

const navItems: NavItem[] = [
  { label: 'Home', href: '/', icon: 'home' },
  {
    label: 'Blog',
    href: '/blog',
    icon: 'blog',
    children: [
      { label: 'All Posts', href: '/blog' },
      { label: 'Tech Sharing', href: '/blog/category/tech' },
      { label: 'Life Notes', href: '/blog/category/life' },
    ]
  },
  {
    label: 'Projects',
    href: '/projects',
    icon: 'projects',
    children: [
      { label: 'Open Source', href: '/projects/open-source' },
      { label: 'Personal Work', href: '/projects/personal' },
    ]
  },
  { label: 'About', href: '/about', icon: 'about' },
  { label: 'Contact', href: '/contact', icon: 'contact' },
];

// Get current page path
const currentPath = Astro.url.pathname;

// Check if it's the current page or subpage
function isActive(href: string): boolean {
  if (href === '/') {
    return currentPath === '/';
  }
  return currentPath.startsWith(href);
}

// Check if has active child
function hasActiveChild(item: NavItem): boolean {
  if (!item.children) return false;
  return item.children.some(child => isActive(child.href));
}
---

<nav class="main-navigation">
  <div class="nav-container">
    <!-- Logo -->
    <div class="nav-logo">
      <a href="/">
        <img src="/logo.svg" alt="Website Logo" />
      </a>
    </div>

    <!-- Navigation menu -->
    <ul class="nav-menu">
      {navItems.map((item) => (
        <li class={`nav-item ${isActive(item.href) || hasActiveChild(item) ? 'active' : ''}`}>
          <a
            href={item.href}
            class={`nav-link ${isActive(item.href) ? 'current' : ''}`}
          >
            {item.icon && <i class={`icon-${item.icon}`}></i>}
            <span>{item.label}</span>
            {item.children && <i class="icon-arrow-down"></i>}
          </a>

          <!-- Submenu -->
          {item.children && (
            <ul class="sub-menu">
              {item.children.map((child) => (
                <li class="sub-item">
                  <a
                    href={child.href}
                    class={`sub-link ${isActive(child.href) ? 'current' : ''}`}
                  >
                    {child.label}
                  </a>
                </li>
              ))}
            </ul>
          )}
        </li>
      ))}
    </ul>

    <!-- Mobile menu toggle -->
    <button class="mobile-menu-toggle" aria-label="Toggle menu">
      <span></span>
      <span></span>
      <span></span>
    </button>
  </div>
</nav>

<style>
.main-navigation {
  @apply bg-white shadow-sm border-b sticky top-0 z-50;
}

.nav-container {
  @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
  @apply flex items-center justify-between h-16;
}

.nav-logo img {
  @apply h-8 w-auto;
}

.nav-menu {
  @apply hidden md:flex space-x-8;
}

.nav-item {
  @apply relative;
}

.nav-link {
  @apply flex items-center space-x-1 px-3 py-2 rounded-md;
  @apply text-gray-600 hover:text-gray-900 transition-colors;
}

.nav-link.current {
  @apply text-blue-600 font-medium;
}

.nav-item.active > .nav-link {
  @apply text-blue-600;
}

.sub-menu {
  @apply absolute left-0 mt-2 w-48 bg-white shadow-lg rounded-md py-1;
  @apply opacity-0 invisible transform translate-y-1 transition-all;
}

.nav-item:hover .sub-menu {
  @apply opacity-100 visible translate-y-0;
}

.sub-link {
  @apply block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100;
}

.sub-link.current {
  @apply text-blue-600 bg-blue-50;
}

.mobile-menu-toggle {
  @apply md:hidden flex flex-col justify-center items-center w-6 h-6 space-y-1;
}

.mobile-menu-toggle span {
  @apply block w-6 h-0.5 bg-gray-600 transition-all;
}
</style>
---
// src/components/Breadcrumb.astro

export interface Props {
  items?: Array<{ label: string; href: string }>;
}

const { items } = Astro.props;

// If items not provided, generate from current path
const breadcrumbItems = items || generateBreadcrumb(Astro.url.pathname);

function generateBreadcrumb(pathname: string) {
  const segments = pathname.split('/').filter(Boolean);
  const breadcrumb = [{ label: 'Home', href: '/' }];

  let currentPath = '';
  segments.forEach((segment, index) => {
    currentPath += `/${segment}`;

    // Convert path segment to readable label
    const label = segment
      .replace(/-/g, ' ')
      .replace(/^\w/, c => c.toUpperCase());

    breadcrumb.push({
      label,
      href: currentPath
    });
  });

  return breadcrumb;
}
---

{breadcrumbItems.length > 1 && (
  <nav class="breadcrumb" aria-label="Breadcrumb navigation">
    <ol class="breadcrumb-list">
      {breadcrumbItems.map((item, index) => (
        <li class="breadcrumb-item">
          {index === breadcrumbItems.length - 1 ? (
            <span class="breadcrumb-current" aria-current="page">
              {item.label}
            </span>
          ) : (
            <>
              <a href={item.href} class="breadcrumb-link">
                {item.label}
              </a>
              <span class="breadcrumb-separator" aria-hidden="true">
                /
              </span>
            </>
          )}
        </li>
      ))}
    </ol>
  </nav>
)}

<style>
.breadcrumb {
  @apply py-3 text-sm;
}

.breadcrumb-list {
  @apply flex items-center space-x-1;
}

.breadcrumb-item {
  @apply flex items-center;
}

.breadcrumb-link {
  @apply text-gray-500 hover:text-gray-700 transition-colors;
}

.breadcrumb-current {
  @apply text-gray-900 font-medium;
}

.breadcrumb-separator {
  @apply text-gray-400 mx-2;
}
</style>

Pagination Navigation

---
// src/components/Pagination.astro

export interface Props {
  currentPage: number;
  totalPages: number;
  baseUrl: string;
  showFirstLast?: boolean;
  showPrevNext?: boolean;
  maxVisible?: number;
}

const {
  currentPage,
  totalPages,
  baseUrl,
  showFirstLast = true,
  showPrevNext = true,
  maxVisible = 5
} = Astro.props;

// Calculate visible page range
function getVisiblePages(current: number, total: number, max: number) {
  if (total <= max) {
    return Array.from({ length: total }, (_, i) => i + 1);
  }

  const half = Math.floor(max / 2);
  let start = Math.max(1, current - half);
  let end = Math.min(total, start + max - 1);

  if (end - start + 1 < max) {
    start = Math.max(1, end - max + 1);
  }

  return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}

const visiblePages = getVisiblePages(currentPage, totalPages, maxVisible);

function getPageUrl(page: number): string {
  if (page === 1) {
    return baseUrl;
  }
  return `${baseUrl}/page/${page}`;
}
---

{totalPages > 1 && (
  <nav class="pagination" aria-label="Pagination navigation">
    <div class="pagination-info">
      Page {currentPage} of {totalPages}
    </div>

    <ul class="pagination-list">
      <!-- First page -->
      {showFirstLast && currentPage > 1 && (
        <li>
          <a href={getPageUrl(1)} class="pagination-link">
            <span class="sr-only">First</span>
            <i class="icon-first"></i>
          </a>
        </li>
      )}

      <!-- Previous page -->
      {showPrevNext && currentPage > 1 && (
        <li>
          <a href={getPageUrl(currentPage - 1)} class="pagination-link">
            <span class="sr-only">Previous</span>
            <i class="icon-prev"></i>
          </a>
        </li>
      )}

      <!-- Page numbers -->
      {visiblePages.map((page) => (
        <li>
          {page === currentPage ? (
            <span class="pagination-current" aria-current="page">
              {page}
            </span>
          ) : (
            <a href={getPageUrl(page)} class="pagination-link">
              {page}
            </a>
          )}
        </li>
      ))}

      <!-- Next page -->
      {showPrevNext && currentPage < totalPages && (
        <li>
          <a href={getPageUrl(currentPage + 1)} class="pagination-link">
            <span class="sr-only">Next</span>
            <i class="icon-next"></i>
          </a>
        </li>
      )}

      <!-- Last page -->
      {showFirstLast && currentPage < totalPages && (
        <li>
          <a href={getPageUrl(totalPages)} class="pagination-link">
            <span class="sr-only">Last</span>
            <i class="icon-last"></i>
          </a>
        </li>
      )}
    </ul>
  </nav>
)}

<style>
.pagination {
  @apply flex flex-col sm:flex-row items-center justify-between gap-4 py-6;
}

.pagination-info {
  @apply text-sm text-gray-600;
}

.pagination-list {
  @apply flex items-center space-x-1;
}

.pagination-link {
  @apply flex items-center justify-center w-10 h-10 rounded-md;
  @apply text-gray-600 hover:text-gray-900 hover:bg-gray-100;
  @apply transition-colors duration-200;
}

.pagination-current {
  @apply flex items-center justify-center w-10 h-10 rounded-md;
  @apply bg-blue-600 text-white font-medium;
}

.sr-only {
  @apply sr-only;
}
</style>

View Transition Animations

Enable View Transitions

---
// src/layouts/BaseLayout.astro
import { ViewTransitions } from 'astro:transitions';
---

<html>
<head>
  <ViewTransitions />
</head>
<body>
  <slot />
</body>
</html>

Custom Transition Animations

---
// src/components/PageTransition.astro
import { fade, slide } from 'astro:transitions';
---

<div transition:animate={fade({ duration: '0.3s' })}>
  <slot />
</div>

<style>
/* Custom transition styles */
::view-transition-old(root) {
  animation: slideOut 0.3s ease-out forwards;
}

::view-transition-new(root) {
  animation: slideIn 0.3s ease-out;
}

@keyframes slideOut {
  from { transform: translateX(0); }
  to { transform: translateX(-100%); }
}

@keyframes slideIn {
  from { transform: translateX(100%); }
  to { transform: translateX(0); }
}
</style>

Conditional Transitions

---
// src/pages/blog/[slug].astro

// Select different transitions based on source page
const referrer = Astro.request.headers.get('referer');
const fromBlogIndex = referrer?.includes('/blog');

const transitionName = fromBlogIndex ? 'blog-detail' : 'slide';
---

<div transition:name={transitionName}>
  <article>
    <!-- Article content -->
  </article>
</div>

Route Performance Optimization

Smart Prefetch

---
// src/components/SmartLink.astro

export interface Props {
  href: string;
  prefetch?: 'hover' | 'visible' | 'load' | false;
  class?: string;
}

const { href, prefetch = 'hover', class: className, ...rest } = Astro.props;
---

<a
  href={href}
  class={className}
  data-astro-prefetch={prefetch}
  {...rest}
>
  <slot />
</a>

<script>
// Custom prefetch logic
document.addEventListener('DOMContentLoaded', () => {
  const links = document.querySelectorAll('a[data-astro-prefetch="hover"]');

  links.forEach(link => {
    let timeout: number;

    link.addEventListener('mouseenter', () => {
      timeout = setTimeout(() => {
        const href = link.getAttribute('href');
        if (href && !href.startsWith('#')) {
          // Prefetch page resources
          fetch(href, { method: 'HEAD' });
        }
      }, 100);
    });

    link.addEventListener('mouseleave', () => {
      clearTimeout(timeout);
    });
  });
});
</script>

Router Cache Strategy

// src/lib/router-cache.ts

interface CacheEntry {
  html: string;
  timestamp: number;
  etag?: string;
}

class RouterCache {
  private cache = new Map<string, CacheEntry>();
  private maxAge = 5 * 60 * 1000; // 5 minutes

  async get(url: string): Promise<string | null> {
    const entry = this.cache.get(url);

    if (!entry) return null;

    // Check if expired
    if (Date.now() - entry.timestamp > this.maxAge) {
      this.cache.delete(url);
      return null;
    }

    return entry.html;
  }

  set(url: string, html: string, etag?: string): void {
    this.cache.set(url, {
      html,
      timestamp: Date.now(),
      etag
    });

    // Limit cache size
    if (this.cache.size > 50) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
  }

  clear(): void {
    this.cache.clear();
  }
}

export const routerCache = new RouterCache();
Key Takeaways
  1. File System Routing: Leverage file structure to automatically generate routes, zero-config development
  2. Dynamic Parameters: Support dynamic routing through [param] syntax
  3. Nested Layouts: Use layout components to reuse page structures
  4. Navigation State: Accurately determine current page state, provide good user experience
  5. Performance Optimization: Use prefetch and cache strategies wisely
Considerations
  • Dynamic routes require pre-generating all possible paths via getStaticPaths
  • View transitions only work in supported browsers, provide fallback solutions
  • Balance prefetch strategy between user experience and server load
  • Consider i18n and dynamic content scenarios for breadcrumbs and navigation state