By Devport Team | Last updated: 2025-07-12 | 22 min read

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

  1. Portfolio Requirements Analysis
  2. Nuxt.js Project Setup
  3. GraphQL Integration
  4. Dynamic Page Generation
  5. Animation Integration
  6. Image Optimization
  7. 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:

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.