DEV Community

姚路行
姚路行

Posted on

How to Create City Boy Meme

How to Create City Boy Meme with Next.js 16 & Fabric.js

Ever wondered how to build a web app that goes viral? I recently launched city boy meme - a free, lightning-fast meme maker that's been creating 2,000+ memes daily. Here's how I built it and the lessons I learned along the way.

🎯 The Problem

Existing meme generators are slow, bloated with ads, add watermarks, or require signups. I wanted to build something different:

  • Instant loading - No waiting, no spinners
  • 🎨 Full customization - Fonts, colors, text styles
  • 💯 Zero friction - No signup, no watermarks, no BS
  • 📱 Mobile-first - Works perfectly on all devices

🛠️ Tech Stack

After evaluating several options, I settled on:

{
  "framework": "Next.js 16",
  "styling": "Tailwind CSS 4",
  "canvas": "Fabric.js 5.3",
  "language": "TypeScript",
  "deployment": "Vercel"
}
Enter fullscreen mode Exit fullscreen mode

Why Next.js 16?

Next.js 16 brings some game-changing features:

  • App Router - Better performance and SEO
  • Server Components - Reduced JavaScript bundle
  • Image Optimization - Automatic WebP conversion
  • Built-in SEO - Metadata API for perfect SEO

Why Fabric.js?

I initially tried HTML Canvas API directly, but Fabric.js saved me weeks:

  • Object-based canvas manipulation
  • Built-in text editing and dragging
  • Easy export to PNG/JPEG
  • Great TypeScript support

🏗️ Architecture Overview

cityboymeme/
├── app/
│   ├── layout.tsx          # Root layout with SEO
│   ├── page.tsx            # Home page
│   ├── sitemap.ts          # Dynamic sitemap
│   └── globals.css         # Global styles
├── components/
│   ├── MemeEditor.tsx      # Main editor component
│   └── FabricCanvas.tsx    # Canvas wrapper
└── public/
    ├── logo.png            # Meme template
    └── robots.txt          # SEO config
Enter fullscreen mode Exit fullscreen mode

💻 Building the Meme Editor

1. Canvas Component with Fabric.js

The core of the app is a custom canvas component that wraps Fabric.js:

// components/FabricCanvas.tsx
'use client'

import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'
import { fabric } from 'fabric'

export interface TextElement {
  id: string
  text: string
  color: string
  font: string
  style: 'bold' | 'filled' | 'outlined'
  size: number
}

export interface FabricCanvasRef {
  addText: (element: TextElement) => void
  updateText: (id: string, updates: Partial<TextElement>) => void
  removeText: (id: string) => void
  exportAsDataURL: () => string | undefined
}

interface Props {
  backgroundImage: string
  width: number
  height: number
  onTextSelect: (id: string | null) => void
  onCanvasReady: () => void
}

