Where Open Source Meets Finance — Merge code. Earn instantly.
This is the web client for MergeFi, a platform where sponsors fund open-source work, maintainers turn GitHub issues into paid bounties, contributors complete them, and Soroban smart contracts on the Stellar network hold funds in escrow and release payment automatically the moment a pull request is merged.
GitHub remains the source of truth for code. Stellar/Soroban handles the financial layer. This repo is the Next.js frontend that ties both together for contributors, maintainers, and sponsors.
Related repositories:
mergefi/backend— NestJS API: GitHub sync, webhooks, bounty/escrow orchestration, reputation, analytics.mergefi/contracts— Soroban smart contracts: escrow, milestone funding, maintenance pools, team splits.
- Very low transaction costs make micro-bounties (a $5 doc fix, a $20 bug fix) economically practical.
- Fast settlement means contributors get paid in minutes, not weeks.
- Soroban smart contracts implement escrow, payment release, refunds, and split logic natively on-chain, so no one — including MergeFi — can touch funds outside the rules encoded in the contract.
This project is licensed under the MIT License.
| Role | What they do |
|---|---|
| Contributors | Discover paid issues, build a public reputation, earn USDC/XLM, track earnings. |
| Maintainers | Create projects, attach rewards to issues, approve completed work, manage permissions. |
| Sponsors | Fund repositories or specific issues/milestones, track spend, measure impact. |
| Feature | Route |
|---|---|
| Landing page / product pitch | / |
| Browse paid issues (bounties) | /issues |
| Bounty detail, escrow status, claim flow | /issues/[id] |
| Milestone funding + recurring maintenance pools | /milestones |
| Connect GitHub + Stellar wallet (Freighter) | /connect |
| Contributor dashboard (earnings, claims, recommendations) | /dashboard/contributor |
| Maintainer dashboard (bounty pipeline, PRs awaiting merge) | /dashboard/maintainer |
| Sponsor dashboard (spend, active bounties, budget remaining) | /dashboard/sponsor |
| Public reputation profile | /reputation/[handle] |
Next.js (App Router) this repo
├─ Server Components fetch data ── mergefi-backend REST API
│ (falls back to realistic mock data
│ when the backend isn't running,
│ so the UI is always demoable)
└─ Client components (/connect) ── GitHub OAuth redirect (backend-issued)
└─ Freighter wallet extension
(@stellar/freighter-api)
- Framework: Next.js 16 (App Router, React 19, TypeScript, Turbopack).
- Styling: Tailwind CSS v4, dark-mode-first design system (
slate/emeraldpalette). - Data fetching: Server Components call the backend via
src/lib/api.ts#fetchWithFallback, which tries the live API and falls back tosrc/lib/mock-data.tsif it's unreachable — the app is fully browsable without the backend running. - Wallet integration:
src/lib/wallet.tswraps@stellar/freighter-apifor connecting a Stellar wallet and signing transactions (escrow funding, milestone deposits) client-side. - GitHub auth: The "Connect GitHub" button redirects to the backend's OAuth endpoint (
NEXT_PUBLIC_API_URL/auth/github); the frontend itself holds no GitHub credentials.
src/
app/ App Router routes (one folder per route above)
components/
ui/ Button, Badge, StatCard, Avatar, Tabs, ThemeToggle — small reusable primitives
layout/ Navbar, Footer, CopyrightYear
bounty/ BountyCard, BountyDescription
dashboard/ ActivityList, DashboardShell
context/
AuthContext.tsx current-user session state
ThemeContext.tsx dark/light theme toggle
WalletContext.tsx Stellar wallet connection state
hooks/
useCrossTabStorage.ts cross-tab localStorage sync
lib/
adapters.ts raw-backend-to-UI-shape translation layer
api.ts fetch wrapper + mock-data fallback
auth.ts token storage
config.ts env-driven API base URL, OAuth URL, Stellar network
env.ts build-time env-var validation
markdown.ts markdown rendering helpers
mock-data.ts realistic sample bounties/milestones/profiles for demos
utils.ts cn(), currency/percent/date formatting
wallet.ts Freighter connect/sign helpers
types/
index.ts shared domain types (Bounty, Milestone, ReputationProfile, ...)
npm install
cp .env.example .env.local
npm run devOpen http://localhost:3000. With no backend running,
every page renders against the bundled mock data in src/lib/mock-data.ts —
useful for frontend-only development or a quick demo. Point
NEXT_PUBLIC_API_URL at a running mergefi-backend instance to see live data.
Both NEXT_PUBLIC_API_URL and NEXT_PUBLIC_STELLAR_NETWORK are validated at build time (next.config.ts / src/lib/env.ts, #26) — an unset or invalid value fails next build/next dev/next start immediately with a clear error, rather than silently falling back and only surfacing as a confusing on-chain failure later. NEXT_PUBLIC_SITE_URL is not build-time validated — it falls back to https://mergefi.app if unset. .env.example sets all three explicitly, so the quickstart above needs no manual edits.
| Variable | Purpose | Default |
|---|---|---|
NEXT_PUBLIC_API_URL |
Base URL of the mergefi-backend API. Must be a well-formed URL. |
http://localhost:4000/api |
NEXT_PUBLIC_STELLAR_NETWORK |
Must be exactly TESTNET or PUBLIC (case-sensitive) — selects the Freighter network passphrase used to sign transactions. |
None. Network selection is too consequential to guess a default for — the wrong value signs transactions with the wrong passphrase. Set it explicitly (.env.example does this for local dev). |
NEXT_PUBLIC_SITE_URL |
Base URL used by src/app/sitemap.ts to generate absolute sitemap URLs. Set this when deploying to a domain other than mergefi.app (staging, forks, etc.). |
https://mergefi.app |
| Command | Description |
|---|---|
npm run dev |
Start the dev server with Turbopack |
npm run build |
Production build |
npm run start |
Serve the production build |
npm run lint |
ESLint (flat config, eslint-config-next) |
npm test |
Runs the Jest test suite |
npm run test:watch |
Runs the Jest test suite in watch mode |
npm run verify:headers |
Boots the production build and asserts security headers are present on real responses (run npm run build first) |
npm run verify:env |
Asserts next build succeeds/fails correctly across missing, invalid, and valid NEXT_PUBLIC_* env var scenarios (runs in CI) |
next.config.ts sets these on every route, verified in CI via npm run verify:headers:
| Header | Value | Why |
|---|---|---|
X-Frame-Options |
DENY |
No legitimate iframe-embedding use case exists in this app, and fund/claim actions are one click away — clickjacking is a real risk here. Relax to a scoped CSP frame-ancestors allowlist if a real embed need ever comes up. |
X-Content-Type-Options |
nosniff |
Stops the browser from MIME-sniffing a response into an executable content type. |
Referrer-Policy |
strict-origin-when-cross-origin |
The GitHub OAuth callback (/auth/callback) carries a short-lived token in its query string; this keeps the full URL out of the Referer header on cross-origin requests. |
Strict-Transport-Security |
max-age=86400 |
Deliberately conservative to start (no includeSubDomains, no preload) — HSTS is effectively irreversible once cached by a browser. Raise max-age and add includeSubDomains after a stable production run, and only add preload once that's stable too. |
Content-Security-Policy is intentionally not set here — it's tracked separately so it can compose correctly with the theme-init inline script in layout.tsx rather than this change guessing at a nonce/hash strategy.
None of these headers affect the Freighter wallet bridge or the GitHub OAuth flow: Freighter communicates via an injected browser-extension content script (@stellar/freighter-api), which framing/MIME/referrer/transport headers have no bearing on, and the OAuth fetch calls to the backend are unaffected since these headers only change what's disclosed, not whether a request succeeds.
Contributor profiles (/reputation/[handle]) server-render a real GitHub handle, avatar, organisations, and lifetime earnings. The policy is: public by direct link, opt-in for search-engine indexing. A profile URL always works and is shareable; it is submitted to and indexed by search engines only if the contributor has explicitly opted in.
The reasoning is asymmetry of reversibility. Search indexing is effectively permanent in practice — removal requests are slow, partial, and never complete for URLs already crawled. A default of "indexed unless you opt out" makes the harm irreversible; a default of "not indexed unless you opt in" costs only some SEO surface, which the contributor can unlock themselves at any time. For earnings attached to a legal name, that trade is worth making.
Enforced across three surfaces that can otherwise disagree:
| Surface | File | Behavior |
|---|---|---|
| Sitemap | src/app/sitemaps/[id]/route.ts, src/lib/api.ts |
Only handles that opted in are listed, filtered in fetchIndexableReputationHandles. |
| Per-page metadata | src/app/reputation/[handle]/page.tsx |
noindex, nofollow on the document for profiles that haven't opted in. |
robots.txt |
src/app/robots.ts |
Allows /reputation/ — see below. Dashboard and OAuth callback stay disallowed. |
robots.txt deliberately does not disallow /reputation/: crawlers have to be able to fetch the page in order to see its noindex tag, and blocking the path is what leaves already-indexed URLs stuck in the index. Sitemap omission alone isn't enough either, since a URL shared in a chat or a GitHub README can still get indexed from an inbound link.
Needs a backend field. mergefi-backend's user entity has no opt-in flag today. The frontend reads isProfilePublic defensively and defaults to not indexable, so nothing is indexed until the field ships — the safe side of the trade. Adding the field (plus a settings UI to set it) requires no further frontend change.
Sitemaps are split into 50,000-URL files (/sitemaps/<id>.xml, with a permanent redirect from the old /sitemap.xml) to respect the per-file limit search engines enforce. They're served with a CDN-cacheable Cache-Control and memoized for an hour in-process, so crawler traffic doesn't reach the backend on every request.
BarChart and Sparkline are hand-rolled — no charting library is a dependency choice here. Both are audited automatically with jest-axe in npm test (so they run in CI), and the audit is also run over the composition the dashboards actually render in src/components/dashboard/dashboard-charts.a11y.test.tsx. The tests assert zero violations, not just zero critical/serious ones.
| Problem | Approach |
|---|---|
| Bars conveyed only as coloured rectangles | Visual bars are aria-hidden; the accessible equivalent is a visually-hidden (sr-only) <table> with a caption naming the chart and a real value per row. |
Sparkline was aria-hidden with no equivalent at all |
Now role="img" with an accessible name summarising the trend — point count, direction, first, last, and full range. StatCard forwards its own label so it reads as "Earnings: 8 data points, trending up from 12 to 31", not an unlabelled graphic. |
Values only in a title attribute |
Unreachable by keyboard and unreliably announced. Each bar is now focusable with a visible focus-visible ring and reveals the same tooltip on focus (group-focus-within) as on hover, inside the aria-hidden subtree so values aren't announced twice. |
| Sign encoded by colour alone (indigo vs rose) | Negative bars also carry a diagonal stripe pattern, and the value keeps its sign in both the tooltip and the data table. |
| Label/series colours failing WCAG AA | Measured, not eyeballed. Bar labels: slate-500 on white = 4.76:1 and slate-400 on slate-900 = 6.96:1 (previously 2.56:1 and 3.75:1, both failing). Negative bars: rose-600/80 on white = 3.75:1, above the 3:1 required of a graphical object (previously 2.97:1). Contrast ratios are recorded in the component doc comments. |
Manual QA still required. Automated tooling cannot verify announcement order or wording in a real screen reader, so the VoiceOver (Safari) and NVDA (Firefox/Chrome) pass over /dashboard/sponsor, /dashboard/contributor, /dashboard/maintainer, and /issues/[id] is a manual checklist: each chart is announced once with its data reachable as text, a negative bar's sign is conveyed without relying on colour, and every bar is reachable and readable by keyboard alone.
- A maintainer connects a GitHub repository from
/connect. - From their dashboard, they mark an issue with a 100 USDC reward.
- A sponsor funds the bounty from
/dashboard/sponsor— the backend locks the funds in a Soroban escrow contract. - A contributor browses
/issues, claims the bounty, and opens a pull request. - The maintainer reviews and merges it on GitHub.
- The backend detects the merge via a GitHub webhook and calls the escrow contract's release function.
- The contributor is paid on-chain within minutes, and their
/reputation/[handle]profile updates.
- Wire the contributor/maintainer dashboards to authenticated sessions instead of a hardcoded demo handle.
- Real-time bounty/escrow status via websockets or polling once the backend emits webhook-driven events.
- AI-powered issue recommendations based on a contributor's language/repo history.
- Organization verification badges and cross-repository reputation aggregation.