DEV Community

姚路行
姚路行

Posted on

Building a City Boy Meme Generator with Next.js and Fabric.js (No Watermarks!)

Building a Free Meme Generator with Next.js and Fabric.js

I recently built a free online meme generator for the "City Boy" meme format, and I want to share the technical journey - from choosing the right canvas library to solving tricky React re-rendering issues.

🔗 Live Demo: cityboymeme.com

Why Another Meme Generator?

Most meme generators online have one or more of these problems:

  • Annoying watermarks on downloaded images
  • Forced registration before downloading
  • Clunky, non-responsive interfaces
  • Ad-heavy experiences

I wanted to build something better: fast, free, and with zero compromises.

Tech Stack

  • Framework: Next.js 16 (App Router)
  • UI Library: React 19
  • Language: TypeScript
  • Canvas Manipulation: Fabric.js 5.3
  • Styling: Tailwind CSS 4
  • Icons: React Icons

Key Features

✅ No registration required
✅ No watermarks on downloads
✅ Drag-and-drop text positioning
✅ Real-time preview
✅ Multiple text styles (outlined, filled, bold)
✅ Fully responsive design
✅ SEO optimized

Technical Challenge #1: Choosing the Right Canvas Library

Initially, I considered using the plain Canvas API or html2canvas, but I went with Fabric.js for several reasons:

Why Fabric.js?

// With Fabric.js, object manipulation is incredibly simple
const text = new fabric.Text('City Boys Be Like', {
  left: 300,
  top: 300,
  fontSize: 48,
  fill: '#FFFFFF',
  stroke: '#000000',
  strokeWidth: 3
})

canvas.add(text)
canvas.setActiveObject(text) // Instantly interactive!
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Built-in object manipulation (drag, resize, rotate)
  • Better text rendering quality
  • Event handling out of the box
  • Easier state management

Technical Challenge #2: The Canvas Flickering Problem

This was the most frustrating bug. Every time a user clicked anywhere or updated text, the entire canvas would flicker and reset. Here's what was happening:

The Problem

// ❌ BAD: This causes canvas to re-initialize on every render
const handleTextSelect = (id: string) => {
  setSelectedTextId(id)
}

useEffect(() => {
  // Canvas gets destroyed and recreated!
  const canvas = new fabric.Canvas(canvasRef.current)
  return () => canvas.dispose()
}, [onTextSelect]) // This dependency changes every render!
Enter fullscreen mode Exit fullscreen mode

The Solution

Use useCallback to stabilize function references:

// ✅ GOOD: Stable function reference
const handleTextSelect = useCallback((id: string | null) => {
  setSelectedTextId(id)
}, [])

const handleCanvasReady = useCallback(() => {
  setCanvasReady(true)
}, [])

// Only initialize canvas once
useEffect(() => {
  const canvas = new fabric.Canvas(canvasRef.current)
  // ... setup code
  return () => canvas.dispose()
}, []) // Empty dependencies - runs once!
Enter fullscreen mode Exit fullscreen mode

Key Lesson: In React, when passing callbacks to components (especially canvas libraries), always use useCallback to prevent unnecessary re-renders.

Technical Challenge #3: Text Style Switching

When switching between text styles (outlined → filled → bold), the previous style properties weren't being cleared properly.

The Problem

// ❌ BAD: Old styles persist
if (updates.style !== undefined) {
  const style = getTextStyle(updatedElement)
  textObj.set(style)
}
Enter fullscreen mode Exit fullscreen mode

The Solution

// ✅ GOOD: Clear old styles first
if (updates.style !== undefined) {
  // Clear all style-related properties first
  textObj.set({
    stroke: undefined,
    strokeWidth: 0,
    backgroundColor: '',
    padding: 0,
  })

  const style = getTextStyle(updatedElement as TextElement)
  const { originX, originY, ...styleOnly } = style
  textObj.set(styleOnly)
}
Enter fullscreen mode Exit fullscreen mode

SEO Optimization Journey

Since this is a free tool, discoverability is crucial. Here's what I implemented:

1. Metadata Optimization

// app/layout.tsx
export const metadata: Metadata = {
  title: {
    default: "City Boy Meme Generator - Free Online Meme Maker",
    template: "%s | City Boy Meme"
  },
  description: "Create hilarious City Boy memes instantly...",
  alternates: {
    canonical: "https://cityboymeme.com/"
  },
  openGraph: {
    type: "website",
    locale: "en_US",
    url: "https://cityboymeme.com",
    siteName: "City Boy Meme Generator",
    images: [{
      url: "https://cityboymeme.com/logo.png",
      width: 1200,
      height: 630,
    }],
  },
}
Enter fullscreen mode Exit fullscreen mode

2. Dynamic Sitemap

// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://cityboymeme.com',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
  ]
}
Enter fullscreen mode Exit fullscreen mode

3. Robots.txt

# public/robots.txt
User-agent: *
Allow: /

Sitemap: https://cityboymeme.com/sitemap.xml
Enter fullscreen mode Exit fullscreen mode

