Membuat CMS Blog Personal dengan Next.js, Better Auth, dan Supabase - Tutorial Lengkap untuk Pemula (Part 2)

Halo teman-teman BuildWithAngga! Selamat datang di bagian 2 dari tutorial CMS blog personal kita. Kalau di bagian 1 kita udah berhasil bikin fondasi yang solid dengan Next.js, Better Auth, dan Supabase, sekarang saatnya kita tambahin fitur yang paling ditunggu-tunggu yaitu rich text editor yang keren.

Bayangin aja, CMS tanpa editor yang bagus itu kayak mobil tanpa setir. Secara teknis sih bisa jalan, tapi pengalaman nulisnya bakal frustasi banget. Makanya di bagian 2 ini kita bakal fokus banget ke pengalaman pengguna dalam menulis artikel. Kita mau bikin editor yang ga cuma powerful, tapi juga nyaman dipake sehari-hari.

Apa yang Bakal Kita Pelajari

Di chapter ini, kita bakal implementasi TipTap Simple Editor yang merupakan salah satu rich text editor terbaik untuk ekosistem React. Yang bikin TipTap menarik adalah pendekatannya yang modular dan headless-first, jadi kita punya kontrol penuh gimana editor kita keliatan dan berperilaku.

Kita ga bakal bikin editor dari nol, tapi bakal pakai template resmi dari tim TipTap yang udah terbukti stabil dan siap produksi. Template ini udah include semua fitur penting yang dibutuhin pembuat konten modern, mulai dari formatting dasar sampai fitur lanjutan kayak syntax highlighting untuk code blocks.

Yang paling penting, kita bakal fokus ke kesederhanaan tanpa ngorbanin fungsionalitas. Jadi editor kita bakal ramah pengguna untuk pemula, tapi tetep powerful untuk kebutuhan lanjutan. Keseimbangan sempurna untuk blog BuildWithAngga yang target audiencenya developer dari berbagai level.

Kenapa TipTap Simple Template

Daripada bikin custom editor dari awal atau pakai library yang berat, kita pilih TipTap Simple Template karena beberapa alasan strategis. Pertama, dia udah teruji di ribuan aplikasi produksi. Kedua, template ini actively maintained sama tim TipTap, jadi kita dapet perbaikan bug dan improvements secara reguler.

Template CLI dari TipTap juga kasih kita titik mulai yang optimal dengan konfigurasi yang udah disetel dengan baik. Semua extensions yang include udah ditest compatibility-nya, jadi kita ga perlu khawatir tentang konflik atau masalah performa. Plus, dokumentasinya lengkap banget kalau nanti mau extend functionality.

Pendekatan kita bakal praktis banget. Install template pakai CLI command, sesuaikan dikit sesuai kebutuhan BuildWithAngga, terus langsung integrate ke CMS kita. Sederhana, cepat, dan hasil yang profesional.

Ready untuk mengubah CMS kita jadi platform pembuatan konten yang powerful? Mari kita mulai dengan setup TipTap Simple Editor yang bakal jadi game-changer untuk blog BuildWithAngga kita!

Rich Text Editor dengan TipTap Simple Template

Tiptap - Dev Toolkit Editor Suite

Tiptap - Dev Toolkit Editor Suite

TipTap Simple Editor adalah template resmi yang udah include semua fitur dasar untuk blog editor. Kita bakal pakai CLI command untuk setup yang cepat dan mudah.

Jalankan command ini di root project:

npx @tiptap/cli@latest init simple-editor

Command ini akan otomatis:

  • Install dependencies yang dibutuhin
  • Generate template di src/components/tiptap-templates/simple/
  • Setup styling dan configuration

Setup Styling yang Wajib

Setelah CLI selesai, akan muncul warning:

⚠️  Action Required: Import Styles
The editor requires these style imports:
Add to app/globals.css:
  @import '../styles/_variables.scss';
  @import '../styles/_keyframe-animations.scss';

image.png

Tambahkan import di src/app/globals.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

/* TipTap Simple Editor required styles */
@import '../styles/_variables.scss';
@import '../styles/_keyframe-animations.scss';

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 0 0% 3.9%;
    /* ... existing variables ... */
  }
}

Ganti Image Upload dengan Paste URL

Daripada modifikasi langsung di file template, kita bikin component terpisah yang lebih clean. Buat file src/components/editor/image-insert.tsx:

"use client";

import { AlertCircle, Image as ImageIcon, Link2, Loader2 } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

interface ImageInsertProps {
  open: boolean;
  onClose: () => void;
  onImageInsert: (url: string, alt?: string) => void;
}

