fix(build): make admin tree dynamic and catch Supabase fetch errors at build
Deploy to route.crispygoat.com / deploy (push) Failing after 4m16s

Two errors were aborting the Gitea build:

1. DYNAMIC_SERVER_USAGE on /admin/settings/square-sync (and any admin page):
   getAdminUser() reads cookies() via next/headers. The admin layout tried
   to prerender statically, so the first child page that hit cookies()
   aborted the build. Added 'export const dynamic = "force-dynamic"' to
   src/app/admin/layout.tsx so the whole admin tree opts out of static
   prerender.

2. Prerender ECONNREFUSED on /indian-river-direct/stops and the sitemap:
   getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic
   fetch NEXT_PUBLIC_SUPABASE_URL at build time. The Gitea runner sets the
   Supabase env vars (so the existing env-var guard passes) but the URL
   is unreachable, so fetch throws ECONNREFUSED and the prerender aborts.
   Wrapped each fetch in try/catch returning [] / {success: false} so the
   prerender completes; runtime behavior is unchanged when the fetch
   succeeds.

Also added force-dynamic to the square-sync page itself as belt-and-braces
in case the layout change doesn't propagate.
This commit is contained in:
2026-06-06 19:46:35 +00:00
parent 2f3be5426f
commit 5477b3419f
5 changed files with 92 additions and 41 deletions
+22 -15
View File
@@ -279,22 +279,29 @@ export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBran
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
}
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
}
);
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just falls back to its
// default brand name and revalidates from a real request later.
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
}
);
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
const data = await response.json();
return {
success: true,
settings: data,
wholesaleEnabled: data?.wholesale_enabled,
};
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
const data = await response.json();
return {
success: true,
settings: data,
wholesaleEnabled: data?.wholesale_enabled,
};
} catch {
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
}
}
export async function saveBrandSettings(params: {
+39 -26
View File
@@ -116,19 +116,25 @@ export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
// an empty list lets the sitemap render with only the static URLs.
if (!supabaseUrl || !supabaseKey) return [];
// Get all active stops with their brand slug
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
);
// Get all active stops with their brand slug. Wrapped in try/catch so a
// build-time outage (ECONNREFUSED) doesn't crash the prerender — the
// sitemap just renders without stop URLs.
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
);
if (!response.ok) return [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? stops : [];
const stops = await response.json();
return Array.isArray(stops) ? stops : [];
} catch {
return [];
}
}
/**
@@ -165,21 +171,28 @@ export async function getPublicStopsForBrand(
// fetch path is unchanged.
if (!supabaseUrl || !supabaseKey) return [];
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: {
revalidate: 300,
tags: ["stops", `brand:${brandSlug}:stops`],
},
}
);
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just renders with no stops
// and revalidates from a real request once the cache is warm.
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: {
revalidate: 300,
tags: ["stops", `brand:${brandSlug}:stops`],
},
}
);
if (!response.ok) return [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
} catch {
return [];
}
}
+5
View File
@@ -8,6 +8,11 @@ import "@/styles/admin-design-system.css";
import { ToastProvider } from "@/components/admin/Toast";
import { ToastContainer } from "@/components/admin/ToastContainer";
// Admin layout calls getAdminUser() which reads cookies(). Without this,
// Next.js tries to prerender the entire /admin/* tree statically and the
// first page that hits cookies() aborts the build with DYNAMIC_SERVER_USAGE.
export const dynamic = "force-dynamic";
// Toast provider wrapper component
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
return (
@@ -4,6 +4,10 @@ import { getPaymentSettings } from "@/actions/payments";
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
// Uses cookies() via getAdminUser — must be dynamic to avoid the
// "couldn't be rendered statically" build error.
export const dynamic = "force-dynamic";
export default async function SquareSyncSettingsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");