Portfolio website itu kartu nama digital kamu. Dulu aku bikin portfolio pakai WordPress — loading lambat, design terbatas, dan gak fleksibel. Sekarang pakai Next.js + Tailwind, portfolio loading < 1 detik dan design-nya exactly yang aku mau.

Di tutorial ini aku bakal jelasin cara bikin portfolio profesional dalam 1 hari, dari setup sampai deploy.

Kenapa Next.js + Tailwind?

Next.js:

  • Static site generation (loading super cepat)
  • Built-in SEO optimization
  • Image optimization otomatis
  • API routes kalau butuh backend
  • Deploy mudah ke Vercel

Tailwind CSS:

  • Utility-first (gak perlu nulis CSS dari nol)
  • Responsive design gampang
  • Dark mode built-in
  • File size kecil (purge unused CSS)
  • Customizable banget

Step 1: Setup Project

# Buat project baru
npx create-next-app@latest my-portfolio \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

cd my-portfolio
npm run dev

Buka http://localhost:3000 — Next.js starter page muncul.

Step 2: Project Structure

my-portfolio/
├── src/
│   ├── app/
│   │   ├── layout.tsx      # Root layout
│   │   ├── page.tsx        # Homepage
│   │   └── globals.css     # Global styles
│   ├── components/
│   │   ├── Navbar.tsx
│   │   ├── Hero.tsx
│   │   ├── About.tsx
│   │   ├── Projects.tsx
│   │   ├── Contact.tsx
│   │   └── Footer.tsx
│   └── lib/
│       └── data.ts         # Project data
├── public/
│   ├── images/
│   └── favicon.ico
└── tailwind.config.ts

Step 3: Root Layout

// src/app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Dovi - Full Stack Developer',
  description: 'Portfolio website Dovi - Full Stack Developer specializing in React, Next.js, and Node.js',
  keywords: ['developer', 'portfolio', 'react', 'nextjs'],
  openGraph: {
    title: 'Dovi - Full Stack Developer',
    description: 'Portfolio website Dovi',
    url: 'https://dovi.dev',
    siteName: 'Dovi Portfolio',
    locale: 'id_ID',
    type: 'website',
  },
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="id" className="scroll-smooth">
      <body className={`${inter.className} bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100`}>
        {children}
      </body>
    </html>
  )
}

Step 4: Navbar Component

// src/components/Navbar.tsx
'use client'

import { useState } from 'react'
import Link from 'next/link'

const navItems = [
  { label: 'Home', href: '#' },
  { label: 'About', href: '#about' },
  { label: 'Projects', href: '#projects' },
  { label: 'Contact', href: '#contact' },
]

export default function Navbar() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <nav className="fixed top-0 w-full bg-white/80 dark:bg-gray-900/80 backdrop-blur-md z-50 border-b border-gray-200 dark:border-gray-800">
      <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
        <div className="flex justify-between h-16">
          <div className="flex items-center">
            <Link href="/" className="text-xl font-bold text-blue-600">
              Dovi.dev
            </Link>
          </div>

          {/* Desktop */}
          <div className="hidden md:flex items-center space-x-8">
            {navItems.map((item) => (
              <Link
                key={item.href}
                href={item.href}
                className="text-gray-600 dark:text-gray-300 hover:text-blue-600 transition-colors"
              >
                {item.label}
              </Link>
            ))}
          </div>

          {/* Mobile toggle */}
          <div className="md:hidden flex items-center">
            <button
              onClick={() => setIsOpen(!isOpen)}
              className="text-gray-600 dark:text-gray-300"
            >
              {isOpen ? '✕' : '☰'}
            </button>
          </div>
        </div>

        {/* Mobile menu */}
        {isOpen && (
          <div className="md:hidden pb-4">
            {navItems.map((item) => (
              <Link
                key={item.href}
                href={item.href}
                className="block py-2 text-gray-600 dark:text-gray-300 hover:text-blue-600"
                onClick={() => setIsOpen(false)}
              >
                {item.label}
              </Link>
            ))}
          </div>
        )}
      </div>
    </nav>
  )
}

Step 5: Hero Section

