A real-time bookmark manager built with Next.js 15 (App Router), Supabase, and Tailwind CSS. Google OAuth only, private per-user bookmarks, and live sync across browser tabs.
| Layer | Tool |
|---|---|
| Framework | Next.js 15 (App Router) |
| Auth | Supabase Auth (Google OAuth) |
| Database | Supabase PostgreSQL |
| Realtime | Supabase Realtime (postgres_changes) |
| Styling | Tailwind CSS |
| Deploy | Vercel |
smart-bookmarks/
├── app/
│ ├── layout.js # Root layout with fonts
│ ├── globals.css # Tailwind + custom animations
│ ├── page.js # Login page (server component)
│ ├── bookmarks/
│ │ └── page.js # Bookmarks page (server, protected)
│ └── auth/
│ └── callback/
│ └── route.js # OAuth callback handler
├── components/
│ ├── LoginButton.js # Google OAuth button (client)
│ └── BookmarksClient.js # Main bookmarks UI + realtime (client)
├── lib/
│ ├── supabase-browser.js # Browser Supabase client
│ └── supabase-server.js # Server Supabase client (async, Next.js 15)
├── middleware.js # Auth route protection + session refresh
├── supabase-migration.sql # DB schema + RLS + Realtime setup
├── .env.local.example # Environment variable template
└── vercel.json # Vercel deployment config
- Go to https://supabase.com and create a new project.
- From Project Settings → API, copy:
Project URL→NEXT_PUBLIC_SUPABASE_URLanon publickey →NEXT_PUBLIC_SUPABASE_ANON_KEY
- In your Supabase dashboard, go to SQL Editor.
- Paste and run the contents of
supabase-migration.sql.
This creates:
bookmarkstable withuser_id,url,title,created_at- Row Level Security (RLS) policies so users only see their own data
- Realtime enabled on the table
- In Supabase, go to Database → Replication.
- Find the
bookmarkstable and toggle it on. - Then go to Database → Publications → supabase_realtime and confirm
bookmarksis listed.
Without this step, bookmarks will still save correctly (with optimistic UI), but cross-tab realtime sync won't work.
- Go to Authentication → Providers → Google.
- Enable Google OAuth.
- In Google Cloud Console:
- Create a new project (or use an existing one).
- Go to APIs & Services → Credentials → Create OAuth 2.0 Client ID.
- Set Authorized JavaScript Origins:
https://your-supabase-project.supabase.co - Set Authorized redirect URIs:
https://your-supabase-project.supabase.co/auth/v1/callback
- Copy the Client ID and Client Secret back into Supabase Google provider settings.
cd smart-bookmarks
npm install
copy .env.local.example .env.local
notepad .env.localFill in your values and save:
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGc...
Then run:
npm run devOpen http://localhost:3000.
npm install -g vercel
vercel
vercel env add NEXT_PUBLIC_SUPABASE_URL
vercel env add NEXT_PUBLIC_SUPABASE_ANON_KEY
vercel --prod- Push the project to a GitHub repository.
- Go to https://vercel.com, click New Project, and import the repo.
- In Environment Variables, add:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY
- Click Deploy.
After deploying, go to Supabase → Authentication → URL Configuration:
- Set Site URL to your Vercel production URL:
https://your-app.vercel.app - Add to Redirect URLs:
https://your-app.vercel.app/auth/callback
Next.js 15 made cookies() async. The createClient() function in lib/supabase-server.js must be async and use await cookies(). All callers (app/bookmarks/page.js, app/auth/callback/route.js) must also await createClient().
// lib/supabase-server.js
export async function createClient() {
const cookieStore = await cookies() // await is required
...
}
// app/bookmarks/page.js and app/auth/callback/route.js
const supabase = await createClient() // await is requiredIf you see a React hydration mismatch warning mentioning data-gr-ext-installed, it's caused by the Grammarly browser extension modifying the <body> tag. Fix it by adding suppressHydrationWarning to <body> in app/layout.js:
<body
className="bg-[#0a0a0a] text-[#e8e8e0] min-h-screen font-mono antialiased"
suppressHydrationWarning
>The insert call must use .select().single() to return the saved row and update state immediately (optimistic UI). Realtime handles cross-tab sync, but the inserting tab should not rely on it alone. Update handleAdd in BookmarksClient.js:
const { data, error: insertError } = await supabase
.from('bookmarks')
.insert({ user_id: user.id, url: finalUrl, title: finalTitle })
.select() // returns the inserted row
.single()
// on success, add to local state immediately:
if (!insertError) {
setBookmarks((prev) => {
if (prev.find((b) => b.id === data.id)) return prev
return [data, ...prev]
})
}supabase
.channel('bookmarks-realtime')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'bookmarks',
filter: `user_id=eq.${user.id}`, // server-side filter for privacy
}, (payload) => {
// INSERT → add to list, DELETE → remove from list
})
.subscribe()The filter ensures each user's realtime channel only receives their own bookmark changes. Combined with optimistic UI on insert, the app feels instant in the active tab and syncs automatically across any other open tabs.