const FabricCanvas = forwardRef<FabricCanvasRef, Props>(
  ({ backgroundImage, width, height, onTextSelect, onCanvasReady }, ref) => {
    const canvasRef = useRef<HTMLCanvasElement>(null)
    const fabricRef = useRef<fabric.Canvas | null>(null)
    const textObjectsRef = useRef<Map<string, fabric.Text>>(new Map())

    // Initialize Fabric.js canvas
    useEffect(() => {
      if (!canvasRef.current) return

      const canvas = new fabric.Canvas(canvasRef.current, {
        width,
        height,
        backgroundColor: '#ffffff',
      })

      fabricRef.current = canvas

      // Load background image
      fabric.Image.fromURL(backgroundImage, (img) => {
        img.scaleToWidth(width)
        img.scaleToHeight(height)
        img.selectable = false
        canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas))
        onCanvasReady()
      })

      // Handle text selection
      canvas.on('selection:created', (e) => {
        const obj = e.selected?.[0] as fabric.Text
        if (obj && obj.data?.id) {
          onTextSelect(obj.data.id)
        }
      })

      canvas.on('selection:cleared', () => {
        onTextSelect(null)
      })

      return () => {
        canvas.dispose()
      }
    }, [])

    // Expose methods via ref
    useImperativeHandle(ref, () => ({
      addText: (element: TextElement) => {
        const canvas = fabricRef.current
        if (!canvas) return

        const textObj = new fabric.Text(element.text, {
          left: width / 2,
          top: height / 2,
          fontSize: element.size,
          fontFamily: element.font,
          fill: element.color,
          stroke: element.style === 'outlined' ? '#000000' : undefined,
          strokeWidth: element.style === 'outlined' ? 3 : 0,
          fontWeight: element.style === 'bold' ? 'bold' : 'normal',
          textAlign: 'center',
          originX: 'center',
          originY: 'center',
        })

        textObj.data = { id: element.id }
        textObjectsRef.current.set(element.id, textObj)
        canvas.add(textObj)
        canvas.setActiveObject(textObj)
        canvas.renderAll()
      },

      updateText: (id: string, updates: Partial<TextElement>) => {
        const textObj = textObjectsRef.current.get(id)
        if (!textObj) return

        if (updates.text !== undefined) textObj.set('text', updates.text)
        if (updates.color !== undefined) textObj.set('fill', updates.color)
        if (updates.font !== undefined) textObj.set('fontFamily', updates.font)
        if (updates.size !== undefined) textObj.set('fontSize', updates.size)

        if (updates.style !== undefined) {
          textObj.set('stroke', updates.style === 'outlined' ? '#000000' : undefined)
          textObj.set('strokeWidth', updates.style === 'outlined' ? 3 : 0)
          textObj.set('fontWeight', updates.style === 'bold' ? 'bold' : 'normal')
        }

        fabricRef.current?.renderAll()
      },

      removeText: (id: string) => {
        const textObj = textObjectsRef.current.get(id)
        if (!textObj) return

        fabricRef.current?.remove(textObj)
        textObjectsRef.current.delete(id)
      },

      exportAsDataURL: () => {
        return fabricRef.current?.toDataURL({
          format: 'png',
          quality: 1,
          multiplier: 2, // 2x resolution for HD quality
        })
      },
    }))

    return (
      <div className="flex justify-center">
        <canvas ref={canvasRef} className="border border-gray-300 rounded-lg shadow-lg" />
      </div>
    )
  }
)

FabricCanvas.displayName = 'FabricCanvas'
export default FabricCanvas
Enter fullscreen mode Exit fullscreen mode

2. Main Editor Component

The editor manages state and provides the UI:

// components/MemeEditor.tsx (simplified)
'use client'

import { useState, useRef } from 'react'
import FabricCanvas, { FabricCanvasRef, TextElement } from './FabricCanvas'

