Proyek: Galeri Teroptimasi
Tugas: bangun galeri foto dengan next/image, metadata, dan sitemap.
Misi: galeri foto sekelas production
Ini proyek integrasi: kamu menggabungkan semua modul optimasi (gambar, font, metadata, sitemap) menjadi satu aplikasi nyata. Hasil akhirnya adalah galeri foto yang cepat, SEO-friendly, dan setiap fotonya punya halaman detail sendiri.
Kerjakan langkah demi langkah. Jangan loncat, tiap langkah adalah fondasi langkah berikutnya.
Langkah 1: siapkan data foto
Buat satu sumber data tunggal agar grid, halaman detail, dan sitemap selalu konsisten:
// lib/photos.ts
export type Photo = {
id: string;
title: string;
width: number;
height: number;
};
export const photos: Photo[] = [
{ id: "237", title: "Anjing hitam yang setia", width: 800, height: 600 },
{ id: "1025", title: "Pug yang menggemaskan", width: 800, height: 600 },
// ... total 12 foto dari picsum.photos
];
export function photoUrl(p: Photo, w = 800) {
return `https://picsum.photos/id/${p.id}/${w}/${Math.round((w * p.height) / p.width)}`;
}Catat width dan height asli tiap foto. Data ini wajib untuk next/image agar tidak terjadi layout shift (gambar melompat-lompat saat loading).
Langkah 2: izinkan domain gambar eksternal
next/image menolak URL eksternal yang tidak terdaftar. Ini langkah yang paling sering dilupakan pemula:
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
images: {
remotePatterns: [{ protocol: "https", hostname: "picsum.photos" }],
},
};
export default config;Tanpa ini kamu akan dapat error Invalid src prop dan gambar tidak tampil sama sekali.
Langkah 3: grid galeri dengan next/image
// app/page.tsx
import Image from "next/image";
import Link from "next/link";
import { photos, photoUrl } from "@/lib/photos";
export default function Galeri() {
return (
<main className="grid grid-cols-2 md:grid-cols-3 gap-4 p-4">
{photos.map((p, i) => (
<Link key={p.id} href={`/foto/${p.id}`}>
<Image
src={photoUrl(p, 600)}
alt={p.title}
width={600}
height={Math.round((600 * p.height) / p.width)}
sizes="(max-width: 768px) 50vw, 33vw"
priority={i < 3}
className="rounded-lg"
/>
</Link>
))}
</main>
);
}Tiga detail penting: alt yang deskriptif (aksesibilitas + SEO), sizes agar browser mengunduh ukuran yang pas untuk layar user, dan priority hanya untuk 3 gambar pertama yang terlihat tanpa scroll.
Langkah 4: halaman detail per foto
// app/foto/[id]/page.tsx
import Image from "next/image";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { photos, photoUrl } from "@/lib/photos";
export function generateStaticParams() {
return photos.map((p) => ({ id: p.id }));
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const foto = photos.find((p) => p.id === id);
return { title: foto ? `${foto.title} | Galeri` : "Foto tidak ditemukan" };
}
export default async function DetailFoto({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const foto = photos.find((p) => p.id === id);
if (!foto) notFound();
return (
<main className="p-4">
<h1>{foto.title}</h1>
<Image src={photoUrl(foto, 1200)} alt={foto.title} width={1200}
height={Math.round((1200 * foto.height) / foto.width)} priority />
</main>
);
}generateStaticParams membuat semua halaman detail di-build sekali saat deploy (super cepat). notFound() menangani ID yang tidak ada dengan halaman 404 yang rapi.
Langkah 5: font via next/font, tanpa link manual
// app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"], display: "swap" });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="id" className={inter.className}>
<body>{children}</body>
</html>
);
}Font diunduh saat build dan disajikan dari domainmu sendiri: tidak ada request ke Google Fonts saat user membuka halaman, tidak ada layout shift karena font swap.
Langkah 6: sitemap mencakup semua foto
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { photos } from "@/lib/photos";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: "https://galeriku.com", lastModified: new Date() },
...photos.map((p) => ({
url: `https://galeriku.com/foto/${p.id}`,
lastModified: new Date(),
})),
];
}Karena semua halaman memakai satu sumber data (lib/photos.ts), sitemap tidak akan pernah ketinggalan.
Langkah 7: uji dengan Lighthouse
Jalankan npm run build && npm start, lalu audit halaman galeri lewat tab Lighthouse di Chrome DevTools. Target: Performance 90+. Skor rendah biasanya karena gambar tanpa sizes, priority yang kebanyakan, atau font yang masih memakai <link> manual.
Tantangan
Riset picsum
Buka picsum.photos dan pahami format URL-nya (mis. /id/237/400/300). Siapkan daftar 12 URL untuk galerimu.
Tugas
Galeri Foto Teroptimasi
Bangun galeri foto: grid next/image, halaman detail dynamic + generateMetadata, sitemap.ts, font via next/font. Tulis kode lengkap file-file utamanya.
Kriteria penilaian:
- next/image dengan width/height dan alt yang benar
- generateMetadata dinamis per foto
- sitemap.ts mencakup semua halaman
- Tidak ada <img> mentah atau <link> font manual
AI tutor akan memeriksa bug, kesalahan syntax, dan memberi saran perbaikan.
Kuis Bab
Uji pemahamanmu: Optimasi & SEO
Jawab 5 soal berikut, lalu tekan "Periksa Jawaban".
1.Bagaimana cara mengatur title berbeda per halaman dengan pola sama?
2.Apa keuntungan utama next/image dibanding <img>?
3.Untuk berapa gambar sebaiknya memakai priority?
4.Di mana global CSS boleh di-import?
5.Apa isi minimal sitemap yang baik?