export function ImageInsert({
  open,
  onClose,
  onImageInsert,
}: ImageInsertProps) {
  const [imageUrl, setImageUrl] = useState("");
  const [altText, setAltText] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState("");
  const [showPreview, setShowPreview] = useState(false);

  const validateImageUrl = async (url: string): Promise<boolean> => {
    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => resolve(true);
      img.onerror = () => resolve(false);
      img.src = url;
    });
  };

  const handleUrlChange = (url: string) => {
    setImageUrl(url);
    setError("");
    setShowPreview(false);

    if (url.trim()) {
      // Auto generate alt text dari URL
      try {
        const urlObj = new URL(url);
        const filename = urlObj.pathname.split("/").pop() || "";
        const altFromFilename = filename
          .replace(/\\.(jpg|jpeg|png|gif|webp|svg)$/i, "")
          .replace(/[-_]/g, " ");

        if (altFromFilename && !altText) {
          setAltText(altFromFilename);
        }
      } catch {
        // Invalid URL, tapi ga perlu show error yet
      }
    }
  };

  const handlePreview = async () => {
    if (!imageUrl.trim()) {
      setError("Please enter an image URL");
      return;
    }

    setIsLoading(true);
    setError("");

    try {
      // Basic URL validation
      new URL(imageUrl.trim());

      // Check if image loads
      const isValidImage = await validateImageUrl(imageUrl.trim());

      if (isValidImage) {
        setShowPreview(true);
      } else {
        setError("Unable to load image from this URL");
      }
    } catch {
      setError("Please enter a valid URL");
    } finally {
      setIsLoading(false);
    }
  };

  const handleInsert = () => {
    if (showPreview) {
      onImageInsert(imageUrl.trim(), altText.trim() || "Image");
      handleClose();
    }
  };

  const handleClose = () => {
    setImageUrl("");
    setAltText("");
    setError("");
    setShowPreview(false);
    setIsLoading(false);
    onClose();
  };

  const sampleUrls = [
    {
      url: "<https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=800>",
      label: "Laptop & Code",
    },
    {
      url: "<https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800>",
      label: "Programming",
    },
    {
      url: "<https://images.unsplash.com/photo-1517180102446-f3ece451e9d8?w=800>",
      label: "Web Design",
    },
  ];

  return (
    <Dialog open={open} onOpenChange={handleClose}>
      <DialogContent className="max-w-md">
        <DialogHeader>
          <DialogTitle className="flex items-center space-x-2">
            <ImageIcon className="w-5 h-5" />
            <span>Insert Image</span>
          </DialogTitle>
          <DialogDescription>
            Paste image URL dan preview sebelum insert ke content
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-4">
          <div className="space-y-2">
            <Label htmlFor="image-url">Image URL</Label>
            <Input
              id="image-url"
              type="url"
              placeholder="<https://images.unsplash.com/photo->..."
              value={imageUrl}
              onChange={(e) => handleUrlChange(e.target.value)}
              className={error ? "border-destructive" : ""}
            />
            {error && (
              <div className="flex items-center space-x-2 text-sm text-destructive">
                <AlertCircle className="w-4 h-4" />
                <span>{error}</span>
              </div>
            )}
          </div>

          <div className="space-y-2">
            <Label htmlFor="alt-text">Alt Text</Label>
            <Input
              id="alt-text"
              placeholder="Describe the image for accessibility"
              value={altText}
              onChange={(e) => setAltText(e.target.value)}
            />
            <p className="text-xs text-muted-foreground">
              Helps screen readers understand the image content
            </p>
          </div>

          {!showPreview && (
            <Button
              onClick={handlePreview}
              disabled={!imageUrl.trim() || isLoading}
              className="w-full"
            >
              {isLoading ? (
                <>
                  <Loader2 className="w-4 h-4 mr-2 animate-spin" />
                  Loading Preview...
                </>
              ) : (
                <>
                  <ImageIcon className="w-4 h-4 mr-2" />
                  Preview Image
                </>
              )}
            </Button>
          )}

          {showPreview && (
            <div className="space-y-3">
              <div className="p-3 border rounded-lg bg-muted/20">
                <p className="text-sm font-medium mb-2">Preview:</p>
                {/** biome-ignore lint/performance/noImgElement: no image */}
                <img
                  src={imageUrl}
                  alt={altText || "Preview"}
                  className="w-full h-auto max-h-48 object-cover rounded border"
                />
              </div>

              <div className="flex space-x-2">
                <Button
                  variant="outline"
                  onClick={() => setShowPreview(false)}
                  className="flex-1"
                >
                  Edit URL
                </Button>
                <Button onClick={handleInsert} className="flex-1">
                  <Link2 className="w-4 h-4 mr-2" />
                  Insert Image
                </Button>
              </div>
            </div>
          )}

          {!showPreview && (
            <div className="border-t pt-4">
              <p className="text-sm font-medium mb-2">Try Sample Images:</p>
              <div className="space-y-1">
                {sampleUrls.map((sample, index) => (
                  <button
                    type="button"
                    // biome-ignore lint/suspicious/noArrayIndexKey: key number
                    key={index}
                    onClick={() => {
                      setImageUrl(sample.url);
                      setAltText(sample.label);
                      setError("");
                    }}
                    className="w-full text-left text-xs text-muted-foreground hover:text-foreground p-2 rounded hover:bg-muted/50 transition-colors"
                  >
                    {sample.label}
                  </button>
                ))}
              </div>
            </div>
          )}
        </div>
      </DialogContent>
    </Dialog>
  );
}

