Building a City Boy Meme Generator with Next.js and Fabric.js (No Watermarks!)
Source: Dev.to
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: https://cityboymeme.com
The Problem with Most Meme Generators
- 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.
| Item | Choice |
|---|---|
| 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 |
| ✅ No registration required |
Why Fabric.js?
I initially considered the plain Canvas API or html2canvas, but I went with Fabric.js for several reasons:
// 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!
Benefits
- Built‑in object manipulation (drag, resize, rotate)
- Better text‑rendering quality
- Event handling out of the box
- Easier state management
The Most Frustrating Bug
Every time a user clicked anywhere or updated text, the entire canvas would flicker and reset.
❌ Bad implementation (re‑initializes canvas 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!
✅ Good implementation (stable callbacks)
// 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!
Key Lesson: In React, when passing callbacks to components (especially canvas libraries), always use useCallback to prevent unnecessary re‑renders.
Fixing Persistent Text Styles
When switching between text styles (outlined → filled → bold), the previous style properties weren’t being cleared properly.
❌ Bad: Old styles persist
if (updates.style !== undefined) {
const style = getTextStyle(updatedElement);
textObj.set(style);
}
✅ 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);
}
SEO & Discoverability
// 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,
},
],
},
};
// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://cityboymeme.com",
lastModified: new Date(),
changeFrequency: "daily",
priority: 1,
},
];
}
# public/robots.txt
User-agent: *
Allow: /
Sitemap: https://cityboymeme.com/sitemap.xml
Generating Favicons (macOS sips)
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
Content Strategy
- Target keyword density: 3‑5%
- Total word count: 800+ words
- Keywords: “City Boy meme”, “meme generator”, “free meme maker”
- Implementation: Natural language throughout FAQ, features, and about sections
Architecture Overview
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 the cloud
- No user accounts
- Limited analytics
For a meme generator, this is the right choice. Users want speed and privacy.
Project 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
import Image from 'next/image';
;
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap", // Prevent layout shift
});
// Export canvas as Data URL for download
export const exportAsDataURL = () => {
canvas.discardActiveObject();
canvas.renderAll();
return canvas.toDataURL({
format: "png",
quality: 1,
multiplier: 2, // 2× resolution for crisp downloads
});
};
Canvas + React: The Compatibility Challenge
- 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.
// Example: initializing Fabric canvas only once
useEffect(() => {
const canvas = new fabric.Canvas(canvasRef.current);
// ...setup (add listeners, objects, etc.)
return () => canvas.dispose();
}, []); // runs once
Fabric.js has TypeScript definitions that make this integration type‑safe and developer‑friendly.
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) => void
removeText: (id: string) => void
exportAsDataURL: () => string
}
SEO Optimisation (Even for a SPA)
- Proper meta tags
- Structured data (JSON‑LD)
- Semantic HTML
- Keyword optimisation
- Fast loading times
Initial Feature Wishlist
- Image upload
- Sticker library
- Advanced filters
- User accounts
Realisation: Simplicity is the killer feature. Users just want to add text and download. Done.
Ideas I’m Considering
- Template Gallery: Add more meme templates
- Quick Templates: Pre‑made text layouts
- Color Picker: Custom colours beyond presets
- Export Formats: JPEG, WebP options
- History / Undo: Canvas state management
- PWA: Offline support
Deployment (Vercel – Zero Config)
npm run build # Build output
# Push to GitHub
# Vercel auto‑deploys
Build output includes:
- Static HTML / CSS / JS
- Optimised images
- Automatic sitemap generation
- Edge CDN distribution
Lessons Learned
- Start simple: Build the core feature first, add complexity later.
- Choose the right tools: Fabric.js saved weeks of development.
- Test on real devices: Mobile experience matters.
- SEO from day one: Don’t treat it as an afterthought.
- Performance matters: Users expect instant feedback.
- No ads, no BS: Sometimes the best monetisation is none.
🚀 Live Demo: https://cityboymeme.com
The entire project runs in the browser – no registration, no watermarks, completely free.
What I Gained
- React performance optimisation
- 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