export default function MemeEditor() {
  const [textElements, setTextElements] = useState<TextElement[]>([
    {
      id: '1',
      text: 'City Boys Be Like',
      color: '#FFFFFF',
      font: 'Impact',
      style: 'outlined',
      size: 48
    }
  ])
  const [selectedTextId, setSelectedTextId] = useState<string | null>('1')
  const canvasRef = useRef<FabricCanvasRef>(null)

  const selectedText = textElements.find(t => t.id === selectedTextId)

  const updateText = (id: string, updates: Partial<TextElement>) => {
    setTextElements(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t))
    canvasRef.current?.updateText(id, updates)
  }

  const handleDownload = () => {
    const dataURL = canvasRef.current?.exportAsDataURL()
    if (!dataURL) return

    const link = document.createElement('a')
    link.download = 'city-boy-meme.png'
    link.href = dataURL
    link.click()
  }

  return (
    <div className="grid grid-cols-1 lg:grid-cols-[1fr_400px] gap-8">
      {/* Canvas */}
      <FabricCanvas
        ref={canvasRef}
        backgroundImage="/logo.png"
        width={600}
        height={600}
        onTextSelect={setSelectedTextId}
        onCanvasReady={() => {}}
      />

      {/* Controls */}
      <div className="space-y-6">
        {selectedText && (
          <>
            <input
              type="text"
              value={selectedText.text}
              onChange={(e) => updateText(selectedText.id, { text: e.target.value })}
              className="w-full px-4 py-2 border rounded-lg"
            />

            <select
              value={selectedText.font}
              onChange={(e) => updateText(selectedText.id, { font: e.target.value })}
              className="w-full px-4 py-2 border rounded-lg"
            >
              <option value="Impact">Impact</option>
              <option value="Arial">Arial</option>
              <option value="Comic Sans MS">Comic Sans MS</option>
            </select>

            <button
              onClick={handleDownload}
              className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg"
            >
              Download Meme
            </button>
          </>
        )}
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

🚀 Performance Optimizations

1. Image Optimization

Next.js Image component automatically optimizes images:

import Image from 'next/image'

<Image
  src="/logo.png"
  alt="City Boy Meme template"
  width={600}
  height={600}
  priority // Load immediately for LCP
/>
Enter fullscreen mode Exit fullscreen mode

2. Code Splitting

Fabric.js is large (~500KB), so I lazy-load it:

'use client'

import dynamic from 'next/dynamic'

const FabricCanvas = dynamic(() => import('./FabricCanvas'), {
  ssr: false, // Fabric.js requires window object
  loading: () => <div>Loading canvas...</div>
})
Enter fullscreen mode Exit fullscreen mode

3. Lighthouse Score: 95+

After optimizations:

  • Performance: 98
  • Accessibility: 100
  • Best Practices: 100
  • SEO: 100

🔍 SEO Strategy

SEO was crucial for organic growth. Here's what worked:

1. Perfect Metadata

// app/layout.tsx
export const metadata: Metadata = {
  title: 'City Boy Meme - Free Online Meme Maker',
  description: 'Create hilarious City Boy memes instantly with our free online generator. Add custom text, choose fonts and colors. Download high-quality memes. No signup!',
  keywords: [
    'city boy meme',
    'meme generator',
    'free meme maker',
    'online meme creator',
  ],
  openGraph: {
    type: 'website',
    url: 'https://cityboymeme.com',
    title: 'City Boy Meme - Free Online Meme Maker',
    description: 'Create viral City Boy memes instantly',
    images: ['/logo.png'],
  },
}
Enter fullscreen mode Exit fullscreen mode

2. Structured Data (Schema.org)

// FAQPage Schema for Featured Snippets
const faqSchema = {
  '@context': 'https://schema.org',
  '@type': 'FAQPage',
  mainEntity: [
    {
      '@type': 'Question',
      name: 'What is a City Boy Meme?',
      acceptedAnswer: {
        '@type': 'Answer',
        text: 'The City Boy Meme is a viral internet format featuring a character with an exaggerated shocked expression...'
      }
    },
    // More questions...
  ]
}
Enter fullscreen mode Exit fullscreen mode

This helped me get Featured Snippets on Google!

3. Dynamic Sitemap

// app/sitemap.ts
import { MetadataRoute } from 'next'

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://cityboymeme.com',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    {
      url: 'https://cityboymeme.com/what-is-city-boy-meme',
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 0.9,
    },
  ]
}
Enter fullscreen mode Exit fullscreen mode

📊 Results After 2 Weeks

The results exceeded my expectations:

  • 📈 2,000+ memes created daily
  • 🔍 Ranking #3 for "city boy meme" on Google
  • Page load time: <1 second
  • 📱 Mobile traffic: 65%
  • 💯 Zero complaints about performance

🎓 Lessons Learned

1. Start with SEO from Day 1

Don't treat SEO as an afterthought. I spent 30% of development time on SEO, and it paid off massively.

2. Mobile-First is Non-Negotiable

65% of my users are on mobile. The app works perfectly on phones because I designed for mobile first.

3. Performance = User Retention

Users expect instant loading. Every 100ms delay costs you users. Optimize ruthlessly.

4. Fabric.js > Raw Canvas

I initially tried raw Canvas API. Switching to Fabric.js saved me 2 weeks and resulted in better UX.

5. TypeScript Saves Time

TypeScript caught dozens of bugs before they reached production. The upfront cost is worth it.

🔮 What's Next?

I'm planning to add:

  • 🎨 More meme templates
  • 📤 Direct social media sharing
  • 🎥 GIF/Video meme support
  • 🌍 Internationalization (i18n)
  • 🤖 AI-powered text suggestions

💡 Key Takeaways

If you're building a viral web app:

  1. Choose the right tools - Next.js 16 + Fabric.js was perfect for this
  2. Optimize for performance - Users expect instant loading
  3. SEO from day 1 - Organic traffic is the best traffic
  4. Mobile-first design - Most users are on mobile
  5. Keep it simple - No signup, no friction, just value

🔗 Links

🙏 Thanks for Reading!

If you found this helpful, please:

  • ⭐ Star the repo on GitHub
  • 💬 Leave a comment with your questions
  • 🔄 Share with your network
  • 🚀 Try building your own viral app!

What would you build with Next.js 16 and Fabric.js? Drop your ideas in the comments! 👇


📚 Resources


Built with ❤️ using Next.js 16, Fabric.js, and lots of coffee ☕

nextjs #react #javascript #webdev #typescript #fabricjs #memes #seo #performance #opensource

Top comments (0)