"use client"

import * as React from "react"
// ... existing imports

import { type Editor, EditorContent, EditorContext, useEditor } from "@tiptap/react";

// Import custom component
import { ImageUrlButton } from "@/components/editor/image-insert"

const MainToolbarContent = ({
  onHighlighterClick,
  onLinkClick,
  isMobile,
  editor,
}: {
  onHighlighterClick: () => void
  onLinkClick: () => void
  isMobile: boolean
  editor?: Editor | null
}) => {
  return (
    <>
      {/* ... existing toolbar groups */}

      <ToolbarSeparator />

      <ToolbarGroup>
        <Button
          type="button"
          data-style="ghost"
          onClick={() => setShowImageInsert(true)}
          className="h-8 w-8 p-0"
          tooltip="Insert Image"
        >
          <ImageIcon className="tiptap-button-icon h-4 w-4" />
        </Button>
        <ImageInsert
          open={showImageInsert}
          onClose={() => setShowImageInsert(false)}
          onImageInsert={handleImageInsert}
        />
      </ToolbarGroup>

      {/* ... rest of toolbar */}
    </>
  )
}

// Di extensions config, pastikan Image extension ada:
export function SimpleEditor() {
  const editor = useEditor({
    // ... existing config
    extensions: [
      // ... existing extensions
      Image.configure({
        HTMLAttributes: {
          class: 'rounded-lg max-w-full h-auto',
        },
      }),
      // Hapus ImageUploadNode dari sini
    ],
    content,
  })

  // ... rest of component
		  <MainToolbarContent
	        onHighlighterClick={() => setMobileView("highlighter")}
	        onLinkClick={() => setMobileView("link")}
	        isMobile={isMobile}
	        editor={editor} // tambahkan ini
}

Lalu edit src/components/tiptap-templates/simple/simple-editor.tsx dan ganti bagian image upload:

Integration dengan CMS

Buat form create post yang clean dan mengikuti design TipTap Simple Editor di src/app/(dashboard)/posts/create/page.tsx:

"use client";

import { SaveIcon, SendIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ArrowLeftIcon } from "@/components/tiptap-icons/arrow-left-icon";
import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor";
import { Button } from "@/components/tiptap-ui-primitive/button";
import { Spacer } from "@/components/tiptap-ui-primitive/spacer";
import {
  Toolbar,
  ToolbarGroup,
  ToolbarSeparator,
} from "@/components/tiptap-ui-primitive/toolbar";
import { Input } from "@/components/ui/input";