// src/components/Hero.tsx
export default function Hero() {
  return (
    <section className="min-h-screen flex items-center justify-center px-4">
      <div className="max-w-4xl mx-auto text-center">
        <div className="mb-8">
          <div className="w-32 h-32 mx-auto rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-4xl font-bold">
            D
          </div>
        </div>

        <h1 className="text-4xl sm:text-6xl font-bold mb-6">
          Hi, I'm <span className="text-blue-600">Dovi</span>
        </h1>

        <p className="text-xl sm:text-2xl text-gray-600 dark:text-gray-300 mb-8">
          Full Stack Developer specializing in{' '}
          <span className="font-semibold text-blue-600">React</span>,{' '}
          <span className="font-semibold text-blue-600">Next.js</span>, and{' '}
          <span className="font-semibold text-blue-600">Node.js</span>
        </p>

        <div className="flex justify-center gap-4">
          <a
            href="#projects"
            className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
          >
            View Projects
          </a>
          <a
            href="#contact"
            className="px-8 py-3 border-2 border-blue-600 text-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
          >
            Contact Me
          </a>
        </div>
      </div>
    </section>
  )
}

Step 6: Projects Section

// src/components/Projects.tsx
import Image from 'next/image'

const projects = [
  {
    title: 'E-Commerce Platform',
    description: 'Full-stack e-commerce with Next.js, Stripe, and PostgreSQL',
    image: '/images/ecommerce.jpg',
    tags: ['Next.js', 'TypeScript', 'PostgreSQL', 'Stripe'],
    github: 'https://github.com/dovi/ecommerce',
    demo: 'https://ecommerce.dovi.dev',
  },
  {
    title: 'AI Chat App',
    description: 'Real-time chat with AI integration using OpenAI API',
    image: '/images/chatapp.jpg',
    tags: ['React', 'Node.js', 'Socket.io', 'OpenAI'],
    github: 'https://github.com/dovi/ai-chat',
    demo: 'https://chat.dovi.dev',
  },
  {
    title: 'Task Management',
    description: 'Kanban-style task manager with drag-and-drop',
    image: '/images/taskmanager.jpg',
    tags: ['Vue.js', 'Firebase', 'Tailwind CSS'],
    github: 'https://github.com/dovi/taskmanager',
    demo: 'https://tasks.dovi.dev',
  },
]

export default function Projects() {
  return (
    <section id="projects" className="py-20 px-4">
      <div className="max-w-6xl mx-auto">
        <h2 className="text-3xl font-bold text-center mb-12">Projects</h2>

        <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
          {projects.map((project) => (
            <div
              key={project.title}
              className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden shadow-lg hover:shadow-xl transition-shadow"
            >
              <div className="relative h-48">
                <Image
                  src={project.image}
                  alt={project.title}
                  fill
                  className="object-cover"
                />
              </div>

              <div className="p-6">
                <h3 className="text-xl font-semibold mb-2">{project.title}</h3>
                <p className="text-gray-600 dark:text-gray-300 mb-4">
                  {project.description}
                </p>

                <div className="flex flex-wrap gap-2 mb-4">
                  {project.tags.map((tag) => (
                    <span
                      key={tag}
                      className="px-3 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-sm"
                    >
                      {tag}
                    </span>
                  ))}
                </div>

                <div className="flex gap-4">
                  <a
                    href={project.github}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="text-gray-600 dark:text-gray-300 hover:text-blue-600"
                  >
                    GitHub 
                  </a>
                  <a
                    href={project.demo}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="text-blue-600 hover:text-blue-700"
                  >
                    Live Demo 
                  </a>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>
    </section>
  )
}

Step 7: Contact Form

// src/components/Contact.tsx
'use client'

import { useState } from 'react'

export default function Contact() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: '',
  })
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setIsSubmitting(true)

    // TODO: Implement actual form submission
    // Option 1: API route
    // Option 2: Formspree
    // Option 3: EmailJS

    setTimeout(() => {
      setSubmitStatus('success')
      setIsSubmitting(false)
      setFormData({ name: '', email: '', message: '' })
    }, 1000)
  }

  return (
    <section id="contact" className="py-20 px-4 bg-gray-100 dark:bg-gray-800">
      <div className="max-w-2xl mx-auto">
        <h2 className="text-3xl font-bold text-center mb-12">Get In Touch</h2>

        <form onSubmit={handleSubmit} className="space-y-6">
          <div>
            <label htmlFor="name" className="block text-sm font-medium mb-2">
              Name
            </label>
            <input
              type="text"
              id="name"
              value={formData.name}
              onChange={(e) => setFormData({ ...formData, name: e.target.value })}
              className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              required
            />
          </div>

          <div>
            <label htmlFor="email" className="block text-sm font-medium mb-2">
              Email
            </label>
            <input
              type="email"
              id="email"
              value={formData.email}
              onChange={(e) => setFormData({ ...formData, email: e.target.value })}
              className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              required
            />
          </div>

          <div>
            <label htmlFor="message" className="block text-sm font-medium mb-2">
              Message
            </label>
            <textarea
              id="message"
              rows={5}
              value={formData.message}
              onChange={(e) => setFormData({ ...formData, message: e.target.value })}
              className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              required
            />
          </div>

          <button
            type="submit"
            disabled={isSubmitting}
            className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
          >
            {isSubmitting ? 'Sending...' : 'Send Message'}
          </button>

          {submitStatus === 'success' && (
            <p className="text-green-600 text-center">Message sent successfully!</p>
          )}
          {submitStatus === 'error' && (
            <p className="text-red-600 text-center">Failed to send message. Try again.</p>
          )}
        </form>
      </div>
    </section>
  )
}

