DEV Community

Cover image for I Built a Drag-and-Drop GitHub Profile README Builder with Next.js 16 & React 19
Zntb
Zntb

Posted on

I Built a Drag-and-Drop GitHub Profile README Builder with Next.js 16 & React 19

A deep-dive into building a fully visual, self-hosted README builder — covering architecture decisions, the self-hosted stats API, drag-and-drop with dnd-kit, and how the block system works.

Your GitHub profile README is often the first thing a recruiter, collaborator, or open-source maintainer sees. It's prime real estate — yet most developers either leave it blank or spend an afternoon wrestling with raw Markdown, hunting for widget URLs, and tweaking layouts by trial and error.

I wanted to fix that. So I built GitHub Profile README Builder: a fully visual, drag-and-drop editor where you configure your profile in a live preview canvas and export a production-ready README.md in one click.

🔗 Live Demo

GitHub Repo

What It Does

  • Drag-and-drop canvas — reorder blocks effortlessly with smooth dnd-kit animations
  • 25+ block types — headings, paragraphs, skill icons, social badges, typing animations, collapsibles, code blocks, GitHub stats cards, activity graphs, trophies, visitor counters, quotes, and more
  • 65+ themes — Tokyo Night, Dracula, Catppuccin Mocha, Nord, Gruvbox, GitHub Dark, and many others
  • 11 starter templates — Animated Developer, Full Stack Engineer, Data Scientist, DevOps / Cloud, Student, Cybersecurity Researcher, Game Developer, and more
  • Live preview — renders as close to GitHub's actual display as possible
  • Self-hosted GitHub Stats API — your own Next.js route handlers generate stat SVGs, so no third-party rate limits
  • One-click export — copy to clipboard or download README.md
  • Fully responsive — a dedicated layout for desktop, tablet, and mobile

Tech Stack

Frontend     Next.js 16 (App Router) · React 19 · TypeScript 5
Styling      Tailwind CSS v4 · tw-animate-css · shadcn/ui (radix-nova)
State        Zustand 5
DnD          dnd-kit (sortable)
Icons        Lucide React
Theming      next-themes
Toasts       Sonner
API          Next.js Route Handlers · GitHub REST & GraphQL APIs
Fonts        Outfit · JetBrains Mono
Enter fullscreen mode Exit fullscreen mode

Architecture: The Block System

The entire editor is built around a simple, composable block model. Every element on the canvas is a Block:

export interface Block {
  id: string;
  type: BlockType;
  props: Record<string, unknown>;
  children?: Block[];
}
Enter fullscreen mode Exit fullscreen mode

BlockType is a union of all supported block types — from layout primitives like 'container' and 'spacer' to GitHub-specific widgets like 'stats-card' and 'activity-graph'. The children field enables nested blocks (collapsible sections, containers, stats rows).

Adding a New Block

The pattern is deliberate and consistent. Adding a new block type takes about 10 minutes:

  1. Add the type to the BlockType union in lib/types.ts
  2. Define defaultProps in BLOCK_CATEGORIES (used by the sidebar)
  3. Add a canvas preview in components/builder/block-preview.tsx
  4. Add config fields in components/builder/config/block-config-fields.tsx
  5. Add a live preview render in components/builder/live-preview.tsx
  6. Add a Markdown render case in lib/markdown.ts

This separation of concerns keeps each concern focused and testable.

The Self-Hosted Stats API

Most README stat widgets depend on external services with rate limits. Instead, the builder ships its own route handlers that generate stat SVGs server-side using your GITHUB_TOKEN:

GET /api/stats      → GitHub stats card SVG
GET /api/streak     → Streak stats SVG
GET /api/top-langs  → Top languages SVG
GET /api/activity   → 30-day contribution graph SVG
GET /api/trophies   → Trophy grid SVG
GET /api/quotes     → Random dev quote SVG
Enter fullscreen mode Exit fullscreen mode

All routes hit the GitHub GraphQL API, apply theme colors, and return inline SVGs. A simple in-memory cache (5-minute TTL) prevents hammering the API on every preview refresh:

class GitHubCache {
  private cache = new Map<string, CacheEntry<unknown>>();
  private readonly ttl: number;