4. Multiple Favicon Formats

Generated favicons in multiple sizes using macOS's built-in sips tool:

sips -z 16 16 logo.png --out favicon-16.png
sips -z 32 32 logo.png --out favicon-32.png
sips -z 180 180 logo.png --out apple-icon.png
sips -z 192 192 logo.png --out icon-192.png
sips -z 512 512 logo.png --out icon-512.png
Enter fullscreen mode Exit fullscreen mode

5. Keyword Density

  • Target: 3-5% keyword density
  • Total word count: 800+ words
  • Keywords: "City Boy meme", "meme generator", "free meme maker"
  • Implementation: Natural language throughout FAQ, features, and about sections

Architecture Decisions

Why Client-Side Only?

The entire app runs in the browser with zero backend:

Advantages:

  • Instant deployment (Vercel/Netlify)
  • No server costs
  • No database needed
  • Privacy-first (no data collection)
  • Faster for users (no API calls)

Trade-offs:

  • Can't save memes to cloud
  • No user accounts
  • Limited analytics

For a meme generator, this is the right choice. Users want speed and privacy.

Component Structure

app/
├── layout.tsx          # SEO metadata, fonts
├── page.tsx            # Main entry point
├── globals.css         # Global styles
└── sitemap.ts          # Dynamic sitemap

components/
├── MemeEditor.tsx      # Main editor component
└── FabricCanvas.tsx    # Canvas abstraction

public/
├── logo.png           # Source image
├── favicon.ico        # Multiple sizes
└── robots.txt         # SEO
Enter fullscreen mode Exit fullscreen mode

Performance Optimizations

1. Image Optimization

import Image from 'next/image'

<Image
  src="/logo.png"
  alt="City Boy Meme Logo"
  width={50}
  height={50}
  className="rounded-lg"
/>
Enter fullscreen mode Exit fullscreen mode

2. Font Loading

import { Inter } from "next/font/google"

const inter = Inter({
  subsets: ["latin"],
  display: "swap", // Prevent layout shift
})
Enter fullscreen mode Exit fullscreen mode

3. Canvas Export Quality

exportAsDataURL: () => {
  canvas.discardActiveObject()
  canvas.renderAll()
  return canvas.toDataURL({
    format: 'png',
    quality: 1,
    multiplier: 2, // 2x resolution for crisp downloads
  })
}
Enter fullscreen mode Exit fullscreen mode

What I Learned

1. React + Canvas = Tricky

Canvas libraries and React don't always play nice. The main issues:

  • React wants to control everything via state
  • Canvas manipulates the DOM directly
  • Re-renders can destroy canvas state

Solution: Treat the canvas as an "uncontrolled component" and manage it via refs and callbacks.

2. TypeScript for Canvas Libraries

Fabric.js has TypeScript definitions, but they're not perfect. I had to create custom interfaces:

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
}
Enter fullscreen mode Exit fullscreen mode

3. SEO Still Matters for SPAs

Even though this is a single-page app, SEO optimization made a huge difference:

  • Proper meta tags
  • Structured data (JSON-LD)
  • Semantic HTML
  • Keyword optimization
  • Fast loading times

4. Less is More

Initially, I wanted to add features like:

  • Image upload
  • Sticker library
  • Advanced filters
  • User accounts

But I realized: simplicity is the killer feature. Users just want to add text and download. Done.

Future Improvements

Ideas I'm considering:

  1. Template Gallery: Add more meme templates
  2. Quick Templates: Pre-made text layouts
  3. Color Picker: Custom colors beyond presets
  4. Export Formats: Add JPEG, WebP options
  5. History/Undo: Canvas state management
  6. PWA: Offline support

Deployment

Deployed on Vercel with zero configuration:

npm run build
# Push to GitHub
# Vercel auto-deploys
Enter fullscreen mode Exit fullscreen mode

Build output:

  • Static HTML/CSS/JS
  • Optimized images
  • Automatic sitemap generation
  • Edge CDN distribution

Lessons for Your Next Project

  1. Start simple: Build the core feature first, add complexity later
  2. Choose the right tools: Fabric.js saved weeks of development
  3. Test on real devices: Mobile experience matters
  4. SEO from day one: Don't treat it as an afterthought
  5. Performance matters: Users expect instant feedback
  6. No ads, no BS: Sometimes the best monetization is none

Try It Out!

🚀 Live Demo: cityboymeme.com

The entire project runs in your browser - no registration, no watermarks, completely free.

Conclusion

Building this meme generator taught me a lot about:

  • React performance optimization
  • Canvas manipulation in modern frameworks
  • SEO for single-page apps
  • The value of simplicity

If you're building something similar, I hope this helps you avoid the pitfalls I encountered!


Questions? Comments? Drop them below! I'm happy to discuss any aspect of the implementation.

Found this helpful? Give it a ❤️ and share with your fellow developers!


Tags: #nextjs #react #javascript #webdev #typescript #canvas #seo #tutorial

Top comments (0)