Create a Dynamic Portfolio Site with Headless WordPress and Nuxt.js
Nuxt.js paired with headless WordPress creates an ideal stack for building portfolio websites that need both stunning visuals and easy content management. This combination delivers server-side rendering for SEO, automatic code splitting for performance, and a delightful developer experience with Vue.js.
In this comprehensive tutorial, we'll build a professional portfolio site featuring project showcases, case studies, client testimonials, and a blog—all managed through WordPress while leveraging Nuxt.js for a modern, performant frontend.
Table of Contents
- Portfolio Requirements Analysis
- Nuxt.js Project Setup
- GraphQL Integration
- Dynamic Page Generation
- Animation Integration
- Image Optimization
- SEO Implementation
Portfolio Requirements Analysis
Defining Portfolio Structure
A professional portfolio needs to showcase work effectively while maintaining easy content management. Here's our target structure:
Portfolio Site Structure:
├── Home
│ ├── Hero Section
│ ├── Featured Projects
│ ├── Services
│ └── Testimonials
├── Work
│ ├── Project Grid
│ ├── Category Filter
│ └── Project Details
├── About
│ ├── Bio
│ ├── Skills
│ └── Experience
├── Blog
│ ├── Article List
│ └── Article Details
└── Contact
└── Contact Form
WordPress Content Architecture
Custom Post Types
// functions.php - Register portfolio post types
function register_portfolio_post_types() {
// Projects post type
register_post_type('project', [
'labels' => [
'name' => 'Projects',
'singular_name' => 'Project',
'add_new' => 'Add New Project',
'edit_item' => 'Edit Project',
],
'public' => true,
'has_archive' => true,
'show_in_rest' => true,
'show_in_graphql' => true,
'graphql_single_name' => 'project',
'graphql_plural_name' => 'projects',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
'menu_icon' => 'dashicons-portfolio',
]);
// Testimonials post type
register_post_type('testimonial', [
'labels' => [
'name' => 'Testimonials',
'singular_name' => 'Testimonial',
],
'public' => true,
'show_in_rest' => true,
'show_in_graphql' => true,
'graphql_single_name' => 'testimonial',
'graphql_plural_name' => 'testimonials',
'supports' => ['title', 'editor', 'thumbnail', 'custom-fields'],
'menu_icon' => 'dashicons-format-quote',
]);
// Project categories
register_taxonomy('project_category', 'project', [
'labels' => [
'name' => 'Project Categories',
'singular_name' => 'Project Category',
],
'hierarchical' => true,
'show_in_rest' => true,
'show_in_graphql' => true,
'graphql_single_name' => 'projectCategory',
'graphql_plural_name' => 'projectCategories',
]);
// Skills taxonomy
register_taxonomy('skill', 'project', [
'labels' => [
'name' => 'Skills',
'singular_name' => 'Skill',
],
'hierarchical' => false,
'show_in_rest' => true,
'show_in_graphql' => true,
'graphql_single_name' => 'skill',
'graphql_plural_name' => 'skills',
]);
}
add_action('init', 'register_portfolio_post_types');
Advanced Custom Fields Setup
// ACF field groups for projects
if( function_exists('acf_add_local_field_group') ):
acf_add_local_field_group(array(
'key' => 'group_project_details',
'title' => 'Project Details',
'fields' => array(
array(
'key' => 'field_client_name',
'label' => 'Client Name',
'name' => 'client_name',
'type' => 'text',
'show_in_graphql' => true,
),
array(
'key' => 'field_project_url',
'label' => 'Project URL',
'name' => 'project_url',
'type' => 'url',
'show_in_graphql' => true,
),
array(
'key' => 'field_completion_date',
'label' => 'Completion Date',
'name' => 'completion_date',
'type' => 'date_picker',
'show_in_graphql' => true,
),
array(
'key' => 'field_project_gallery',
'label' => 'Project Gallery',
'name' => 'project_gallery',
'type' => 'gallery',
'show_in_graphql' => true,
),
array(
'key' => 'field_technologies_used',
'label' => 'Technologies Used',
'name' => 'technologies_used',
'type' => 'repeater',
'sub_fields' => array(
array(
'key' => 'field_technology',
'label' => 'Technology',
'name' => 'technology',
'type' => 'text',
),
),
'show_in_graphql' => true,
),
array(
'key' => 'field_case_study',
'label' => 'Case Study Content',
'name' => 'case_study',
'type' => 'flexible_content',
'layouts' => array(
array(
'key' => 'layout_text_block',
'name' => 'text_block',
'label' => 'Text Block',
'sub_fields' => array(
array(
'key' => 'field_text_content',
'label' => 'Content',
'name' => 'content',
'type' => 'wysiwyg',
),
),
),
array(
'key' => 'layout_image_block',
'name' => 'image_block',
'label' => 'Image Block',
'sub_fields' => array(
array(
'key' => 'field_image',
'label' => 'Image',
'name' => 'image',
'type' => 'image',
),
array(
'key' => 'field_caption',
'label' => 'Caption',
'name' => 'caption',
'type' => 'text',
),
),
),
),
'show_in_graphql' => true,
),
),
'location' => array(
array(
array(
'param' => 'post_type',
'operator' => '==',
'value' => 'project',
),
),
),
));
endif;
Nuxt.js Project Setup
Initial Setup and Configuration
# Create Nuxt 3 project
npx nuxi@latest init nuxt-portfolio
cd nuxt-portfolio
# Install dependencies
npm install @nuxtjs/apollo@next @nuxtjs/tailwindcss @nuxtjs/google-fonts
npm install @vueuse/nuxt @nuxtjs/seo
npm install gsap @gsap/scrolltrigger
npm install swiper vue-awesome-swiper
Nuxt Configuration
// nuxt.config.ts
export default defineNuxtConfig({
devtools: { enabled: true },
modules: [
'@nuxtjs/apollo',
'@nuxtjs/tailwindcss',
'@nuxtjs/google-fonts',
'@vueuse/nuxt',
'@nuxtjs/seo',
],
apollo: {
clients: {
default: {
httpEndpoint: process.env.NUXT_PUBLIC_WORDPRESS_GRAPHQL_ENDPOINT || 'http://localhost:8080/graphql',
httpLinkOptions: {
credentials: 'include'
}
}
}
},
googleFonts: {
families: {
Inter: [300, 400, 500, 600, 700],
'Playfair+Display': [400, 700, 900],
},
display: 'swap',
prefetch: true,
preconnect: true,
},
css: ['~/assets/css/main.css'],
app: {
head: {
htmlAttrs: {
lang: 'en',
},
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
],
},
pageTransition: { name: 'page', mode: 'out-in' },
layoutTransition: { name: 'layout', mode: 'out-in' },
},
nitro: {
prerender: {
routes: ['/sitemap.xml', '/'],
},
},
runtimeConfig: {
public: {
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000',
wordpressUrl: process.env.NUXT_PUBLIC_WORDPRESS_URL || 'http://localhost:8080',
},
},
})
Project Structure
nuxt-portfolio/
├── components/
│ ├── global/
│ │ ├── AppHeader.vue
│ │ ├── AppFooter.vue
│ │ └── AppLoader.vue
│ ├── home/
│ │ ├── HeroSection.vue
│ │ ├── FeaturedProjects.vue
│ │ └── Testimonials.vue
│ ├── portfolio/
│ │ ├── ProjectCard.vue
│ │ ├── ProjectGrid.vue
│ │ └── ProjectFilter.vue
│ └── ui/
│ ├── BaseButton.vue
│ ├── BaseCard.vue
│ └── BaseModal.vue
├── composables/
│ ├── useProjects.ts
│ ├── useAnimation.ts
│ └── useFilters.ts
├── layouts/
│ ├── default.vue
│ └── minimal.vue
├── pages/
│ ├── index.vue
│ ├── work/
│ │ ├── index.vue
│ │ └── [slug].vue
│ ├── about.vue
│ ├── blog/
│ │ ├── index.vue
│ │ └── [slug].vue
│ └── contact.vue
├── plugins/
│ └── gsap.client.ts
└── queries/
└── graphql.ts
GraphQL Integration
GraphQL Queries Definition
// queries/graphql.ts
export const PROJECT_FIELDS = gql`
fragment ProjectFields on Project {
id
slug
title
excerpt
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
projectCategories {
nodes {
id
name
slug
}
}
skills {
nodes {
id
name
slug
}
}
projectDetails {
clientName
projectUrl
completionDate
technologiesUsed {
technology
}
}
}
`;
export const GET_ALL_PROJECTS = gql`
${PROJECT_FIELDS}
query GetAllProjects($first: Int = 10, $after: String) {
projects(first: $first, after: $after, where: { status: PUBLISH }) {
pageInfo {
hasNextPage
endCursor
}
nodes {
...ProjectFields
}
}
}
`;
export const GET_PROJECT_BY_SLUG = gql`
query GetProjectBySlug($slug: ID!) {
project(id: $slug, idType: SLUG) {
id
title
content
excerpt
slug
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
projectDetails {
clientName
projectUrl
completionDate
projectGallery {
nodes {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
technologiesUsed {
technology
}
caseStudy {
... on ProjectDetailsContentBlocksTextBlock {
content
}
... on ProjectDetailsContentBlocksImageBlock {
image {
sourceUrl
altText
}
caption
}
}
}
projectCategories {
nodes {
name
slug
}
}
skills {
nodes {
name
slug
}
}
}
}
`;
export const GET_TESTIMONIALS = gql`
query GetTestimonials($first: Int = 6) {
testimonials(first: $first, where: { status: PUBLISH }) {
nodes {
id
title
content
featuredImage {
node {
sourceUrl
altText
}
}
testimonialDetails {
clientName
clientPosition
clientCompany
rating
}
}
}
}
`;
Composables for Data Fetching
// composables/useProjects.ts
import type { Project, ProjectCategory } from '~/types';
export const useProjects = () => {
const { $apollo } = useNuxtApp();
const fetchProjects = async (limit = 10, category?: string) => {
const variables: any = { first: limit };
if (category) {
variables.where = { projectCategoryName: category };
}
const { data } = await $apollo.default.query({
query: GET_ALL_PROJECTS,
variables,
});
return data.projects.nodes as Project[];
};
const fetchProjectBySlug = async (slug: string) => {
const { data } = await $apollo.default.query({
query: GET_PROJECT_BY_SLUG,
variables: { slug },
});
return data.project as Project;
};
const fetchProjectCategories = async () => {
const { data } = await $apollo.default.query({
query: GET_PROJECT_CATEGORIES,
});
return data.projectCategories.nodes as ProjectCategory[];
};
return {
fetchProjects,
fetchProjectBySlug,
fetchProjectCategories,
};
};
// composables/useFilters.ts
export const useFilters = () => {
const selectedCategory = ref<string | null>(null);
const selectedSkills = ref<string[]>([]);
const searchQuery = ref('');
const filterProjects = (projects: Project[]) => {
return projects.filter(project => {
// Category filter
if (selectedCategory.value && !project.projectCategories.nodes.some(
cat => cat.slug === selectedCategory.value
)) {
return false;
}
// Skills filter
if (selectedSkills.value.length > 0 && !project.skills.nodes.some(
skill => selectedSkills.value.includes(skill.slug)
)) {
return false;
}
// Search filter
if (searchQuery.value && !project.title.toLowerCase().includes(
searchQuery.value.toLowerCase()
)) {
return false;
}
return true;
});
};
return {
selectedCategory,
selectedSkills,
searchQuery,
filterProjects,
};
};
Dynamic Page Generation
Homepage Implementation
<!-- pages/index.vue -->
<template>
<div>
<HeroSection />
<FeaturedProjects :projects="featuredProjects" />
<ServicesSection />
<TestimonialsSection :testimonials="testimonials" />
<CTASection />
</div>
</template>
<script setup lang="ts">
import { GET_ALL_PROJECTS, GET_TESTIMONIALS } from '~/queries/graphql';
// Fetch data
const { data: projectsData } = await useAsyncQuery(GET_ALL_PROJECTS, {
first: 6,
});
const { data: testimonialsData } = await useAsyncQuery(GET_TESTIMONIALS, {
first: 3,
});
const featuredProjects = computed(() => projectsData.value?.projects.nodes || []);
const testimonials = computed(() => testimonialsData.value?.testimonials.nodes || []);
// SEO
useSeoMeta({
title: 'John Doe - Creative Developer & Designer',
description: 'Portfolio of John Doe, a creative developer specializing in web development, UI/UX design, and digital experiences.',
ogTitle: 'John Doe - Creative Developer & Designer',
ogDescription: 'Portfolio showcasing web development and design projects',
ogImage: '/og-image.jpg',
twitterCard: 'summary_large_image',
});
</script>
Portfolio Grid Page
<!-- pages/work/index.vue -->
<template>
<div class="min-h-screen bg-gray-50">
<div class="container mx-auto px-4 py-16">
<div class="text-center mb-12">
<h1 class="text-5xl font-bold mb-4 font-playfair">Our Work</h1>
<p class="text-xl text-gray-600 max-w-2xl mx-auto">
Explore our portfolio of digital experiences, creative solutions, and innovative projects.
</p>
</div>
<!-- Filters -->
<ProjectFilter
:categories="categories"
v-model:selected-category="selectedCategory"
v-model:search-query="searchQuery"
@filter-change="handleFilterChange"
/>
<!-- Projects Grid -->
<TransitionGroup
name="projects"
tag="div"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
<ProjectCard
v-for="project in filteredProjects"
:key="project.id"
:project="project"
@click="navigateToProject(project.slug)"
/>
</TransitionGroup>
<!-- Load More -->
<div v-if="hasMore" class="text-center mt-12">
<BaseButton
@click="loadMore"
:loading="loading"
variant="outline"
size="lg"
>
Load More Projects
</BaseButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { GET_ALL_PROJECTS, GET_PROJECT_CATEGORIES } from '~/queries/graphql';
// State
const projects = ref<Project[]>([]);
const categories = ref<ProjectCategory[]>([]);
const loading = ref(false);
const hasMore = ref(true);
const endCursor = ref<string | null>(null);
// Filters
const { selectedCategory, searchQuery, filterProjects } = useFilters();
// Fetch initial data
const { data: initialData } = await useAsyncQuery(GET_ALL_PROJECTS, {
first: 9,
});
const { data: categoriesData } = await useAsyncQuery(GET_PROJECT_CATEGORIES);
// Set initial data
projects.value = initialData.value?.projects.nodes || [];
hasMore.value = initialData.value?.projects.pageInfo.hasNextPage || false;
endCursor.value = initialData.value?.projects.pageInfo.endCursor || null;
categories.value = categoriesData.value?.projectCategories.nodes || [];
// Computed
const filteredProjects = computed(() => filterProjects(projects.value));
// Methods
const loadMore = async () => {
if (!hasMore.value || loading.value) return;
loading.value = true;
try {
const { data } = await $apollo.default.query({
query: GET_ALL_PROJECTS,
variables: {
first: 9,
after: endCursor.value,
},
});
projects.value.push(...data.projects.nodes);
hasMore.value = data.projects.pageInfo.hasNextPage;
endCursor.value = data.projects.pageInfo.endCursor;
} catch (error) {
console.error('Error loading more projects:', error);
} finally {
loading.value = false;
}
};
const navigateToProject = (slug: string) => {
navigateTo(`/work/${slug}`);
};
const handleFilterChange = () => {
// Optional: Track filter changes for analytics
console.log('Filters changed:', {
category: selectedCategory.value,
search: searchQuery.value,
});
};
// SEO
useSeoMeta({
title: 'Portfolio - Our Work',
description: 'Browse our portfolio of web development, design, and digital marketing projects.',
});
</script>
<style scoped>
.projects-move,
.projects-enter-active,
.projects-leave-active {
transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
}
.projects-enter-from {
opacity: 0;
transform: translate(0, 30px);
}
.projects-leave-to {
opacity: 0;
transform: scale(0.9);
}
.projects-leave-active {
position: absolute;
}
</style>
Project Detail Page
<!-- pages/work/[slug].vue -->
<template>
<article v-if="project" class="min-h-screen">
<!-- Hero Section -->
<section class="relative h-[70vh] overflow-hidden">
<NuxtImg
:src="project.featuredImage?.node.sourceUrl"
:alt="project.featuredImage?.node.altText || project.title"
class="w-full h-full object-cover"
loading="eager"
quality="90"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent" />
<div class="absolute bottom-0 left-0 right-0 p-8 md:p-16">
<div class="container mx-auto">
<h1 class="text-4xl md:text-6xl font-bold text-white mb-4 font-playfair">
{{ project.title }}
</h1>
<div class="flex flex-wrap gap-4 text-white/80">
<span v-if="project.projectDetails.clientName">
Client: {{ project.projectDetails.clientName }}
</span>
<span v-if="project.projectDetails.completionDate">
{{ formatDate(project.projectDetails.completionDate) }}
</span>
</div>
</div>
</div>
</section>
<!-- Project Info -->
<section class="py-16">
<div class="container mx-auto px-4">
<div class="grid lg:grid-cols-3 gap-12">
<!-- Main Content -->
<div class="lg:col-span-2">
<div class="prose prose-lg max-w-none" v-html="project.content" />
<!-- Case Study Blocks -->
<div v-if="project.projectDetails.caseStudy" class="mt-12 space-y-8">
<template v-for="(block, index) in project.projectDetails.caseStudy" :key="index">
<div v-if="block.__typename === 'ProjectDetailsContentBlocksTextBlock'"
class="prose prose-lg max-w-none"
v-html="block.content" />
<figure v-else-if="block.__typename === 'ProjectDetailsContentBlocksImageBlock'"
class="my-8">
<NuxtImg
:src="block.image.sourceUrl"
:alt="block.image.altText"
class="w-full rounded-lg shadow-xl"
loading="lazy"
/>
<figcaption v-if="block.caption" class="text-center text-gray-600 mt-4">
{{ block.caption }}
</figcaption>
</figure>
</template>
</div>
</div>
<!-- Sidebar -->
<aside class="lg:col-span-1">
<div class="sticky top-8 space-y-8">
<!-- Project Details -->
<div class="bg-gray-50 rounded-lg p-6">
<h3 class="text-xl font-semibold mb-4">Project Details</h3>
<dl class="space-y-4">
<div v-if="project.projectDetails.projectUrl">
<dt class="text-sm text-gray-600">Live Site</dt>
<dd>
<a :href="project.projectDetails.projectUrl"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-800">
Visit Website →
</a>
</dd>
</div>
<div v-if="project.projectCategories.nodes.length">
<dt class="text-sm text-gray-600">Categories</dt>
<dd class="flex flex-wrap gap-2 mt-1">
<span v-for="category in project.projectCategories.nodes"
:key="category.id"
class="px-3 py-1 bg-gray-200 rounded-full text-sm">
{{ category.name }}
</span>
</dd>
</div>
<div v-if="project.projectDetails.technologiesUsed?.length">
<dt class="text-sm text-gray-600">Technologies</dt>
<dd class="flex flex-wrap gap-2 mt-1">
<span v-for="tech in project.projectDetails.technologiesUsed"
:key="tech.technology"
class="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
{{ tech.technology }}
</span>
</dd>
</div>
</dl>
</div>
<!-- Share Buttons -->
<ShareButtons :url="currentUrl" :title="project.title" />
</div>
</aside>
</div>
</div>
</section>
<!-- Project Gallery -->
<section v-if="project.projectDetails.projectGallery?.nodes.length" class="py-16 bg-gray-50">
<div class="container mx-auto px-4">
<h2 class="text-3xl font-bold mb-8 text-center">Project Gallery</h2>
<ProjectGallery :images="project.projectDetails.projectGallery.nodes" />
</div>
</section>
<!-- Navigation -->
<ProjectNavigation :current-slug="project.slug" />
</article>
</template>
<script setup lang="ts">
import { GET_PROJECT_BY_SLUG } from '~/queries/graphql';
const route = useRoute();
const { $apollo } = useNuxtApp();
// Fetch project data
const { data, error } = await useAsyncQuery(GET_PROJECT_BY_SLUG, {
slug: route.params.slug,
});
if (error.value || !data.value?.project) {
throw createError({
statusCode: 404,
statusMessage: 'Project not found',
});
}
const project = computed(() => data.value.project);
const currentUrl = computed(() => `${useRuntimeConfig().public.siteUrl}${route.fullPath}`);
// Utilities
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
});
};
// SEO
useSeoMeta({
title: `${project.value.title} - Portfolio`,
description: project.value.excerpt || `Case study for ${project.value.title}`,
ogTitle: project.value.title,
ogDescription: project.value.excerpt,
ogImage: project.value.featuredImage?.node.sourceUrl,
ogType: 'article',
});
// Structured data
useSchemaOrg([
defineArticle({
headline: project.value.title,
description: project.value.excerpt,
image: project.value.featuredImage?.node.sourceUrl,
datePublished: project.value.date,
dateModified: project.value.modified,
}),
]);
</script>
Animation Integration
GSAP Plugin Setup
// plugins/gsap.client.ts
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { TextPlugin } from 'gsap/TextPlugin';
import { CustomEase } from 'gsap/CustomEase';
export default defineNuxtPlugin(() => {
if (process.client) {
gsap.registerPlugin(ScrollTrigger, TextPlugin, CustomEase);
// Custom eases
CustomEase.create('smooth', '0.4, 0, 0.2, 1');
CustomEase.create('bounce', '0.68, -0.55, 0.265, 1.55');
// Global defaults
gsap.defaults({
ease: 'smooth',
duration: 1,
});
// Refresh ScrollTrigger on route change
const nuxtApp = useNuxtApp();
nuxtApp.hook('page:finish', () => {
ScrollTrigger.refresh();
});
return {
provide: {
gsap,
ScrollTrigger,
},
};
}
});
Animation Composables
// composables/useAnimation.ts
export const useAnimation = () => {
const { $gsap, $ScrollTrigger } = useNuxtApp();
const animateOnScroll = (element: string | Element, options: GSAPTweenVars = {}) => {
if (!process.client) return;
const defaults = {
opacity: 0,
y: 50,
duration: 1,
scrollTrigger: {
trigger: element,
start: 'top 80%',
end: 'bottom 20%',
toggleActions: 'play none none reverse',
},
};
$gsap.from(element, { ...defaults, ...options });
};
const animateStagger = (elements: string | Element[], options: GSAPTweenVars = {}) => {
if (!process.client) return;
const defaults = {
opacity: 0,
y: 30,
duration: 0.8,
stagger: 0.1,
scrollTrigger: {
trigger: elements[0],
start: 'top 80%',
},
};
$gsap.from(elements, { ...defaults, ...options });
};
const animateHero = (elements: {
title?: string;
subtitle?: string;
cta?: string;
image?: string;
}) => {
if (!process.client) return;
const tl = $gsap.timeline();
if (elements.title) {
tl.from(elements.title, {
opacity: 0,
y: 100,
duration: 1.2,
ease: 'power4.out',
});
}
if (elements.subtitle) {
tl.from(elements.subtitle, {
opacity: 0,
y: 50,
duration: 1,
}, '-=0.8');
}
if (elements.cta) {
tl.from(elements.cta, {
opacity: 0,
scale: 0.9,
duration: 0.8,
}, '-=0.6');
}
if (elements.image) {
tl.from(elements.image, {
opacity: 0,
scale: 1.2,
duration: 1.5,
ease: 'power2.inOut',
}, 0);
}
return tl;
};
const createScrollProgress = (element: string | Element, onUpdate: (progress: number) => void) => {
if (!process.client) return;
$ScrollTrigger.create({
trigger: element,
start: 'top bottom',
end: 'bottom top',
scrub: true,
onUpdate: (self) => onUpdate(self.progress),
});
};
return {
animateOnScroll,
animateStagger,
animateHero,
createScrollProgress,
};
};
Animated Components
<!-- components/home/HeroSection.vue -->
<template>
<section ref="heroSection" class="relative h-screen overflow-hidden">
<!-- Background -->
<div ref="heroBg" class="absolute inset-0 z-0">
<NuxtImg
src="/hero-bg.jpg"
alt="Hero background"
class="w-full h-full object-cover"
loading="eager"
/>
<div class="absolute inset-0 bg-gradient-to-b from-black/50 to-black/70" />
</div>
<!-- Content -->
<div class="relative z-10 h-full flex items-center justify-center text-white">
<div class="text-center max-w-4xl mx-auto px-4">
<h1 ref="heroTitle" class="text-5xl md:text-7xl font-bold mb-6 font-playfair">
Creative Developer
<span class="block text-3xl md:text-5xl mt-2 text-blue-400">
& Digital Designer
</span>
</h1>
<p ref="heroSubtitle" class="text-xl md:text-2xl mb-8 opacity-90 max-w-2xl mx-auto">
Crafting beautiful digital experiences with modern technologies and creative design
</p>
<div ref="heroCta" class="flex flex-col sm:flex-row gap-4 justify-center">
<BaseButton to="/work" size="lg" variant="primary">
View Portfolio
</BaseButton>
<BaseButton to="/contact" size="lg" variant="outline">
Get in Touch
</BaseButton>
</div>
</div>
</div>
<!-- Scroll Indicator -->
<div ref="scrollIndicator" class="absolute bottom-8 left-1/2 transform -translate-x-1/2">
<div class="w-6 h-10 border-2 border-white/50 rounded-full relative">
<div class="w-1 h-3 bg-white rounded-full absolute left-1/2 top-2 transform -translate-x-1/2 animate-bounce" />
</div>
</div>
</section>
</template>
<script setup lang="ts">
const heroSection = ref<HTMLElement>();
const heroBg = ref<HTMLElement>();
const heroTitle = ref<HTMLElement>();
const heroSubtitle = ref<HTMLElement>();
const heroCta = ref<HTMLElement>();
const scrollIndicator = ref<HTMLElement>();
const { animateHero, createScrollProgress } = useAnimation();
onMounted(() => {
// Hero entrance animation
animateHero({
title: heroTitle.value,
subtitle: heroSubtitle.value,
cta: heroCta.value,
image: heroBg.value,
});
// Parallax effect on scroll
createScrollProgress(heroSection.value!, (progress) => {
if (heroBg.value) {
gsap.set(heroBg.value, {
y: progress * 100,
});
}
});
// Scroll indicator animation
gsap.from(scrollIndicator.value, {
opacity: 0,
y: -20,
duration: 1,
delay: 2,
repeat: -1,
yoyo: true,
});
});
</script>
Image Optimization
NuxtImg Configuration
<!-- components/portfolio/ProjectCard.vue -->
<template>
<article
ref="card"
class="group relative overflow-hidden rounded-lg bg-white shadow-lg cursor-pointer transform transition-all duration-300 hover:scale-105 hover:shadow-2xl"
@click="$emit('click')"
>
<!-- Image Container -->
<div class="relative aspect-[4/3] overflow-hidden">
<NuxtImg
:src="project.featuredImage?.node.sourceUrl"
:alt="project.featuredImage?.node.altText || project.title"
:width="project.featuredImage?.node.mediaDetails.width"
:height="project.featuredImage?.node.mediaDetails.height"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
loading="lazy"
format="webp"
quality="80"
fit="cover"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
:placeholder="[16, 12, 1, 1, 'blur']"
/>
<!-- Overlay -->
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<!-- Quick View -->
<div class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div class="bg-white/90 backdrop-blur-sm rounded-full p-3">
<Icon name="mdi:eye" class="w-5 h-5" />
</div>
</div>
</div>
<!-- Content -->
<div class="p-6">
<h3 class="text-xl font-semibold mb-2 group-hover:text-blue-600 transition-colors">
{{ project.title }}
</h3>
<p class="text-gray-600 text-sm mb-4 line-clamp-2">
{{ project.excerpt }}
</p>
<div class="flex items-center justify-between">
<div class="flex flex-wrap gap-2">
<span
v-for="category in project.projectCategories.nodes"
:key="category.id"
class="text-xs px-2 py-1 bg-gray-100 rounded-full"
>
{{ category.name }}
</span>
</div>
<Icon name="mdi:arrow-right" class="w-5 h-5 text-blue-600 transform group-hover:translate-x-2 transition-transform" />
</div>
</div>
</article>
</template>
<script setup lang="ts">
import type { Project } from '~/types';
interface Props {
project: Project;
}
defineProps<Props>();
defineEmits<{
click: [];
}>();
const card = ref<HTMLElement>();
const { animateOnScroll } = useAnimation();
onMounted(() => {
animateOnScroll(card.value!, {
opacity: 0,
y: 50,
duration: 0.8,
scrollTrigger: {
trigger: card.value,
start: 'top 85%',
},
});
});
</script>
Responsive Gallery Component
<!-- components/portfolio/ProjectGallery.vue -->
<template>
<div class="relative">
<!-- Gallery Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="(image, index) in images"
:key="index"
class="relative aspect-square overflow-hidden rounded-lg cursor-pointer group"
@click="openLightbox(index)"
>
<NuxtImg
:src="image.sourceUrl"
:alt="image.altText || `Gallery image ${index + 1}`"
:width="image.mediaDetails.width"
:height="image.mediaDetails.height"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
loading="lazy"
format="webp"
quality="85"
fit="cover"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors duration-300 flex items-center justify-center">
<Icon name="mdi:magnify-plus" class="w-10 h-10 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
</div>
</div>
<!-- Lightbox -->
<Teleport to="body">
<Transition name="lightbox">
<div
v-if="lightboxOpen"
class="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
@click="closeLightbox"
>
<div class="relative max-w-7xl max-h-[90vh] w-full">
<NuxtImg
:src="images[currentIndex].sourceUrl"
:alt="images[currentIndex].altText"
class="w-full h-full object-contain"
loading="eager"
quality="90"
/>
<!-- Navigation -->
<button
v-if="currentIndex > 0"
@click.stop="currentIndex--"
class="absolute left-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/20 rounded-full p-3 transition-colors"
>
<Icon name="mdi:chevron-left" class="w-8 h-8 text-white" />
</button>
<button
v-if="currentIndex < images.length - 1"
@click.stop="currentIndex++"
class="absolute right-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/20 rounded-full p-3 transition-colors"
>
<Icon name="mdi:chevron-right" class="w-8 h-8 text-white" />
</button>
<!-- Close button -->
<button
@click.stop="closeLightbox"
class="absolute top-4 right-4 bg-white/10 hover:bg-white/20 rounded-full p-3 transition-colors"
>
<Icon name="mdi:close" class="w-6 h-6 text-white" />
</button>
<!-- Image counter -->
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 text-white bg-black/50 px-4 py-2 rounded-full">
{{ currentIndex + 1 }} / {{ images.length }}
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
interface GalleryImage {
sourceUrl: string;
altText?: string;
mediaDetails: {
width: number;
height: number;
};
}
interface Props {
images: GalleryImage[];
}
const props = defineProps<Props>();
const lightboxOpen = ref(false);
const currentIndex = ref(0);
const openLightbox = (index: number) => {
currentIndex.value = index;
lightboxOpen.value = true;
document.body.style.overflow = 'hidden';
};
const closeLightbox = () => {
lightboxOpen.value = false;
document.body.style.overflow = '';
};
// Keyboard navigation
onMounted(() => {
const handleKeydown = (e: KeyboardEvent) => {
if (!lightboxOpen.value) return;
switch (e.key) {
case 'ArrowLeft':
if (currentIndex.value > 0) currentIndex.value--;
break;
case 'ArrowRight':
if (currentIndex.value < props.images.length - 1) currentIndex.value++;
break;
case 'Escape':
closeLightbox();
break;
}
};
window.addEventListener('keydown', handleKeydown);
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown);
});
});
</script>
<style scoped>
.lightbox-enter-active,
.lightbox-leave-active {
transition: opacity 0.3s ease;
}
.lightbox-enter-from,
.lightbox-leave-to {
opacity: 0;
}
</style>
SEO Implementation
SEO Configuration
// nuxt.config.ts - SEO module configuration
export default defineNuxtConfig({
site: {
url: 'https://portfolio.example.com',
name: 'John Doe Portfolio',
description: 'Creative developer and designer portfolio',
defaultLocale: 'en',
},
ogImage: {
enabled: true,
defaults: {
width: 1200,
height: 630,
type: 'image/jpeg',
quality: 90,
},
},
sitemap: {
sources: [
'/api/sitemap/projects',
'/api/sitemap/posts',
],
},
robots: {
UserAgent: '*',
Allow: '/',
Sitemap: 'https://portfolio.example.com/sitemap.xml',
},
});
Dynamic Sitemap Generation
// server/api/sitemap/projects.get.ts
import { defineSitemapEventHandler } from '#imports';
export default defineSitemapEventHandler(async (e) => {
const { $apollo } = useNuxtApp();
const { data } = await $apollo.default.query({
query: gql`
query GetAllProjectsSitemap {
projects(first: 1000, where: { status: PUBLISH }) {
nodes {
slug
modified
}
}
}
`,
});
return data.projects.nodes.map((project: any) => ({
loc: `/work/${project.slug}`,
lastmod: project.modified,
changefreq: 'monthly',
priority: 0.8,
}));
});
Structured Data Implementation
<!-- composables/useStructuredData.ts -->
export const useProjectStructuredData = (project: Project) => {
useSchemaOrg([
defineWebPage({
'@type': 'ItemPage',
name: project.title,
description: project.excerpt,
}),
defineCreativeWork({
'@type': 'CreativeWork',
name: project.title,
description: project.excerpt,
image: project.featuredImage?.node.sourceUrl,
dateCreated: project.date,
dateModified: project.modified,
creator: {
'@type': 'Person',
name: 'John Doe',
url: 'https://portfolio.example.com',
},
keywords: project.skills.nodes.map(s => s.name).join(', '),
}),
]);
};
Performance Optimization
Build Configuration
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
compressPublicAssets: true,
prerender: {
crawlLinks: true,
routes: ['/', '/work', '/about', '/contact'],
},
},
vite: {
css: {
preprocessorOptions: {
scss: {
additionalData: '@import "@/assets/scss/variables.scss";',
},
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
'gsap': ['gsap'],
'apollo': ['@apollo/client'],
},
},
},
},
},
experimental: {
payloadExtraction: false,
inlineSSRStyles: false,
},
});
Deployment
Vercel Deployment
// vercel.json
{
"buildCommand": "nuxt build",
"outputDirectory": ".output/public",
"framework": "nuxtjs",
"regions": ["iad1"],
"functions": {
"server/index.mjs": {
"maxDuration": 10
}
}
}
Conclusion
Building a portfolio site with headless WordPress and Nuxt.js combines the best of both worlds: a familiar content management system with a modern, performant frontend framework. The setup we've created provides:
- Exceptional performance through server-side rendering and optimized images
- Beautiful animations with GSAP integration
- SEO optimization with structured data and sitemaps
- Easy content management through WordPress
- Type safety with TypeScript
- Modern developer experience with Vue 3 and Nuxt 3
This foundation can be extended with additional features like: - Client portal with authentication - Blog section with comments - Contact form with email notifications - Analytics dashboard - Multi-language support
The headless approach gives you complete creative freedom while maintaining the content management capabilities that clients love.
Explore more headless WordPress possibilities in our Headless WordPress Guide for advanced patterns and best practices.