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
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[];
}
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:
- Add the type to the
BlockTypeunion inlib/types.ts - Define
defaultPropsinBLOCK_CATEGORIES(used by the sidebar) - Add a canvas preview in
components/builder/block-preview.tsx - Add config fields in
components/builder/config/block-config-fields.tsx - Add a live preview render in
components/builder/live-preview.tsx - 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
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;
}
}
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,
};
The PointerSensor has an activation constraint of 8px to prevent accidental drags when clicking to select a block:
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
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;
}
}
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 }),
},
),
);
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.ts—calculateStreakStats,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
Sheetoverlays 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)