From 4431778c0a05f24e8e41f8408d2d4cae8871b3d9 Mon Sep 17 00:00:00 2001
From: Randall Stillwell
Date: Sun, 19 Apr 2026 10:12:44 -0500
Subject: [PATCH] Replace hardcoded echoocr.com fallbacks with resolved site
URL
Introduces src/lib/site-url.ts with getSiteUrl(), getSiteUrlFromRequest(),
and getSiteHostname() helpers that cascade through NEXT_PUBLIC_SITE_URL,
AUTH_URL, VERCEL_URL, and localhost so no code path ever falls back to a
third-party domain we may not own.
- forgot-password route now derives the base URL from the incoming request
origin so reset links always match the host the user hit
- email-sender, layout metadata, email preview, and QR code routes use the
new helper; email footers display the derived hostname instead of a
hardcoded brand string
- .env.example clarifies the real expected values for AUTH_URL and
NEXT_PUBLIC_SITE_URL per environment
Made-with: Cursor
---
.env.example | 10 +++-
src/app/api/auth/forgot-password/route.ts | 18 +++---
src/app/api/dev/email-preview/route.ts | 6 +-
src/app/api/form-templates/[id]/qr/route.ts | 8 +--
src/app/layout.tsx | 3 +-
src/lib/email-sender.ts | 7 ++-
src/lib/site-url.ts | 62 +++++++++++++++++++++
7 files changed, 94 insertions(+), 20 deletions(-)
create mode 100644 src/lib/site-url.ts
diff --git a/.env.example b/.env.example
index b04656e..1ec8745 100644
--- a/.env.example
+++ b/.env.example
@@ -18,7 +18,11 @@ AI_GATEWAY_API_KEY=""
# ─── Auth.js (required — generate with: npx auth secret) ─────
AUTH_SECRET=""
-AUTH_URL="https://echoocr.yourdomain.com"
+# AUTH_URL must match the canonical origin this deployment serves.
+# Local dev: http://localhost:3000
+# Production: https://
+# Preview: https://-git--.vercel.app
+AUTH_URL="http://localhost:3000"
# ─── OIDC SSO (optional) ──────────────────────────────────────
AUTHENTIK_ISSUER=""
@@ -35,7 +39,9 @@ EMAIL_FROM_ADDRESS="mars@noreply.stillwell.cloud"
CRON_SECRET=""
# ─── Public site URL (used for OG metadata, canonical links) ─
-NEXT_PUBLIC_SITE_URL="https://echoocr.com"
+# Used by transactional emails, OpenGraph tags, and QR codes.
+# If unset, the app falls back to AUTH_URL, then VERCEL_URL, then localhost.
+NEXT_PUBLIC_SITE_URL=""
# ─── Environment indicator ────────────────────────────────────
NEXT_PUBLIC_ENV=""
diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts
index 6cf737a..ec15a52 100644
--- a/src/app/api/auth/forgot-password/route.ts
+++ b/src/app/api/auth/forgot-password/route.ts
@@ -2,9 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { prisma } from "@/lib/db";
import { sendEmail } from "@/lib/email-sender";
-
-const SITE_URL =
- process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com";
+import { getSiteHostname, getSiteUrlFromRequest } from "@/lib/site-url";
const BRAND = {
dark: "#2d2b28",
@@ -19,7 +17,8 @@ const BRAND = {
font: "'Quicksand', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
};
-function resetEmailHtml(resetUrl: string) {
+function resetEmailHtml(resetUrl: string, siteUrl: string) {
+ const hostname = getSiteHostname(siteUrl);
return `
@@ -49,7 +48,7 @@ function resetEmailHtml(resetUrl: string) {
@@ -78,8 +77,13 @@ export async function POST(req: NextRequest) {
},
});
- const resetUrl = `${SITE_URL}/reset-password?token=${token}`;
- await sendEmail(user.email, "Reset your password — Echo", resetEmailHtml(resetUrl));
+ const siteUrl = getSiteUrlFromRequest(req);
+ const resetUrl = `${siteUrl}/reset-password?token=${token}`;
+ await sendEmail(
+ user.email,
+ "Reset your password — Echo",
+ resetEmailHtml(resetUrl, siteUrl)
+ );
}
return NextResponse.json({ ok: true });
diff --git a/src/app/api/dev/email-preview/route.ts b/src/app/api/dev/email-preview/route.ts
index d7bd95d..2607451 100644
--- a/src/app/api/dev/email-preview/route.ts
+++ b/src/app/api/dev/email-preview/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
+import { getSiteHostname, getSiteUrlFromRequest } from "@/lib/site-url";
export async function GET(req: NextRequest) {
if (process.env.NODE_ENV === "production") {
@@ -10,7 +11,8 @@ export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const template = searchParams.get("template") || "verification";
- const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com";
+ const SITE_URL = getSiteUrlFromRequest(req);
+ const SITE_HOSTNAME = getSiteHostname(SITE_URL);
const BRAND = {
dark: "#2d2b28",
@@ -105,7 +107,7 @@ export async function GET(req: NextRequest) {
AI-powered response card scanning for churches
- echoocr.com
+ ${SITE_HOSTNAME}
diff --git a/src/app/api/form-templates/[id]/qr/route.ts b/src/app/api/form-templates/[id]/qr/route.ts
index 2c2be0e..2257dec 100644
--- a/src/app/api/form-templates/[id]/qr/route.ts
+++ b/src/app/api/form-templates/[id]/qr/route.ts
@@ -1,11 +1,12 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
+import { getSiteUrlFromRequest } from "@/lib/site-url";
import QRCode from "qrcode";
type RouteContext = { params: Promise<{ id: string }> };
-export async function GET(_request: NextRequest, ctx: RouteContext) {
+export async function GET(request: NextRequest, ctx: RouteContext) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await ctx.params;
@@ -21,10 +22,7 @@ export async function GET(_request: NextRequest, ctx: RouteContext) {
return new Response("Not found", { status: 404 });
}
- const baseUrl =
- process.env.NEXTAUTH_URL ||
- process.env.AUTH_URL ||
- "https://echoocr.app";
+ const baseUrl = getSiteUrlFromRequest(request);
const surveyUrl = `${baseUrl}/s/${template.organization.slug}/${template.slug}`;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index b78d054..cc8b8fe 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Quicksand } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
+import { getSiteUrl } from "@/lib/site-url";
const quicksand = Quicksand({
variable: "--font-sans",
@@ -9,7 +10,7 @@ const quicksand = Quicksand({
weight: ["300", "400", "500", "600", "700"],
});
-const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com";
+const siteUrl = getSiteUrl();
export const metadata: Metadata = {
title: {
diff --git a/src/lib/email-sender.ts b/src/lib/email-sender.ts
index b3069ab..0c1b7e3 100644
--- a/src/lib/email-sender.ts
+++ b/src/lib/email-sender.ts
@@ -1,6 +1,7 @@
import { BrevoClient } from "@getbrevo/brevo";
import { prisma } from "@/lib/db";
import crypto from "crypto";
+import { getSiteHostname, getSiteUrl } from "@/lib/site-url";
const brevo = new BrevoClient({ apiKey: process.env.BREVO_API_KEY! });
@@ -9,8 +10,8 @@ const DEFAULT_SENDER = {
email: process.env.EMAIL_FROM_ADDRESS || "mars@noreply.stillwell.cloud",
};
-const SITE_URL =
- process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com";
+const SITE_URL = getSiteUrl();
+const SITE_HOSTNAME = getSiteHostname(SITE_URL);
/* ------------------------------------------------------------------ */
/* Shared template shell */
@@ -133,7 +134,7 @@ function emailShell({
AI-powered response card scanning for churches
- echoocr.com
+ ${SITE_HOSTNAME}
diff --git a/src/lib/site-url.ts b/src/lib/site-url.ts
new file mode 100644
index 0000000..198c5e3
--- /dev/null
+++ b/src/lib/site-url.ts
@@ -0,0 +1,62 @@
+/**
+ * Resolve the canonical site URL using a cascade that works across every
+ * environment (local dev, Vercel previews, production).
+ *
+ * Order of preference:
+ * 1. NEXT_PUBLIC_SITE_URL — explicit brand/marketing URL (e.g. https://echo.stillwell.cloud)
+ * 2. AUTH_URL — set in production for Auth.js; identical to canonical host
+ * 3. VERCEL_URL — auto-injected on every Vercel deploy (e.g. *.vercel.app)
+ * 4. http://localhost:3000 — final fallback for local dev
+ *
+ * Never returns a hardcoded third-party domain the team might not own.
+ */
+export function getSiteUrl(): string {
+ const explicit = process.env.NEXT_PUBLIC_SITE_URL || process.env.AUTH_URL;
+ if (explicit) return stripTrailingSlash(explicit);
+
+ if (process.env.VERCEL_URL) {
+ return `https://${process.env.VERCEL_URL}`;
+ }
+
+ return "http://localhost:3000";
+}
+
+/**
+ * Resolve the site URL from an incoming request first (most reliable — whatever
+ * host the user actually hit), falling back to env vars.
+ *
+ * Prefer this in API routes where you're generating links the user will follow
+ * (password reset emails, invite links, QR codes, etc.), so links always point
+ * back to the same domain the request came from.
+ */
+export function getSiteUrlFromRequest(req: Request): string {
+ try {
+ const url = new URL(req.url);
+ const forwardedHost = req.headers.get("x-forwarded-host");
+ const forwardedProto = req.headers.get("x-forwarded-proto");
+ if (forwardedHost) {
+ const proto = forwardedProto || url.protocol.replace(":", "") || "https";
+ return `${proto}://${forwardedHost}`;
+ }
+ return url.origin;
+ } catch {
+ return getSiteUrl();
+ }
+}
+
+/**
+ * Extract a human-readable hostname (e.g. "echo.stillwell.cloud") from any
+ * URL string. Useful for email footers and marketing copy so the displayed
+ * brand domain always matches the link target.
+ */
+export function getSiteHostname(siteUrl: string = getSiteUrl()): string {
+ try {
+ return new URL(siteUrl).hostname;
+ } catch {
+ return siteUrl;
+ }
+}
+
+function stripTrailingSlash(url: string): string {
+ return url.endsWith("/") ? url.slice(0, -1) : url;
+}