diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts
index 56fb951..f50d30b 100644
--- a/src/app/auth/callback/route.ts
+++ b/src/app/auth/callback/route.ts
@@ -2,6 +2,8 @@ import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
+import { isSupabaseConfigured } from "@/lib/supabase/config";
+
// Always redirect to the canonical domain after OAuth so that arriving via
// any auto-assigned Vercel URL (e.g. studymapp-student-suite.vercel.app)
// doesn't leave the user stranded on the wrong domain.
@@ -13,6 +15,11 @@ export async function GET(request: Request) {
const code = searchParams.get("code");
const next = searchParams.get("next") ?? "/";
+ // No Supabase configured (self-host / preview mode): nothing to exchange.
+ if (!isSupabaseConfigured()) {
+ return NextResponse.redirect(`${SITE_URL}/`);
+ }
+
if (code) {
const cookieStore = await cookies();
const supabase = createServerClient(
diff --git a/src/app/calendar/CalendarView.tsx b/src/app/calendar/CalendarView.tsx
index 3037a95..97143c5 100644
--- a/src/app/calendar/CalendarView.tsx
+++ b/src/app/calendar/CalendarView.tsx
@@ -126,6 +126,7 @@ export function CalendarView() {
useEffect(() => {
const supabase = createClient();
+ if (!supabase) return; // self-host / preview mode: no auth, no personal events
supabase.auth.getUser().then(({ data }) => setUser(data.user));
const {
data: { subscription },
diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx
index 7c7ee53..694d164 100644
--- a/src/app/login/login-form.tsx
+++ b/src/app/login/login-form.tsx
@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
+import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
@@ -23,19 +24,41 @@ export function LoginForm() {
const [password, setPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
+ // Self-host / preview mode: Supabase isn't configured, so there's no auth.
+ if (!supabase) {
+ return (
+
+
+
+ Sign-in is not available on this deployment. StudyMap is running in
+ preview mode without accounts. The map and calendar work fully
+ without signing in.
+
+
+
+
+ );
+ }
+
+ // Non-null past the guard above; captured so the handler closures below
+ // don't re-widen it back to `SupabaseClient | null`.
+ const client = supabase;
+
async function handleEmailAuth(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
if (mode === "signup") {
- const { error } = await supabase.auth.signUp({ email, password });
+ const { error } = await client.auth.signUp({ email, password });
if (error) {
toast.error(error.message);
} else {
toast.success("Check your email to confirm your account.");
}
} else {
- const { error } = await supabase.auth.signInWithPassword({
+ const { error } = await client.auth.signInWithPassword({
email,
password,
});
@@ -55,7 +78,7 @@ export function LoginForm() {
// fall back to the hardcoded canonical domain so any auto-assigned
// Vercel URL never leaks into the OAuth redirectTo.
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? site.url;
- const { error } = await supabase.auth.signInWithOAuth({
+ const { error } = await client.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${siteUrl}/auth/callback?next=${encodeURIComponent(next)}`,
diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx
index a74d410..d88e625 100644
--- a/src/components/layout/navbar.tsx
+++ b/src/components/layout/navbar.tsx
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { ThemeToggle } from "@/components/layout/theme-toggle";
import { createClient } from "@/lib/supabase/client";
+import { isSupabaseConfigured } from "@/lib/supabase/config";
export function Navbar() {
const pathname = usePathname();
@@ -18,8 +19,13 @@ export function Navbar() {
const [open, setOpen] = React.useState(false);
const [loggedIn, setLoggedIn] = React.useState(false);
+ // Auth is only available when Supabase is configured. In self-host / preview
+ // mode we hide sign-in entirely and skip the auth listener.
+ const authEnabled = isSupabaseConfigured();
+
React.useEffect(() => {
const supabase = createClient();
+ if (!supabase) return;
supabase.auth.getUser().then(({ data }) => setLoggedIn(!!data.user));
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_, session) => setLoggedIn(!!session),
@@ -29,6 +35,7 @@ export function Navbar() {
async function handleSignOut() {
const supabase = createClient();
+ if (!supabase) return;
await supabase.auth.signOut();
router.refresh();
}
@@ -63,29 +70,30 @@ export function Navbar() {
- {loggedIn ? (
-
- ) : (
-
- )}
+ {authEnabled &&
+ (loggedIn ? (
+
+ ) : (
+
+ ))}
@@ -109,24 +117,25 @@ export function Navbar() {
{link.label}
))}
- {loggedIn ? (
-
- ) : (
- setOpen(false)}
- className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
- >
-
- Sign in
-
- )}
+ {authEnabled &&
+ (loggedIn ? (
+
+ ) : (
+ setOpen(false)}
+ className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+ >
+
+ Sign in
+
+ ))}
diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx
index fcf0955..1f11e91 100644
--- a/src/components/map/places-map.tsx
+++ b/src/components/map/places-map.tsx
@@ -95,6 +95,7 @@ export function PlacesMap({ places }: PlacesMapProps) {
React.useEffect(() => {
const supabase = createClient();
+ if (!supabase) return; // self-host / preview mode: no auth, no private layer
supabase.auth.getUser().then(({ data }) => setUser(data.user));
const {
data: { subscription },
diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts
index 2ad2591..8232913 100644
--- a/src/lib/supabase/client.ts
+++ b/src/lib/supabase/client.ts
@@ -1,6 +1,14 @@
import { createBrowserClient } from "@supabase/ssr";
+import { isSupabaseConfigured } from "./config";
+
+/**
+ * Browser Supabase client, or `null` when Supabase isn't configured.
+ * Callers must handle the null case: in self-host / preview mode the app
+ * runs without auth or any private features.
+ */
export function createClient() {
+ if (!isSupabaseConfigured()) return null;
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
diff --git a/src/lib/supabase/config.ts b/src/lib/supabase/config.ts
new file mode 100644
index 0000000..16fe8d7
--- /dev/null
+++ b/src/lib/supabase/config.ts
@@ -0,0 +1,14 @@
+/**
+ * True when Supabase environment variables are set.
+ *
+ * When false, the app runs in self-host / preview mode: no sign-in, no saved
+ * places, no personal calendar events - just the public map and calendar.
+ * This lets contributors run StudyMap locally to preview their map entries
+ * without provisioning a Supabase project. See SELF-HOSTING.md and issue #72.
+ */
+export function isSupabaseConfigured(): boolean {
+ return Boolean(
+ process.env.NEXT_PUBLIC_SUPABASE_URL &&
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
+ );
+}
diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts
index 731e0af..b366103 100644
--- a/src/lib/supabase/server.ts
+++ b/src/lib/supabase/server.ts
@@ -1,7 +1,15 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
+import { isSupabaseConfigured } from "./config";
+
+/**
+ * Server Supabase client, or `null` when Supabase isn't configured
+ * (self-host / preview mode).
+ */
export async function createClient() {
+ if (!isSupabaseConfigured()) return null;
+
const cookieStore = await cookies();
return createServerClient(
diff --git a/src/lib/user-events.ts b/src/lib/user-events.ts
index b7df515..3eedef0 100644
--- a/src/lib/user-events.ts
+++ b/src/lib/user-events.ts
@@ -1,5 +1,16 @@
import { createClient } from "@/lib/supabase/client";
+/**
+ * Supabase client for the private-data calls below. These only ever run for a
+ * signed-in user, which is impossible without Supabase configured, so a null
+ * client here means something is badly misconfigured - throw rather than guess.
+ */
+function requireClient() {
+ const supabase = createClient();
+ if (!supabase) throw new Error("Supabase is not configured");
+ return supabase;
+}
+
export type PersonalEventCategory =
| "deadline"
| "exam"
@@ -36,7 +47,7 @@ export interface PersonalEventInput {
}
export async function fetchUserEvents(): Promise {
- const supabase = createClient();
+ const supabase = requireClient();
const { data, error } = await supabase
.from("user_events")
.select("*")
@@ -48,7 +59,7 @@ export async function fetchUserEvents(): Promise {
export async function createUserEvent(
input: PersonalEventInput,
): Promise {
- const supabase = createClient();
+ const supabase = requireClient();
const { data, error } = await supabase
.from("user_events")
.insert(input)
@@ -62,7 +73,7 @@ export async function updateUserEvent(
id: string,
input: PersonalEventInput,
): Promise {
- const supabase = createClient();
+ const supabase = requireClient();
const { data, error } = await supabase
.from("user_events")
.update(input)
@@ -74,7 +85,7 @@ export async function updateUserEvent(
}
export async function deleteUserEvent(id: string): Promise {
- const supabase = createClient();
+ const supabase = requireClient();
const { error } = await supabase.from("user_events").delete().eq("id", id);
if (error) throw error;
}
diff --git a/src/lib/user-places.ts b/src/lib/user-places.ts
index e7c14a3..7c75a7e 100644
--- a/src/lib/user-places.ts
+++ b/src/lib/user-places.ts
@@ -53,15 +53,26 @@ export function userPlaceToPlace(row: UserPlaceRow): Place {
};
}
-async function currentUserId(): Promise {
+/**
+ * Supabase client for the private-data calls below. These only ever run for a
+ * signed-in user, which is impossible without Supabase configured, so a null
+ * client here means something is badly misconfigured - throw rather than guess.
+ */
+function requireClient() {
const supabase = createClient();
+ if (!supabase) throw new Error("Supabase is not configured");
+ return supabase;
+}
+
+async function currentUserId(): Promise {
+ const supabase = requireClient();
const { data } = await supabase.auth.getUser();
if (!data.user) throw new Error("Not signed in");
return data.user.id;
}
export async function fetchUserPlaces(): Promise {
- const supabase = createClient();
+ const supabase = requireClient();
const { data, error } = await supabase
.from("user_places")
.select("*")
@@ -71,7 +82,7 @@ export async function fetchUserPlaces(): Promise {
}
export async function createUserPlace(input: UserPlaceInput): Promise {
- const supabase = createClient();
+ const supabase = requireClient();
const user_id = await currentUserId();
const { data, error } = await supabase
.from("user_places")
@@ -86,7 +97,7 @@ export async function updateUserPlace(
id: string,
input: UserPlaceInput,
): Promise {
- const supabase = createClient();
+ const supabase = requireClient();
const { data, error } = await supabase
.from("user_places")
.update(input)
@@ -98,20 +109,20 @@ export async function updateUserPlace(
}
export async function deleteUserPlace(id: string): Promise {
- const supabase = createClient();
+ const supabase = requireClient();
const { error } = await supabase.from("user_places").delete().eq("id", id);
if (error) throw error;
}
export async function fetchUserHome(): Promise {
- const supabase = createClient();
+ const supabase = requireClient();
const { data, error } = await supabase.from("user_home").select("*").maybeSingle();
if (error) throw error;
return data;
}
export async function upsertUserHome(input: UserHomeInput): Promise {
- const supabase = createClient();
+ const supabase = requireClient();
const user_id = await currentUserId();
const { data, error } = await supabase
.from("user_home")
@@ -123,7 +134,7 @@ export async function upsertUserHome(input: UserHomeInput): Promise {
}
export async function deleteUserHome(): Promise {
- const supabase = createClient();
+ const supabase = requireClient();
const user_id = await currentUserId();
const { error } = await supabase.from("user_home").delete().eq("user_id", user_id);
if (error) throw error;
diff --git a/src/proxy.ts b/src/proxy.ts
index b4d3039..2f6b617 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -1,6 +1,8 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
+import { isSupabaseConfigured } from "@/lib/supabase/config";
+
const CANONICAL = "https://studymapp.vercel.app";
// Vercel auto-assigns this URL based on the team name — it can't be deleted,
// so we intercept every request on it and redirect to the canonical domain.
@@ -20,6 +22,12 @@ export async function proxy(request: NextRequest) {
let proxyResponse = NextResponse.next({ request });
+ // Self-host / preview mode: no Supabase, so there's no session to refresh.
+ // Skipping this keeps every route working without Supabase credentials.
+ if (!isSupabaseConfigured()) {
+ return proxyResponse;
+ }
+
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,