export default function CreatePostPage() {
  const [title, setTitle] = useState("");
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleSave = async (isDraft: boolean = true) => {
    setLoading(true);
    try {
      // TODO: Save to database
      console.log("Saving post:", { title, isDraft });
      await new Promise((resolve) => setTimeout(resolve, 1000));
      router.push("/dashboard/posts");
    } catch (error) {
      console.error("Error saving post:", error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="simple-editor-wrapper !w-full !h-full">
      {/* Header Toolbar */}
      <Toolbar className="mb-6">
        <ToolbarGroup>
          <Button
            data-style="ghost"
            onClick={() => router.back()}
            disabled={loading}
          >
            <ArrowLeftIcon className="tiptap-button-icon" />
            Kembali
          </Button>
        </ToolbarGroup>

        <Spacer />

        <ToolbarGroup>
          <h1 className="text-xl font-semibold">
            Buat Post Baru - BuildWithAngga
          </h1>
        </ToolbarGroup>

        <Spacer />

        <ToolbarGroup>
          <Button
            data-style="ghost"
            onClick={() => handleSave(true)}
            disabled={loading}
          >
            <SaveIcon className="tiptap-button-icon" />
            {loading ? "Menyimpan..." : "Draft"}
          </Button>

          <ToolbarSeparator />

          <Button
            data-style="default"
            onClick={() => handleSave(false)}
            disabled={loading}
          >
            <SendIcon className="tiptap-button-icon" />
            {loading ? "Menerbitkan..." : "Terbitkan"}
          </Button>
        </ToolbarGroup>
      </Toolbar>

      {/* Form Content */}
      <div className="space-y-6 max-w-4xl mx-auto">
        {/* Title Input */}
        <div className="space-y-2">
          <Input
            id="title"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="Masukkan judul yang menarik..."
            className="font-medium"
          />
        </div>

        {/* Editor */}
        <div className="space-y-2">
          <SimpleEditor />
        </div>
      </div>

      {/* Bottom Actions - Mobile Friendly */}
      <div className="fixed bottom-0 left-0 right-0 bg-background border-t border-border p-4 md:hidden">
        <div className="flex justify-between items-center max-w-4xl mx-auto">
          <Button
            data-style="ghost"
            onClick={() => router.back()}
            disabled={loading}
          >
            Batal
          </Button>

          <div className="flex space-x-2">
            <Button
              data-style="ghost"
              onClick={() => handleSave(true)}
              disabled={loading}
            >
              Draft
            </Button>
            <Button
              data-style="default"
              onClick={() => handleSave(false)}
              disabled={loading}
            >
              Publish
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
}

Dan update src/components/tiptap-templates/simple/simple-editor.tsx dengan class tambahan:

// Di bagian return component SimpleEditor
return (
  <div className="simple-editor-wrapper !w-full">
    <EditorContext.Provider value={{ editor }}>
      ...
    </EditorContext.Provider>
  </div>
)

BWABlogCMS-BWA-MicrosoftEdge2025-09-2516-30-19-ezgif.com-video-to-gif-converter.gif

Error saat import SCSS:

  • Next.js udah built-in support Sass, ga perlu install apa-apa

Keunggulan CLI Template

  • Template resmi dari tim TipTap
  • Auto install dependencies dengan versi compatible
  • Configuration udah dioptimasi untuk production
  • Regular updates dari maintainer
  • Documentation yang align

TipTap Simple Editor sekarang siap dipakai untuk BuildWithAngga CMS dengan fitur image paste URL yang sederhana tapi fungsional.

Penutup

Kita udah berhasil menyelesaikan integrasi TipTap Simple Editor ke dalam CMS blog BuildWithAngga kita. Dari setup CLI command yang sederhana, sampe sesuaikan image upload jadi paste URL, editor sekarang udah fungsional untuk kebutuhan dasar pembuatan konten. Halaman buat post juga udah mengikuti sistem desain TipTap dengan toolbar yang konsisten.

Template CLI memberikan dasar yang solid dan mudah dipelihara untuk pengembangan selanjutnya. Fungsi image URL yang kita implementasi lebih sederhana dibanding file upload, tapi cukup efektif untuk fase pengembangan saat ini.

Yang Masih Perlu Dikerjakan

Bagian ini baru menyelesaikan rich text editor saja. Masih ada beberapa komponen penting yang belum diimplementasi untuk bikin CMS ini jadi lengkap:

Integrasi Database: Editor sekarang cuma bisa nulis dan preview, tapi belum connect ke database untuk simpan posts secara persistent.

Manajemen Posts: Belum ada sistem untuk list, edit, delete, atau publikasi posts yang udah dibuat.

Category dan Tags: Schema database udah ada, tapi interface untuk kelola categories dan tags belum dibikin.

SEO dan Meta Data: Penanganan meta description, featured images, dan optimisasi SEO masih perlu dikerjakan.

Persiapan untuk Bagian Selanjutnya

Foundation yang udah kita bangun sampe sini - authentication, database schema, dan rich text editor - adalah komponen inti yang dibutuhin CMS. Bagian-bagian berikutnya akan fokus hubungkan semua pieces ini jadi satu sistem yang utuh.

Pastikan editor TipTap udah jalan dengan baik sebelum lanjut, karena komponen ini akan dipake di berbagai bagian CMS nantinya. Style imports dan konfigurasi yang kita setup di bagian ini juga bakal jadi dependency untuk fitur-fitur selanjutnya.

Saran Belajar Lanjutan

Sambil nunggu tutorial berikutnya, kamu bisa eksplore dokumentasi TipTap untuk familiar dengan extensions dan pilihan kustomisasi yang tersedia. BuildWithAngga juga punya course lain tentang Next.js dan React yang bisa memperdalam pemahaman tentang teknologi yang kita pakai.

Join juga komunitas BuildWithAngga di Discord untuk diskusi dan berbagi pengalaman dengan sesama learner yang ngikutin tutorial series ini.

Tutorial CMS blog personal ini masih akan berlanjut untuk melengkapi fungsionalitas yang masih missing. Stay tuned untuk bagian selanjutnya yang akan mulai integrasikan editor dengan database dan bikin sistem manajemen posts!