Skip to content
Astro projects 4 min read

Project: Portfolio Site

A portfolio is where Astro shines: it ships almost no JavaScript, loads instantly, and still feels app-like thanks to native view transitions. In this project you’ll build a polished personal portfolio with smooth page-to-page animations, automatically optimized images, JSON-LD structured data, and the SEO fundamentals that get your work indexed and shared. Every snippet uses real, modern Astro 5 APIs you can drop straight into a fresh project.

Project structure

Scaffold a project and add the integrations a portfolio needs. The @astrojs/sitemap integration generates a sitemap, and the built-in astro:assets module handles image optimization with no extra package.

npm create astro@latest portfolio
cd portfolio
npm install @astrojs/sitemap sharp

A portfolio keeps shared chrome in layouts, projects in a content collection, and a flat set of top-level pages:

src/
  content/
    config.ts          # projects schema
    projects/
      design-system.md
      analytics-app.md
  components/
    Seo.astro
  layouts/
    BaseLayout.astro
  pages/
    index.astro
    about.astro
    work/[slug].astro
  assets/
    hero.jpg

Enabling view transitions

Astro’s <ClientRouter /> (formerly <ViewTransitions />) intercepts same-origin navigations and animates between pages using the browser’s View Transitions API, falling back gracefully where it isn’t supported. Add it once in your base layout’s <head> and every page transition becomes smooth, with no per-page wiring.

---
import { ClientRouter } from "astro:transitions";

const { title, description } = Astro.props;
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>
    <ClientRouter />
  </head>
  <body>
    <slot />
  </body>
</html>

You can name elements so a shared item morphs across pages, like a project thumbnail expanding into a hero image. Matching transition:name values on both pages tells the browser to animate between them.

<img src={thumb.src} transition:name={`cover-${slug}`} alt="" />
DirectiveEffect
transition:animate="fade"Cross-fade the element (the default)
transition:animate="slide"Slide old out and new in
transition:name="hero"Pair elements across pages to morph between them
transition:persistKeep an element (e.g. audio, video) alive across navigation

View transitions degrade safely: browsers without support simply do a normal navigation, so you never need a polyfill or a JavaScript fallback.

Optimizing images

Import images as modules and render them through the <Image /> component from astro:assets. Astro generates resized, modern-format files at build time and writes the correct width, height, loading, and decoding attributes to prevent layout shift.

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

<Image
  src={hero}
  alt="Portrait of the author at a desk"
  widths={[400, 800, 1200]}
  sizes="(max-width: 800px) 100vw, 800px"
  format="avif"
  loading="eager"
/>

For above-the-fold art use loading="eager"; everything else stays lazy by default. The widths and sizes props emit a responsive srcset so phones download small files and desktops get sharp ones.

SEO and structured data

Centralize meta tags in a single Seo.astro component so every page sets a canonical URL, Open Graph card, and Twitter summary consistently. Resolve absolute URLs with new URL() against Astro.site.

---
interface Props {
  title: string;
  description: string;
  image?: string;
}
const { title, description, image = "/og-default.png" } = Astro.props;
const canonical = new URL(Astro.url.pathname, Astro.site);
const ogImage = new URL(image, Astro.site);
---

<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />

<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImage} />
<meta property="og:url" content={canonical} />
<meta name="twitter:card" content="summary_large_image" />

Add a JSON-LD Person block so search engines understand who the site belongs to. Render it with set:html to inject raw, un-escaped JSON.

---
const person = {
  "@context": "https://schema.org",
  "@type": "Person",
  name: "Ada Lovelace",
  url: "https://example.com",
  jobTitle: "Frontend Engineer",
  sameAs: ["https://github.com/ada", "https://linkedin.com/in/ada"],
};
---

<script type="application/ld+json" set:html={JSON.stringify(person)} />

Finally, wire up the sitemap and set site so canonical links, Open Graph URLs, and the sitemap all resolve to absolute paths.

import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";

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

Build the site to confirm everything is generated:

npm run build

Output:

12:01:14 [@astrojs/sitemap] `sitemap-index.xml` created at `dist`
12:01:14 [build] 7 page(s) built in 1.84s
12:01:14 [build] Complete!

Best practices

  • Add <ClientRouter /> once in the base layout rather than per page, and use shared transition:name values to morph thumbnails into hero images.
  • Always import images so <Image /> can optimize them; reserve raw <img> only for remote URLs you can’t process at build time.
  • Set explicit widths/sizes and format="avif" on responsive images to cut bytes and avoid cumulative layout shift.
  • Funnel all meta tags through one SEO component so canonical, Open Graph, and Twitter tags never drift out of sync.
  • Set site in the config so canonical URLs, OG images, and the sitemap are absolute and indexable.
  • Keep the site statically rendered for instant loads, and add JSON-LD structured data so search and social previews look professional.
Last updated June 14, 2026
Was this helpful?