Step 8: Homepage Assembly

// src/app/page.tsx
import Navbar from '@/components/Navbar'
import Hero from '@/components/Hero'
import About from '@/components/About'
import Projects from '@/components/Projects'
import Contact from '@/components/Contact'
import Footer from '@/components/Footer'

export default function Home() {
  return (
    <main>
      <Navbar />
      <Hero />
      <About />
      <Projects />
      <Contact />
      <Footer />
    </main>
  )
}

Step 9: Tailwind Customization

// tailwind.config.ts
import type { Config } from 'tailwindcss'

const config: Config = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        primary: {
          50: '#eff6ff',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
        },
      },
      animation: {
        'fade-in': 'fadeIn 0.5s ease-in-out',
      },
      keyframes: {
        fadeIn: {
          '0%': { opacity: '0' },
          '100%': { opacity: '1' },
        },
      },
    },
  },
  plugins: [],
}
export default config

Step 10: Deploy ke Vercel

# 1. Push ke GitHub
git init
git add .
git commit -m "Initial portfolio"
git remote add origin https://github.com/dovi/my-portfolio.git
git push -u origin main

# 2. Deploy ke Vercel
# - Buka vercel.com
# - Login dengan GitHub
# - Import repository
# - Klik Deploy

# 3. Custom domain
# - Di Vercel dashboard → Settings → Domains
# - Tambahkan domain kamu
# - Update DNS records

Bonus: Performance Optimization

// Image optimization
import Image from 'next/image'

<Image
  src="/images/project.jpg"
  alt="Project"
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
/>

// Metadata untuk SEO
export const metadata: Metadata = {
  title: 'Dovi - Full Stack Developer',
  description: 'Portfolio...',
  openGraph: {
    images: ['/og-image.jpg'],
  },
}

Checklist Sebelum Deploy

  • Semua links working
  • Mobile responsive
  • Dark mode berfungsi
  • Images optimized
  • Meta tags lengkap
  • Favicon ada
  • Contact form berfungsi
  • Loading < 2 detik
  • No console errors

Conclusion

Portfolio website dengan Next.js + Tailwind bisa dibikin dalam 1 hari kalau kamu udah familiar dengan React. Yang paling penting:

  1. Keep it simple — jangan over-engineer
  2. Show your best work — kualitas > kuantitas
  3. Make it fast — loading speed matters
  4. Mobile first — 60%+ visitors dari mobile

Portfolio ini bisa jadi starting point. Customize sesuai kebutuhan dan style kamu.

Butuh bantuan? Open issue di GitHub!