diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index c349b1c..89b3f2d 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -38,7 +38,7 @@ COPY src ./src
COPY messages ./messages
# Build arguments
-ARG NEXT_PUBLIC_API_URL=http://localhost:8080
+ARG NEXT_PUBLIC_API_URL=http://localhost:8521
ARG NEXT_PUBLIC_BACKOFFICE_URL=http://localhost:3001
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_BACKOFFICE_URL=$NEXT_PUBLIC_BACKOFFICE_URL
diff --git a/frontend/src/app/api/config/route.ts b/frontend/src/app/api/config/route.ts
new file mode 100644
index 0000000..6b447f3
--- /dev/null
+++ b/frontend/src/app/api/config/route.ts
@@ -0,0 +1,25 @@
+import { NextResponse } from 'next/server';
+
+/**
+ * Runtime Configuration API
+ *
+ * This endpoint returns environment variables that can be read at runtime,
+ * allowing the application to use different configurations without rebuilding.
+ *
+ * Usage: Fetch /api/config on app initialization and use the returned values.
+ */
+export async function GET() {
+ const config = {
+ apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8521',
+ backofficeUrl: process.env.NEXT_PUBLIC_BACKOFFICE_URL || 'http://localhost:3001',
+ seederApiUrl: process.env.NEXT_PUBLIC_SEEDER_API_URL || 'http://localhost:3002',
+ scraperApiUrl: process.env.NEXT_PUBLIC_SCRAPER_API_URL || 'http://localhost:3003',
+ };
+
+ return NextResponse.json(config, {
+ headers: {
+ // Cache for 5 minutes to avoid too many requests
+ 'Cache-Control': 'public, max-age=300, s-maxage=300',
+ },
+ });
+}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index db06dff..7ef37c4 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -6,6 +6,7 @@ import { Analytics } from "@vercel/analytics/next"
import { Toaster } from "sonner"
import { NotificationProvider } from "@/contexts/notification-context"
import { ThemeProvider } from "@/contexts/ThemeContext"
+import { ConfigProvider } from "@/contexts/ConfigContext"
import { I18nProvider } from "@/lib/i18n"
import "./globals.css"
import { Suspense } from "react"
@@ -33,20 +34,22 @@ export default function RootLayout({
return (
-
-
-
- }>{children}
-
-
-
-
+
+
+
+
+ }>{children}
+
+
+
+
+
{process.env.NODE_ENV === "production" && shouldLoadAnalytics && }
diff --git a/frontend/src/contexts/ConfigContext.tsx b/frontend/src/contexts/ConfigContext.tsx
new file mode 100644
index 0000000..a64791b
--- /dev/null
+++ b/frontend/src/contexts/ConfigContext.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
+
+export interface RuntimeConfig {
+ apiUrl: string;
+ backofficeUrl: string;
+ seederApiUrl: string;
+ scraperApiUrl: string;
+}
+
+const defaultConfig: RuntimeConfig = {
+ apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8521',
+ backofficeUrl: process.env.NEXT_PUBLIC_BACKOFFICE_URL || 'http://localhost:3001',
+ seederApiUrl: process.env.NEXT_PUBLIC_SEEDER_API_URL || 'http://localhost:3002',
+ scraperApiUrl: process.env.NEXT_PUBLIC_SCRAPER_API_URL || 'http://localhost:3003',
+};
+
+interface ConfigContextType {
+ config: RuntimeConfig;
+ isLoading: boolean;
+}
+
+const ConfigContext = createContext({
+ config: defaultConfig,
+ isLoading: true,
+});
+
+export function ConfigProvider({ children }: { children: ReactNode }) {
+ const [config, setConfig] = useState(defaultConfig);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ async function fetchConfig() {
+ try {
+ const response = await fetch('/api/config');
+ if (response.ok) {
+ const data = await response.json();
+ setConfig(data);
+ }
+ } catch (error) {
+ console.warn('[Config] Failed to fetch runtime config, using defaults:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ fetchConfig();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useConfig(): RuntimeConfig {
+ const { config } = useContext(ConfigContext);
+ return config;
+}
+
+export function useConfigContext(): ConfigContextType {
+ return useContext(ConfigContext);
+}
+
+// Helper function for non-React contexts (like lib files)
+// This returns a promise that resolves to the config
+let cachedConfig: RuntimeConfig | null = null;
+
+export async function getConfig(): Promise {
+ if (cachedConfig) return cachedConfig;
+
+ try {
+ const response = await fetch('/api/config');
+ if (response.ok) {
+ cachedConfig = await response.json();
+ return cachedConfig!;
+ }
+ } catch {
+ console.warn('[Config] Failed to fetch config, using defaults');
+ }
+
+ return defaultConfig;
+}
+
+// Sync getter for when you need immediate access (uses cached or default)
+export function getConfigSync(): RuntimeConfig {
+ return cachedConfig || defaultConfig;
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index f38c87f..1a01b6b 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,9 +1,9 @@
import { toast } from "sonner";
import { Job } from "./types";
+import { getApiUrl, getBackofficeUrl } from "./config";
-// API Base URL - endpoints already include /api/v1 prefix
-// NEXT_PUBLIC_API_URL should be just the domain, e.g. https://api.gohorsejobs.com
-const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
+// API Base URL - now uses runtime config
+// Fetched from /api/config at runtime, falls back to build-time env or defaults
/**
* Helper to log CRUD actions for the 'Activity Log' or console
@@ -24,7 +24,7 @@ async function apiRequest(endpoint: string, options: RequestInit = {}): Promi
...options.headers,
};
- const response = await fetch(`${API_BASE_URL}${endpoint}`, {
+ const response = await fetch(`${getApiUrl()}${endpoint}`, {
...options,
headers,
credentials: "include", // Enable cookie sharing
@@ -552,7 +552,7 @@ export const profileApi = {
// For now, assuming saving the key is what's requested ("salvando as chaves").
// We use the generic updateProfile method.
const token = localStorage.getItem("token");
- const res = await fetch(`${API_BASE_URL}/api/v1/users/me/profile`, {
+ const res = await fetch(`${getApiUrl()}/api/v1/users/me/profile`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
@@ -569,7 +569,7 @@ export const profileApi = {
// =============================================================================
// BACKOFFICE API (Stripe, Admin Dashboard, etc.)
// =============================================================================
-const BACKOFFICE_URL = process.env.NEXT_PUBLIC_BACKOFFICE_URL || "";
+// Backoffice URL - now uses runtime config
async function backofficeRequest(endpoint: string, options: RequestInit = {}): Promise {
const token = localStorage.getItem("token");
@@ -579,7 +579,7 @@ async function backofficeRequest(endpoint: string, options: RequestInit = {})
...options.headers,
};
- const response = await fetch(`${BACKOFFICE_URL}${endpoint}`, {
+ const response = await fetch(`${getBackofficeUrl()}${endpoint}`, {
...options,
headers,
});
diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts
index 7293b9d..a5ce716 100644
--- a/frontend/src/lib/auth.ts
+++ b/frontend/src/lib/auth.ts
@@ -1,9 +1,10 @@
import { User } from "./types";
+import { getApiUrl } from "./config";
export type { User };
const AUTH_KEY = "job-portal-auth";
-const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521";
-const API_URL = `${BASE_URL}/api/v1`;
+// API URL now uses runtime config
+const getApiV1Url = () => `${getApiUrl()}/api/v1`;
interface LoginResponse {
token: string;
@@ -24,7 +25,7 @@ export async function login(
): Promise {
try {
console.log("%c[AUTH] Attempting login...", "color: #3b82f6; font-weight: bold", { email });
- const res = await fetch(`${API_URL}/auth/login`, {
+ const res = await fetch(`${getApiV1Url()}/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -123,7 +124,7 @@ export interface RegisterCandidateData {
export async function registerCandidate(data: RegisterCandidateData): Promise {
console.log('[registerCandidate] Sending request:', { ...data, password: '***' });
- const res = await fetch(`${API_URL}/auth/register`, {
+ const res = await fetch(`${getApiV1Url()}/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -171,7 +172,7 @@ export async function registerCompany(data: RegisterCompanyData): Promise
admin_email: data.email,
};
- const res = await fetch(`${API_URL}/companies`, {
+ const res = await fetch(`${getApiV1Url()}/companies`, {
method: "POST",
headers: {
"Content-Type": "application/json",
diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts
new file mode 100644
index 0000000..ad0c645
--- /dev/null
+++ b/frontend/src/lib/config.ts
@@ -0,0 +1,128 @@
+/**
+ * Runtime Configuration Service
+ *
+ * This service fetches and caches configuration from the /api/config endpoint,
+ * allowing the application to use runtime environment variables instead of
+ * build-time values.
+ *
+ * Usage:
+ * import { getApiUrl, getBackofficeUrl, initConfig } from '@/lib/config';
+ *
+ * // Initialize once at app startup (optional, auto-initializes on first use)
+ * await initConfig();
+ *
+ * // Get URLs (returns cached values or defaults)
+ * const apiUrl = getApiUrl();
+ */
+
+export interface RuntimeConfig {
+ apiUrl: string;
+ backofficeUrl: string;
+ seederApiUrl: string;
+ scraperApiUrl: string;
+}
+
+// Default values (fallback to build-time env or hardcoded defaults)
+const defaultConfig: RuntimeConfig = {
+ apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8521',
+ backofficeUrl: process.env.NEXT_PUBLIC_BACKOFFICE_URL || 'http://localhost:3001',
+ seederApiUrl: process.env.NEXT_PUBLIC_SEEDER_API_URL || 'http://localhost:3002',
+ scraperApiUrl: process.env.NEXT_PUBLIC_SCRAPER_API_URL || 'http://localhost:3003',
+};
+
+let cachedConfig: RuntimeConfig | null = null;
+let isInitialized = false;
+let initPromise: Promise | null = null;
+
+/**
+ * Initialize the config from the server.
+ * Safe to call multiple times - only fetches once.
+ */
+export async function initConfig(): Promise {
+ // If already initialized, return cached config
+ if (isInitialized && cachedConfig) {
+ return cachedConfig;
+ }
+
+ // If already initializing, wait for that promise
+ if (initPromise) {
+ return initPromise;
+ }
+
+ // Start initialization
+ initPromise = (async () => {
+ try {
+ // Only fetch if we're in the browser
+ if (typeof window !== 'undefined') {
+ const response = await fetch('/api/config');
+ if (response.ok) {
+ cachedConfig = await response.json();
+ console.log('[Config] Loaded runtime config:', cachedConfig);
+ } else {
+ console.warn('[Config] Failed to fetch config, using defaults');
+ cachedConfig = defaultConfig;
+ }
+ } else {
+ // Server-side: use defaults
+ cachedConfig = defaultConfig;
+ }
+ } catch (error) {
+ console.warn('[Config] Error fetching config, using defaults:', error);
+ cachedConfig = defaultConfig;
+ }
+
+ isInitialized = true;
+ return cachedConfig!;
+ })();
+
+ return initPromise;
+}
+
+/**
+ * Get the full config object.
+ * Returns cached config if initialized, otherwise defaults.
+ */
+export function getConfig(): RuntimeConfig {
+ // Trigger async init in background if not initialized
+ if (!isInitialized && typeof window !== 'undefined') {
+ initConfig();
+ }
+ return cachedConfig || defaultConfig;
+}
+
+/**
+ * Get the API base URL.
+ * @returns The API URL (e.g., "https://api.gohorsejobs.com")
+ */
+export function getApiUrl(): string {
+ return getConfig().apiUrl;
+}
+
+/**
+ * Get the full API URL with /api/v1 suffix.
+ * @returns The full API URL (e.g., "https://api.gohorsejobs.com/api/v1")
+ */
+export function getApiV1Url(): string {
+ return `${getConfig().apiUrl}/api/v1`;
+}
+
+/**
+ * Get the Backoffice URL.
+ */
+export function getBackofficeUrl(): string {
+ return getConfig().backofficeUrl;
+}
+
+/**
+ * Get the Seeder API URL.
+ */
+export function getSeederApiUrl(): string {
+ return getConfig().seederApiUrl;
+}
+
+/**
+ * Get the Scraper API URL.
+ */
+export function getScraperApiUrl(): string {
+ return getConfig().scraperApiUrl;
+}
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts
index dd8b607..f21faf9 100644
--- a/frontend/src/lib/storage.ts
+++ b/frontend/src/lib/storage.ts
@@ -2,7 +2,9 @@
* Storage service for S3 file uploads via pre-signed URLs
*/
-const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521/api/v1";
+import { getApiV1Url } from "./config";
+
+// API_URL getter - uses runtime config
interface UploadUrlResponse {
uploadUrl: string;
@@ -30,7 +32,7 @@ export async function getUploadUrl(
throw new Error('Not authenticated');
}
- const response = await fetch(`${API_URL}/storage/upload-url`, {
+ const response = await fetch(`${getApiV1Url()}/storage/upload-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -81,7 +83,7 @@ export async function getDownloadUrl(key: string): Promise
throw new Error('Not authenticated');
}
- const response = await fetch(`${API_URL}/storage/download-url`, {
+ const response = await fetch(`${getApiV1Url()}/storage/download-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -129,7 +131,7 @@ export async function deleteFile(key: string): Promise {
throw new Error('Not authenticated');
}
- const response = await fetch(`${API_URL}/storage/files?key=${encodeURIComponent(key)}`, {
+ const response = await fetch(`${getApiV1Url()}/storage/files?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,