  get<T>(key: string): T | null {
    const entry = this.cache.get(key);
    if (!entry) return null;
    if (Date.now() - entry.timestamp > this.ttl) {
      this.cache.delete(key);
      return null;
    }
    return entry.data as T;
  }
}
Enter fullscreen mode Exit fullscreen mode

When you export your README, URLs point to your deployed instance — so the stat images are always live and under your control.

Drag and Drop with dnd-kit

The canvas uses @dnd-kit/sortable for block reordering. Blocks are wrapped in a useSortable hook, giving each one a stable drag handle and smooth CSS transforms during drag:

const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
  useSortable({
    id: block.id,
  });

const style: React.CSSProperties = {
  transform: CSS.Transform.toString(transform),
  transition,
};
Enter fullscreen mode Exit fullscreen mode

The PointerSensor has an activation constraint of 8px to prevent accidental drags when clicking to select a block:

useSensor(PointerSensor, {
  activationConstraint: { distance: 8 },
}),
Enter fullscreen mode Exit fullscreen mode

Markdown Rendering

The renderMarkdown function in lib/markdown.ts walks the block array and converts each block to its Markdown equivalent. The interesting case is adjacent half-width stats cards — these need to be grouped into a single <div align="center"> row:

if (imageTag && isHalfWidthCard(block)) {
  const nextBlock = blocks[i + 1];
  const nextImageTag =
    nextBlock && isHalfWidthCard(nextBlock)
      ? getHalfWidthCardImageTag(nextBlock, origin)
      : null;

  if (nextImageTag) {
    rendered.push(
      `<div align="center">\n` +
        `  <img src="..." width="50%" alt="GitHub Stats" />\n` +
        `  <img src="..." width="50%" alt="Top Languages" />\n` +
        `</div>`,
    );
    i += 1; // skip the next block — it's already rendered
    continue;
  }
}
Enter fullscreen mode Exit fullscreen mode

This produces clean, side-by-side stat cards in the exported Markdown without any wrapper framework.

State Management with Zustand

The builder state lives in a single Zustand store. Only the username field is persisted to localStorage — the block canvas resets on refresh by design (to keep things simple and avoid stale state bugs):

export const useBuilderStore = create<BuilderState>()(
  persist(
    (set, get) => ({
      blocks: [],
      selectedBlockId: null,
      username: '',
      // ...actions
    }),
    {
      name: 'github-readme-builder-storage',
      partialize: (state) => ({ username: state.username }),
    },
  ),
);
Enter fullscreen mode Exit fullscreen mode

Store actions handle deeply nested operations like removeBlock and updateBlock, which recursively walk the children tree to find and update the target block.

Testing

The project uses Jest with ts-jest for unit tests covering:

  • lib/markdown.ts — every block type's Markdown output, including complex scenarios like adjacent half-width cards and stats-row children
  • lib/github.tscalculateStreakStats, calculateRank, fetchUserStats, fetchLanguageStats, and more
  • lib/store.ts — all store actions including nested block operations
  • lib/themes.ts — theme resolution and alias handling

A pre-commit hook runs the full test suite, ESLint, Prettier format check, and TypeScript type-check before every commit.

Responsive Layout

The builder uses three distinct layouts:

  • Desktop (lg+): fixed left sidebar (block library) + scrollable canvas + fixed right panel (config or preview)
  • Tablet (md–lg): canvas fills the screen, sidebar and config panel open as Sheet overlays triggered by floating buttons
  • Mobile: a bottom tab bar switches between Blocks, Canvas, and Preview views; the config panel slides up as a bottom sheet

What's Next

A few things on the roadmap:

  • Undo/redo — the block array is already serializable, so it's a natural fit for a history stack
  • Custom block templates — save your own configured blocks for reuse
  • More block types — GitHub language badges, contribution heatmaps, Spotify now-playing, etc.
  • Custom Theme Builder - Allow users to create custom themes beyond the 65+ pre-built options with a visual color picker for stats cards, borders, and backgrounds.

Try It

🔗 Live demo: github-profile-maker.vercel.app

Star the repo: github.com/zntb/github-profile-maker

The project is MIT-licensed and contributions are welcome. If you add a new block type or template, open a PR — I'd love to include it.

Questions, feedback, or ideas? Drop them in the comments. 👇

Top comments (0)