For a portfolio, prerender the whole site. Nuxt can run as a Node server, and a portfolio has no reason to — the pages are the same for every visitor, so generating them at build time gives you a set of files that load instantly and cost almost nothing to serve.
The Nuxt tutorials cover building the site and the Nuxt documentation covers the deployment mechanics, and the decision between them is left to you. For a portfolio it is not a close call, but it is worth understanding why, because the same reasoning applies to the image handling that actually determines whether the site feels fast.
Table of contents
- Project structure
- Querying the content
- Prerender or server-render
- Images, which decide whether it feels fast
- The details that make it findable
- Deploying it
- How this fits the rest of the stack
- FAQ
Project structure
Start with the framework and add the two modules that do most of the work for a portfolio.
npx nuxi@latest init my-portfolio
cd my-portfolio
npx nuxi module add content
npx nuxi module add image
npm run dev
Nuxt Content lets you write projects as markdown files with frontmatter and query them like a database. Nuxt Image handles resizing and format conversion, which is the single most important thing on a portfolio.
A structure that works:
content/
projects/
redesign-for-client-a.md
internal-tool.md
about.md
pages/
index.vue
projects/
index.vue
[slug].vue
about.vue
components/
ProjectCard.vue
ProjectGallery.vue
public/
images/
assets/
css/
Each project is a markdown file with frontmatter for the metadata:
---
title: Redesign for Client A
description: A rebuild that cut load time by two thirds.
year: 2026
role: Design and build
cover: /images/client-a-cover.jpg
tags: [design, frontend]
featured: true
---
The advantage of this arrangement is that adding a project is adding a markdown file and some images, then pushing. That matters more than it sounds: a portfolio that is annoying to update is a portfolio that is out of date within a year.
Querying the content
Nuxt Content exposes a query API you use directly in your components.
<script setup>
const { data: projects } = await useAsyncData('projects', () =>
queryContent('/projects')
.where({ featured: true })
.sort({ year: -1 })
.find()
)
</script>
<template>
<section class="grid">
<ProjectCard
v-for="project in projects"
:key="project._path"
:project="project"
/>
</section>
</template>
For the individual project page, a dynamic route renders the markdown body:
<script setup>
const route = useRoute()
const { data: project } = await useAsyncData(route.path, () =>
queryContent(route.path).findOne()
)
</script>
<template>
<article v-if="project">
<h1>{{ project.title }}</h1>
<ContentRenderer :value="project" />
</article>
</template>
Note that this all runs at build time when the site is prerendered, which is the next section. Nothing queries anything at request time, so there is no database, no API, and nothing that can be slow for a visitor.
Prerender or server-render
Nuxt supports several output modes and the choice matters more than any other technical decision here.
Prerendering generates every page as HTML at build time. The result is a directory of files. Serving it requires nothing but a static host, pages arrive as fast as the network allows, there is no server to keep running, and there is nothing to attack.
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
prerender: {
crawlLinks: true,
routes: ['/'],
},
},
})
npm run generate # output in .output/public
Server-side rendering runs a Node process that renders pages per request. You need this when pages depend on the request — a logged-in user, personalised content, data that changes minute to minute.
A portfolio has none of those. Every visitor sees the same pages, and they change when you publish something rather than continuously. Prerendering is the correct answer, and it is also the cheapest: static hosting rather than a running service.
The one thing that pushes people toward SSR is a contact form, and it should not. A prerendered site can post a form to a form-handling service, a small serverless function, or a tiny separate endpoint. Running a Node server for the whole site to handle one form is a poor trade.
There is also ssr: false, which produces a client-rendered SPA. Avoid it for a portfolio — it means an empty initial HTML document, which is bad for search engines and bad for link previews, both of which matter when the point of the site is being found and shared.
Images, which decide whether it feels fast
A portfolio is mostly images, so image handling is not a detail. It is the main performance work.
Nuxt Image generates resized, reformatted variants at build time and emits the right markup:
<template>
<NuxtImg
:src="project.cover"
:alt="project.title"
width="1200"
height="800"
sizes="100vw sm:50vw lg:800px"
format="webp"
quality="80"
loading="lazy"
/>
</template>
What each part is doing:
- width and height reserve the space, so the layout does not shift as images arrive. This is a Core Web Vitals metric and it is also just less irritating to look at.
- sizes with breakpoints generates a srcset, so a phone downloads a small file rather than a desktop-sized one.
- format webp or avif cuts file size substantially at the same visual quality.
- quality 80 is close to indistinguishable from 100 and considerably smaller.
- loading lazy defers anything below the fold. Set the hero image to eager instead, or it delays the largest contentful paint.
Configure it once in nuxt.config so defaults apply everywhere:
export default defineNuxtConfig({
image: {
format: ['avif', 'webp'],
quality: 80,
screens: { sm: 640, md: 768, lg: 1024, xl: 1280 },
},
})
With prerendering, all of this happens at build time, so the variants are static files. The build takes longer and every visitor gets the benefit.
The details that make it findable
A portfolio exists to be found and shared, which makes a small amount of metadata work disproportionately valuable.
<script setup>
useSeoMeta({
title: () => `${project.value.title} — Your Name`,
description: () => project.value.description,
ogTitle: () => project.value.title,
ogDescription: () => project.value.description,
ogImage: () => `https://yourdomain.com${project.value.cover}`,
twitterCard: 'summary_large_image',
})
</script>
The Open Graph image is the one people skip and the one with the most visible effect: it is what appears when somebody shares your work in a message or on a social platform. A link with no preview image gets noticeably less attention than one with a good one.
Add a sitemap and robots file — there are Nuxt modules for both, and each is one line of configuration.
Write real alt text on every image. It is required for accessibility, it is read by search engines, and on a visual portfolio it is the only description of your work that a machine can read.
Deploying it
A prerendered Nuxt site is a directory of static files, which makes deployment straightforward.
The pieces you need: a build from your repository running npm run generate, the resulting .output/public directory served as the site root, a custom domain, and a certificate. Static site hosting on RunxBuild covers exactly this shape — build from the repo, custom domains, headers, redirects, and SPA fallback, with 120GB of bandwidth included and $0.10 per GB after.
Two things to configure beyond the defaults.
Caching headers. Hashed build assets can be cached for a year because their filenames change when the contents do. HTML should be cached briefly or revalidated, so a new deploy is visible immediately. Getting this right is the difference between repeat visits being instant and being full downloads.
Your own domain, from the start. A portfolio on a platform subdomain builds recognition for the platform, and every link you hand out breaks if you move. Register the domain, point it at whatever hosts the site today, and the address survives every future change.
Then check the built output before announcing it: run the production build locally, open it, and confirm the pages are real HTML with content in them rather than an empty shell waiting for JavaScript.
How this fits the rest of the stack
A Nuxt portfolio is a content directory, a prerender step, and disciplined image handling — and those three decisions cover almost everything that determines whether it loads instantly or slowly. Prerender it, size the images, put it on your own domain, and set the cache headers once. Static hosting on RunxBuild builds from the repository with custom domains and 120GB of bandwidth included, and the RunxBuild hosting calculator shows what that comes to alongside anything else the project needs.
Useful related references:
- How to Start a Photography Business: The Portfolio Is the Storefront
- Freelancer Portfolio Websites: Getting One Online, Not Just Designing It
- Builds on RunxBuild
FAQ
Should a Nuxt portfolio be static or server-rendered?
Static. Prerender every page with npm run generate. A portfolio shows the same content to every visitor and changes when you publish, so there is nothing for a server to compute per request. You get faster pages, cheaper hosting, and no running process to maintain or secure.
How do I deploy a prerendered Nuxt site?
Run npm run generate and serve the .output/public directory as the site root on any static host. Configure a custom domain and certificate, then set cache headers — long for hashed assets, short or revalidated for HTML so a new deploy appears immediately.
Do I need Nuxt Content for a portfolio?
Not strictly, but it makes updating far easier. Projects become markdown files with frontmatter that you query like a database, so adding one is adding a file and pushing. The alternative — hand-editing components for each project — is what leads to portfolios that are two years out of date.
How do I make a Nuxt portfolio load faster?
Images are almost the whole answer. Use NuxtImg with explicit width and height, a sizes attribute for responsive srcset, WebP or AVIF format, and quality around 80. Lazy-load everything except the hero image. With prerendering, all variants are generated at build time so visitors get static files.
Can a static Nuxt site have a contact form?
Yes. Post the form to a form-handling service, a small serverless function, or a separate endpoint. Switching the entire site to server-side rendering to handle one form means running a Node process continuously for something that happens a few times a week.