This repo hosts production-quality example apps built on urBackend to showcase its core features. Each example lives in its own sub-folder and is self-contained.
| # | App | Description | Status |
|---|---|---|---|
| 1 | featuresync/ |
Canny.io clone — Feature request board | Planning |
A Canny.io clone that demonstrates Auth, Database, RLS, and Admin Roles all in one Next.js app.
- Framework: Next.js (App Router, JavaScript — no TypeScript for simplicity as an example)
- Styling: Vanilla CSS (
globals.css+ component-scoped styles) - Auth + DB:
@urbackend/react+@urbackend/sdk - Font: Inter from Google Fonts
npx -y create-next-app@latest featuresync --no-typescript --no-tailwind --eslint --app --src-dir --import-alias "@/*"
cd featuresync
npm install @urbackend/react @urbackend/sdk# .env.local
NEXT_PUBLIC_URBACKEND_API_KEY=pk_live_...
URBACKEND_SECRET_KEY=sk_live_...Critical rules (from docs):
- ONLY ever use
pk_live_...(Publishable Key) on the frontend. NEVERsk_live_.... - The env var must be prefixed
NEXT_PUBLIC_to be accessible in the browser. - The SDK logs a console warning if it detects
sk_live_in a browser context. - No
NEXT_PUBLIC_URBACKEND_PROJECT_IDneeded — the API key identifies the project.
Before running the app, you must configure your urBackend project exactly like this:
This is required by the urBackend auth system.
- Go to Database -> Collections -> Create Collection
- Name it exactly:
users - Add the following fields:
- Field name:
email, Type:String, check Required, check Unique - Field name:
password, Type:String, check Required - Field name:
name, Type:String - Field name:
role, Type:String, Default Value:"user"
- Field name:
- Click Save Collection.
This will store all the feature requests.
- Create a new collection named:
features - Add the following fields:
- Field name:
title, Type:String, check Required - Field name:
description, Type:String, check Required - Field name:
status, Type:String, check Required, Default Value:"under_review" - Field name:
userId, Type:Ref(Reference touserscollection), check Required (This is for RLS) - Field name:
userName, Type:String - Field name:
votes, Type:Array(set array item type toRefpointing touserscollection) - Field name:
tags, Type:Array
- Field name:
- Go to the Security / Rules (RLS) tab for this collection:
- Enable Row Level Security (RLS)
- Set Mode to:
public-read(anyone can read, but only owners can write) - Set Owner Field to:
userId
- Click Save Collection.
This will store the comments for each feature.
- Create a new collection named:
comments - Add the following fields:
- Field name:
featureId, Type:Ref(Reference tofeaturescollection), check Required - Field name:
content, Type:String, check Required - Field name:
userId, Type:Ref(Reference touserscollection), check Required - Field name:
userName, Type:String
- Field name:
- Go to the Security / Rules (RLS) tab:
- Enable Row Level Security (RLS)
- Set Mode to:
public-read - Set Owner Field to:
userId
- Click Save Collection.
- Go to the Authentication tab in the sidebar.
- Ensure Allow Public Signups is toggled ON (so new users can register).
- Optionally, enable Social Providers like Google or GitHub if you have Client IDs.
- Go to Project Settings.
- Find Site URL and set it to:
http://localhost:3000(Update this when deploying to production).
When inserting with pk_live + user JWT + RLS enabled:
- If you omit
userIdfrom the body → urBackend auto-injects the authenticated user's ID. ✅ - If you include
userIdand it matches the JWT → allowed. - If you include
userIdbut it doesn't match JWT → 403 rejected.
Consequence for our insert calls:
// CORRECT — omit userId, let urBackend inject it
db.insert('features', { title, description, tags, userName: user.name, votes: [], status: 'under_review' }, accessToken)
// ALSO CORRECT — include it explicitly (must match)
db.insert('features', { title, description, tags, userId: user._id, userName: user.name, votes: [], status: 'under_review' }, accessToken)useDb() returns the raw db client. For RLS writes you must pass the access token manually:
const db = useDb();
const { user } = useUser();
const { getToken } = useAuth(); // useAuth exposes getToken()
// Pass token as 3rd argument to insert
await db.insert('features', { ... }, getToken());
// Pass token as 3rd argument to update/patch/delete
await db.patch('features', featureId, { status: 'planned' }, getToken());
await db.delete('features', featureId, getToken());Note: useAuth() returns { login, logout, error, isLoading } — check if getToken() is available or use useUser() to read user and store the token from login response. The React SDK manages the access token internally via the provider — access it via client.auth.getToken() if needed.
urBackend does NOT have MongoDB $push/$pull operators. The votes array must be managed client-side:
// To vote:
const feature = await db.getOne('features', featureId);
const newVotes = [...feature.votes, user._id];
await db.patch('features', featureId, { votes: newVotes }, token);
// To unvote:
const newVotes = feature.votes.filter(id => id !== user._id);
await db.patch('features', featureId, { votes: newVotes }, token);RLS concern on votes: votes patch is updating a feature the user does NOT own. This will be blocked by RLS (only owner can update). Design decision needed:
- Option A: Use
sk_liveon a Next.js API route (/api/vote) to perform the patch server-side. ✅ Recommended. - Option B: Make votes a separate collection with its own RLS (each vote is a document owned by the voter).
- Plan decision: Use Option A — a Next.js API Route with
sk_livefor votes and status updates.
An admin changing status on someone else's feature will hit a 403 RLS owner mismatch. Solution: same server-side API route pattern using sk_live.
// src/providers/Providers.js
"use client";
import { UrProvider } from '@urbackend/react';
export function Providers({ children }) {
return (
<UrProvider apiKey={process.env.NEXT_PUBLIC_URBACKEND_API_KEY}>
{children}
</UrProvider>
);
}// src/app/layout.js
import { Providers } from '../providers/Providers';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata = {
title: 'FeatureSync — Feature Request Board',
description: 'Submit and vote on feature requests. Built on urBackend.',
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>
{children}
</Providers>
</body>
</html>
);
}urBackend calls:
const db = useDb();
// Public read — no token needed (public-read RLS mode)
const features = await db.getAll('features', { sort: 'createdAt:desc', limit: 100 });Sort logic (client-side):
// Most voted
const sorted = [...features].sort((a, b) => b.votes.length - a.votes.length);
// Newest
const sorted = [...features].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));Filter logic (can use db filter OR client-side):
// Server-side filter (preferred)
const features = await db.getAll('features', { filter: { status: 'planned' } });
// OR client-side filter
const filtered = features.filter(f => f.status === selectedStatus);isInitializing guard (important):
const { user, isInitializing } = useUser();
if (isInitializing) return <LoadingSkeleton />;// src/app/login/page.js
"use client";
import { GuestRoute, UrAuth } from '@urbackend/react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
return (
<GuestRoute fallback={<div>Loading...</div>} onRedirect={() => router.push('/')}>
<UrAuth
providers={['google', 'github']}
enableEmailPassword={true}
theme="light"
branding={{ appName: 'FeatureSync', subtitle: 'Submit and vote on feature requests' }}
onSuccess={() => router.push('/')}
/>
</GuestRoute>
);
}Notes from docs:
GuestRouteauto-redirects already-logged-in users away from the login page. Use it here.onSuccesscallback fires after successful sign-in or sign-up.- Social login redirects user to the provider then back to
<siteUrl>/auth/callback— this is handled automatically by urBackend, NOT by our app. providersaccepts array form['google', 'github']OR object form{ google: true, github: true, emailPassword: true }.hideSignup={false}is the default — no need to set it explicitly.
"use client";
import { ProtectedRoute } from '@urbackend/react';
import { useRouter } from 'next/navigation';
export default function SubmitPage() {
const router = useRouter();
return (
<ProtectedRoute fallback={<div>Loading...</div>} onRedirect={() => router.push('/login')}>
<SubmitForm />
</ProtectedRoute>
);
}DB insert (omit userId — auto-injected):
const db = useDb();
const { user } = useUser();
// Get access token from auth context
// useAuth() gives login/logout — for token, read from user session
// The React SDK stores token internally; access via the underlying client if needed
await db.insert('features', {
title,
description,
tags,
userName: user.name || user.email,
votes: [],
status: 'under_review'
// userId is intentionally OMITTED — urBackend auto-injects it
}, token);Accessing the token in React hooks:
The React SDK's useAuth() exposes login / logout but NOT getToken() directly per the hooks docs. The access token is managed internally. Options:
- Store it in component state after login if building custom flow.
- Use the underlying
@urbackend/sdkclient thatuseDb()wraps — it calls its ownauth.getToken()internally for authenticated requests. - Check if
useAuth()exposes additional methods in the actual SDK source.
Action: verify useAuth() return shape in sdks/urbackend-react source before building /submit.
// Fetch feature
const feature = await db.getOne('features', id);
// Fetch comments for this feature
const comments = await db.getAll('comments', { filter: { featureId: id }, sort: 'createdAt:asc' });
// Post comment (RLS write — userId auto-injected)
await db.insert('comments', { featureId: id, content, userName: user.name }, token);
// Vote / Unvote — goes through API route (see server-side routes section)
await fetch('/api/vote', { method: 'POST', body: JSON.stringify({ featureId: id, userId: user._id, action: 'toggle' }) });Owner check for edit/delete:
const canEdit = user && (user._id === feature.userId || user.role === 'admin');Note on user.role: The role field must exist in the users collection schema in the dashboard. Set role: "admin" manually for admin users (or via a dashboard admin panel / direct DB seed).
Admin check:
const { user, isInitializing } = useUser();
if (!isInitializing && (!user || user.role !== 'admin')) router.push('/');Status update goes through server API route (to bypass RLS):
await fetch('/api/admin/update-status', {
method: 'POST',
body: JSON.stringify({ featureId, status })
});Delete also goes through server API route:
await fetch('/api/admin/delete-feature', {
method: 'DELETE',
body: JSON.stringify({ featureId })
});These use sk_live on the server to bypass RLS for cross-user operations.
src/app/api/
├── vote/
│ └── route.js ← Toggle vote on a feature (sk_live patch)
├── admin/
│ ├── update-status/
│ │ └── route.js ← Admin status change (sk_live patch)
│ └── delete-feature/
│ └── route.js ← Admin delete (sk_live delete)
Example vote/route.js:
import urBackend from '@urbackend/sdk';
import { NextResponse } from 'next/server';
const client = urBackend({ apiKey: process.env.URBACKEND_SECRET_KEY });
export async function POST(req) {
const { featureId, userId, action } = await req.json();
const feature = await client.db.getOne('features', featureId);
let newVotes = feature.votes || [];
if (action === 'toggle') {
if (newVotes.includes(userId)) {
newVotes = newVotes.filter(id => id !== userId);
} else {
newVotes = [...newVotes, userId];
}
}
const updated = await client.db.patch('features', featureId, { votes: newVotes });
return NextResponse.json({ success: true, data: updated });
}Required additional env var (server-side only, NOT prefixed with NEXT_PUBLIC_):
URBACKEND_SECRET_KEY=sk_live_...featuresync/
├── src/
│ ├── app/
│ │ ├── layout.js <- Root layout (Providers + metadata)
│ │ ├── page.js <- Public board (/)
│ │ ├── globals.css <- Design system + CSS variables
│ │ ├── login/
│ │ │ └── page.js <- GuestRoute + UrAuth
│ │ ├── submit/
│ │ │ └── page.js <- ProtectedRoute + submit form
│ │ ├── feature/
│ │ │ └── [id]/
│ │ │ └── page.js <- Detail + comments + vote
│ │ ├── admin/
│ │ │ └── page.js <- Admin dashboard (role check)
│ │ └── api/
│ │ ├── vote/
│ │ │ └── route.js <- Server: toggle vote (sk_live)
│ │ └── admin/
│ │ ├── update-status/
│ │ │ └── route.js <- Server: status update (sk_live)
│ │ └── delete-feature/
│ │ └── route.js <- Server: delete feature (sk_live)
│ ├── components/
│ │ ├── Navbar.js <- Logo, nav links, UrUserButton
│ │ ├── FeatureCard.js <- Card: title, desc, votes, status, tags
│ │ ├── VoteButton.js <- Arrow-up icon + count, calls /api/vote
│ │ ├── StatusBadge.js <- Color-coded pill badge
│ │ ├── TagBadge.js <- Tag chip
│ │ ├── CommentBox.js <- Comment list + input
│ │ ├── FilterBar.js <- Status filter tabs
│ │ ├── SortBar.js <- Most Voted / Newest toggle
│ │ └── LoadingSkeleton.js <- Skeleton while isInitializing
│ └── providers/
│ └── Providers.js <- "use client" UrProvider wrapper
├── .env.local <- NEXT_PUBLIC_ + URBACKEND_SECRET_KEY
├── .env.local.example <- Safe to commit — no real values
└── README.md <- Full setup guide
| Feature | Where Used | Exact API |
|---|---|---|
<UrProvider> |
Providers.js |
apiKey={process.env.NEXT_PUBLIC_URBACKEND_API_KEY} |
<UrAuth> |
/login page |
providers, theme, branding, onSuccess props |
<GuestRoute> |
/login page |
fallback + onRedirect — redirects logged-in users away |
<ProtectedRoute> |
/submit page |
fallback + onRedirect — redirects guests to login |
<UrUserButton> |
Navbar.js |
position="inline", onProfileClick, auto-hides if logged out |
useUser() |
All pages | { user, isAuthenticated, isLoading, isInitializing } |
useAuth() |
Navbar (logout) | { login, logout, error, isLoading } |
useDb() |
All data pages | db.getAll(), db.getOne(), db.insert(), db.patch(), db.delete() |
Public Read (pk_live) |
Board + Detail page | db.getAll() without token — works because public-read RLS |
RLS Write (pk_live + JWT) |
Submit + Comment | Pass token as 3rd arg to db.insert() |
Server-side writes (sk_live) |
API Routes | Used for vote toggle + admin actions that cross ownership |
| Admin Role Check | /admin + detail page |
user.role === 'admin' — field must be in users schema |
| Soft Delete | Behind the scenes | db.delete() soft-deletes (30-day grace). Deleted docs excluded by default. |
- Sidebar:
#0f0f0fdark — logo + nav links - Content area:
#f8f8f8light — feature cards - Accent:
#5B4FFF— brand purple (vote button, CTA, active states) - Font: Inter (via
next/font/google)
| Status | Color |
|---|---|
under_review |
#9ca3af — Grey |
planned |
#3b82f6 — Blue |
in_progress |
#f59e0b — Amber |
done |
#22c55e — Green |
:root {
--color-bg: #f8f8f8;
--color-sidebar: #0f0f0f;
--color-surface: #ffffff;
--color-border: #e5e7eb;
--color-accent: #5B4FFF;
--color-text: #111827;
--color-text-muted: #6b7280;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 16px rgba(0,0,0,0.12);
}- Not voted: outlined border, grey arrow
- Voted: solid
--color-accentfill, white arrow - Transition:
all 0.15s ease
<UrUserButton
position="inline" // fits into flex navbar layout
shape="circle"
/>From docs — SDK throws typed errors:
import { AuthError, NotFoundError, RateLimitError, ValidationError } from '@urbackend/sdk';Key errors to handle in the UI:
AuthError(401/403) — session expired → redirect to loginNotFoundError(404) — feature deleted → show "not found" stateValidationError(400) — bad input → show inline form errorRateLimitError(429) — 100 req / 15 min per IP → show "too many requests" toast
-
isInitializingvsisLoading: Always checkisInitializingfirst (SDK is reading from localStorage). Only checkisAuthenticatedafterisInitializingis false. -
userIdin votes patch: Patching another user's document is blocked by RLS. Always use server-side API route for vote toggling. -
Owner field immutability: Once a document is inserted,
userIdcannot be changed viapatch/update. Don't include it in update bodies. -
Soft delete:
db.delete()is a soft delete (30-day grace). Deleted features won't appear in normalgetAll()calls. This is the correct behaviour for our app. -
Refs not auto-populated:
featureIdincommentsis stored as a plain string ID. We must fetch the feature and comments separately, not via a join. -
Schema strictly typed:
votesis anArraytype — send[]notnullon insert.tagsis alsoArray. Never send wrong types or you get a 400. -
userscollection is blocked: Never calldb.getAll('users')ordb.getOne('users', id). Always useuseUser()hook for the current user's data. -
Social auth redirect: After Google/GitHub login, urBackend redirects to
<siteUrl>/auth/callback. We do NOT need to build this page — the React SDK handles the exchange automatically viasocialExchange()under the hood when the user lands back. -
getToken()availability: The React SDK manages tokens internally. IfuseAuth()doesn't exposegetToken(), use a workaround: store the token in React state after a manual login, or check the underlying client instance. -
limitcap:db.getAll()max is 100 documents per page. For larger datasets, implement pagination withpageparam.
-
npx create-next-appscaffold +npm install @urbackend/react @urbackend/sdk -
.env.localwithNEXT_PUBLIC_URBACKEND_API_KEYandURBACKEND_SECRET_KEY -
Providers.js— UrProvider wrapper -
layout.js— root layout with Providers + Inter font + metadata -
globals.css— full design system (variables, resets, typography, utility classes) -
LoadingSkeleton.js— skeleton cards for loading state -
StatusBadge.js— color-coded badge -
TagBadge.js— tag chip -
VoteButton.js— voted/unvoted states, calls/api/vote -
FeatureCard.js— full card with all sub-components -
FilterBar.js— status filter tabs -
SortBar.js— most voted / newest toggle -
CommentBox.js— list + input -
Navbar.js— logo, links, UrUserButton (inline) -
api/vote/route.js— server-side vote toggle -
api/admin/update-status/route.js— server-side status update -
api/admin/delete-feature/route.js— server-side delete -
/board page — fetch, filter, sort, empty state -
/loginpage — GuestRoute + UrAuth with branding -
/submitpage — ProtectedRoute + form -
/feature/[id]page — detail, comments, vote, edit/delete -
/adminpage — role check, table, stats panel -
.env.local.example -
README.md— full dashboard setup steps - Final: responsive check, empty states, loading skeletons, error boundaries