# Build a fast portfolio website with vagejs

> A walkthrough for designers and photographers: project pages with checked fields, a work index that sorts itself and phone-sized images.

A fast portfolio website needs three things: project pages that all have the same fields, an index that sorts itself, and photos in phone-sized copies. This walkthrough builds all three with vagejs.

5 November 2026 · 7 min read · by [Venkatesh A G E](#author)

A portfolio has one job: show your best work quickly, to someone who'll give it about thirty seconds. That makes it a demanding kind of website. It's mostly large images, it's often opened on a phone from a link in an email, and it has to stay easy to update when you finish a new project. In this walkthrough we build one with vagejs: a work index that sorts itself, project pages with fields the build checks for you, and images that come in phone-sized copies.

We built the example below, a portfolio for a made-up designer called Alex Moreno, with vagejs 0.3.0. The files and terminal output are from that run.

## The plan

Four kinds of pages:

-   **Home:** who you are, in two sentences.
-   **Work (`/work/`):** every project, newest first.
-   **One page per project (`/work/<name>/`):** the client, the year, your role, a few words and the pictures.
-   **About:** a short bio and how to reach you.

Start with a new site:

terminal

```
npm create vagejs@latest alex-portfolio
cd alex-portfolio
npm install
npm install --save-dev sharp
```

`sharp` is the image library vagejs uses to make the smaller copies of your photos. You'll turn that on in Step 4. The starter comes with a blog; if you don't want one, delete `src/pages/blog/` and `src/layouts/blog.ts`, and change the "Blog" link in `src/layouts/default.ts` to `<a href="/work/">Work</a>`.

## Step 1: Give every project the same fields

Project pages should all have the same facts: client, year, role. With vagejs, a layout can declare the fields its pages must have, and the build refuses any page that gets them wrong. Create `src/layouts/project.ts`:

TypeScript

```
import { z, type Layout } from "vagejs";
import defaultLayout from "./default.ts";

// Fields every project page must have. A typo or a missing year fails the build.
export const frontmatter = z.object({
  client: z.string(),
  year: z.number().int(),
  role: z.string(),
});

const layout: Layout = (props) => defaultLayout(props);
export default layout;
```

This layout looks exactly like the default one; its only job is the field list. `z` is a small validation library (Zod) that vagejs re-exports for exactly this. [Templates and layouts](https://vagejs.com/docs/templates-and-layouts/) covers layout fields in more depth.

## Step 2: Write a project page

Put your images in `public/images/work/`, then write `src/pages/work/trail-maps.md`:

```
---
title: Coastal trail maps
description: Folding walking maps for a coastal path charity, designed to be read in the rain and on a phone screen.
layout: project
client: Coast Path Trust
year: 2024
role: Cartography and layout
---

# Coastal trail maps

Four maps, one style.

![A folded trail map open on a picnic table, showing the cliff path in orange](/images/work/trail-maps.jpg)
```

Write real alt text. It's what screen readers read aloud, and it's what search engines and AI tools understand about the picture. `vage check` fails on any image with no alt at all.

Now the payoff. We misspelled `year` as `yaer` on one project, and the check caught it:

```
error   page-error             src/pages/work/cafe-menus.md
        Frontmatter doesn't match the "project" layout's fields:
✖ Unrecognized key: "yaer"
✖ Invalid input: expected number, received undefined
  → at year

Checked 7 page(s): 1 error(s), 0 warning(s).
Passing checks do not guarantee indexing, rankings, rich results or AI citations.
```

One more trap we fell into, and so might you. A description with a colon in it, like `A new identity for a small ferry company: logo, timetable design and signs`, isn't valid YAML (the format frontmatter is written in). The check stopped us:

```
error   page-error             src/pages/work/harbour-rebrand.md
        Invalid YAML frontmatter: Nested mappings are not allowed in compact mappings at line 2, column 14:

description: A new identity for a small ferry company: logo, timetable design a…
             ^
```

The fix is to wrap the value in double quotes: `description: "A new identity for a small ferry company: logo, ..."`.

## Step 3: A work index that sorts itself

Create `src/layouts/work.ts`:

TypeScript

```
import { collection, html, raw, type Layout } from "vagejs";
import defaultLayout from "./default.ts";

// The work index: every project, newest year first.
const layout: Layout = (props) => {
  const projects = collection(props.pages, {
    under: "/work/",
    sort: (a, b) => Number(b.frontmatter.year) - Number(a.frontmatter.year),
  });
  const list = html`<ul>${projects.map(
    (p) => html`<li><a href="${p.route}">${p.title}</a>: ${p.frontmatter.client}, ${p.frontmatter.year}. ${p.description}</li>`,
  )}</ul>`;
  return defaultLayout({ ...props, content: String(html`${raw(props.content)}${list}`) });
};

export default layout;
```

And `src/pages/work/index.md` with `layout: work` in its frontmatter. `collection()` picks every page under `/work/` (but not the index itself), and our own sort function puts the newest year first. Add a project file and it appears in the list; delete one and it's gone. Nobody edits a list by hand, so the list can't fall out of date.

## Step 4: Make the images phone-friendly

Portfolio photos are big, and phones don't need all those pixels. Add one line to `vage.config.ts`:

TypeScript

```
images: { optimize: true },
```

For each image in your page content, vagejs now makes resized WebP copies (480, 960 and 1600 pixels wide by default) and lists them in `srcset`. The browser picks the one that suits the screen. Here's the image from our trail maps page after the build:

HTML

```
<img src="/images/work/trail-maps.jpg" alt="A folded trail map open on a picnic table, showing the cliff path in orange" srcset="/_vage/img/trail-maps-cabf321c7e-480.webp 480w, /_vage/img/trail-maps-cabf321c7e-960.webp 960w, /_vage/img/trail-maps-cabf321c7e-1600.webp 1600w" sizes="(max-width: 48rem) 100vw, 48rem" width="2400" height="1600" decoding="async">
```

![A 2,400-pixel photo turned into three WebP copies at 480, 960 and 1,600 pixels, with the browser picking the one that fits the screen](https://vagejs.com/images/blog/build-portfolio-website-vagejs-srcset.svg)

A few details are worth knowing:

-   **Width and height are filled in from the file**, so the browser saves the right amount of space and the page doesn't jump as photos arrive. This works even without `optimize`.
-   **The first image loads straight away; later ones are lazy-loaded**, so they wait until the visitor scrolls near them.
-   **Nothing is enlarged.** A photo narrower than a copy size simply doesn't get that copy.
-   **Copies are cached** between builds, so rebuilding a big portfolio stays quick.

You can change the sizes, quality and `sizes` attribute under `images` in the config; see [configuration](https://vagejs.com/docs/configuration/).

## Step 5: Check the page weight

`npx vage audit` builds the site and measures every page against size budgets. We deliberately used heavy 2,400-pixel test images (about 2.8 MB each) to see what happens. Here's the start of the output:

```
  /                            total    1.6 KB  js 0.0 KB  css 0.0 KB  img 0.0 KB  font 0.0 KB  1 req
  /about/                      total    1.4 KB  js 0.0 KB  css 0.0 KB  img 0.0 KB  font 0.0 KB  1 req
  /work/                       total    2.0 KB  js 0.0 KB  css 0.0 KB  img 0.0 KB  font 0.0 KB  1 req
  /work/cafe-menus/            total 2853.1 KB  js 0.0 KB  css 0.0 KB  img 2851.2 KB  font 0.0 KB  2 req
  /work/harbour-rebrand/       total 2852.2 KB  js 0.0 KB  css 0.0 KB  img 2850.2 KB  font 0.0 KB  2 req
  /work/trail-maps/            total 2851.9 KB  js 0.0 KB  css 0.0 KB  img 2850.0 KB  font 0.0 KB  2 req
  /404/                        total    1.4 KB  js 0.0 KB  css 0.0 KB  img 0.0 KB  font 0.0 KB  1 req

warning budget                 /work/cafe-menus/
        image is 2851.2 KB, over the 500 KB budget (performance.budgets.imageKb).
warning budget                 /work/cafe-menus/
        total is 2853.1 KB, over the 1000 KB budget (performance.budgets.totalKb).
```

The same two warnings follow for the other two projects, then some general notes. Notice the `js 0.0 KB` column: the portfolio ships no JavaScript at all.

Be aware of one honest quirk here. In 0.3.0, the audit measures the original file named in the image's `src`, even when `optimize` is on and browsers download a smaller WebP copy instead. So these warnings overstate what a phone actually downloads. Since 0.4, the audit counts the largest copy a browser would load instead, so its numbers match what visitors download. Either way, the practical fix is the one that helps everyone anyway: export your photos at a sensible size (2,000 pixels wide is plenty for most layouts) before you add them.

## Step 6: About and contact

`src/pages/about.md` is plain Markdown. For contact, ordinary email and phone links work and pass the check:

```
Email [hello@alexmoreno.example.net](mailto:hello@alexmoreno.example.net) or call [+44 20 7946 0000](tel:+442079460000).
```

Links to other websites (your social profiles, say) need approving once in `links.lock.json` with `npx vage links approve <domain>`. That's vagejs's protection against spam links; [security](https://vagejs.com/docs/security/) explains why.

## What won't vagejs do for a portfolio?

-   **No lightbox or carousel.** Click-to-zoom galleries and sliders need JavaScript. vagejs doesn't include one; you can add a script to a layout, at the cost of page weight.
-   **Only images in page content are optimized.** If a layout writes its own `<img>` tags (say, cover thumbnails on the work index), those aren't resized, and you should give them `width` and `height` yourself.
-   **AVIF is one more setting.** Since 0.4, `images: { optimize: true, avif: true }` also makes AVIF copies and offers them first, with WebP as the fallback.
-   **No contact form in page content.** Forms are removed from Markdown for safety. On Cloudflare Workers, vagejs has a built-in [contact form](https://vagejs.com/docs/deployment/#contact-form) for a layout; elsewhere, use email and phone links, or link to a form service you trust.
-   **Video embeds** belong in a layout, not in Markdown, because iframes are stripped from content.

## Portfolio build checklist

-   A layout with `export const frontmatter = z.object(...)` makes the build check every project's client, year and role.
-   `collection()` with a sort function gives you a work index that's never out of date.
-   `images: { optimize: true }` plus `sharp` makes 480, 960 and 1600 pixel WebP copies with `srcset`, and every local content image gets width and height.
-   `vage audit` measures each page; in 0.3.0 it counts the original image, so resize big photos before adding them.
-   Quote any frontmatter value with a colon in it.

Want to try it? [Getting started](https://vagejs.com/docs/getting-started/) has you running in a minute, and the [content authoring guide](https://vagejs.com/docs/content-authoring/) covers images in more detail.

![](https://vagejs.com/images/venkatesh-age-144.webp)

Written by

Venkatesh A G E

Web developer in Bengaluru, India, and the maker of vagejs. Helps startups turn their websites into a real source of customers, with a focus on clarity, technical SEO and AI search.

[LinkedIn](https://www.linkedin.com/in/ageven/)[About](https://vagejs.com/about/)
