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:
- Keep it simple — jangan over-engineer
- Show your best work — kualitas > kuantitas
- Make it fast — loading speed matters
- Mobile first — 60%+ visitors dari mobile
Portfolio ini bisa jadi starting point. Customize sesuai kebutuhan dan style kamu.
Butuh bantuan? Open issue di GitHub!