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="" />
| Directive | Effect |
|---|---|
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:persist | Keep 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 sharedtransition:namevalues 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/sizesandformat="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
sitein 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.