Config Environment
Overview
The Remote Environment Configuration system provides a flexible way to manage application configuration in React Native/Expo apps by fetching environment variables from a remote Supabase database at runtime, rather than bundling them in the app binary.
Key Features
- 🔄 Runtime Configuration: Fetch configs from Supabase at app startup
- 🛡️ Security: Keep sensitive API keys out of app bundle
- 📦 Multi-tier Fallback: Remote → Cached → Process.env
- ⚡ Smart Caching: Configurable cache duration to minimize requests
- 🔧 Type Safety: Full TypeScript support with type parsing
- 🎯 Flexible Options: Configure fallback behavior via environment variables
When to Use This Approach
✅ Good For:
- Feature flags
- Public API keys (Maps, Analytics)
- App configuration values
- A/B testing parameters
- Remote feature toggles
- Environment-specific settings
❌ Not Suitable For:
- Payment processor secrets (Stripe secret keys)
- Encryption keys
- Server-to-server tokens
- Database passwords
- OAuth client secrets
Security Note: Any configuration fetched to a mobile app can be intercepted by users with tools like Charles Proxy. For truly sensitive operations, use Supabase Edge Functions as a server-side proxy.
Config Environment Management
This document explains how to use useConfigEnvironment (from hooks/useConfigEnvironment.ts) in the starter kit to manage your app configuration via Supabase, with safe fallbacks.
What useConfigEnvironment Does
- Pulls app config from Supabase (
app_configstable) and normalizes types (STRING, NUMBER, BOOLEAN, JSON, etc.). - Stores the result in global state.
- Provides a robust fallback chain when remote fetch fails:
- 1. Remote Supabase config – highest priority when available.
- 2. Cached/persisted environment – optional, medium priority.
- 3.
process.env– optional, lowest priority.
- Exposes loading, error, and refresh state so you can control UX and re-fetch configs.
Look for the Database section in the video above.
Basic Usage (Recommended Setup)
Use it early in your app, for example in your root layout or a top-level provider:
import { useConfigEnvironment, getConfigEnvironmentOptionsFromEnv } from "@/hooks/useConfigEnvironment";
import { useEnvironment } from "@/state";
export function RootConfigProvider() {
const { isLoading, error } = useConfigEnvironment(
getConfigEnvironmentOptionsFromEnv()
);
const env = useEnvironment();
if (isLoading) {
// Show a minimal loading UI while config loads
return null;
}
if (error) {
// Decide how strict you want to be here
// For many apps, you may still continue with fallback env
return null;
}
// At this point env contains merged config (remote + fallbacks)
const apiBaseUrl = env.EXPO_PUBLIC_API_URL as string | undefined;
return (
// children / navigation / providers
null
);
}Key points:
- You do not get the environment object directly from
useConfigEnvironment; it just orchestrates fetching+storing. - You read the effective environment via
useEnvironment()from@/state. - The hook automatically updates:
environment(inuseApp/useEnvironment)appConfigs(raw config records)lastFetchTimeremoteEnvReadyflag inuseCache
Return Value
useConfigEnvironment(options?: ConfigEnvironmentOptions) returns:
isLoading: boolean–truewhile a fetch is in progress (or on first mount).error: Error | null– last fetch error if any.refresh: () => void– forces a re-fetch ignoring the cache age.
Example refresh usage (e.g. in an admin/debug screen):
const { isLoading, error, refresh } = useConfigEnvironment(
getConfigEnvironmentOptionsFromEnv(),
useProcessEnvOnly: __DEV__ // for local dev without Supabase
);
return (
<Button
label={isLoading ? "Refreshing..." : "Refresh remote config"}
onPress={refresh}
disabled={isLoading}
/>
);Config Options (ConfigEnvironmentOptions)
ConfigEnvironmentOptions
interface ConfigEnvironmentOptions {
/**
* Use cached environment as fallback when remote fetch fails
* @default true
* Priority: MEDIUM (overrides process.env, but not remote)
*/
usePersistentFallback?: boolean;
/**
* Use process.env as fallback when remote fetch fails
* @default true
* Priority: LOWEST
*/
useProcessEnvFallback?: boolean;
/**
* Throw error instead of using fallbacks
* @default false
* Warning: Will crash app if remote fetch fails
*/
throwOnError?: boolean;
/**
* Skip remote fetch and use only process.env
* @default false
* Use case: Development mode or offline testing
*/
useProcessEnvOnly?: boolean;
/**
* Cache duration in milliseconds
* @default 300000 (5 minutes)
* Prevents unnecessary remote fetches
*/
cacheMaxAge?: number;
}useConfigEnvironment accepts an options.
Environment Variable Configuration
# All options can be set via environment variables
# Use cached fallback (true/false/yes/1)
EXPO_PUBLIC_REMOTE_ENV_USE_PERSISTENT_FALLBACK=true
# Use process.env fallback (true/false/yes/1)
EXPO_PUBLIC_REMOTE_ENV_USE_PROCESS_ENV_FALLBACK=true
# Throw on error (true/false/yes/1)
EXPO_PUBLIC_REMOTE_ENV_THROW_ON_ERROR=false
# Cache max age in milliseconds
EXPO_PUBLIC_REMOTE_ENV_CACHE_MAX_AGE=3000001. usePersistentFallback (default: true)
- What it does: If remote fetch fails, the hook can fall back to the previously cached environment from Zustand storage.
- Priority: Medium – overrides
process.env, but is overridden by fresh remote data. - When to keep enabled:
- You want resilience when offline or when Supabase is temporarily unavailable.
- When to disable:
- You explicitly want to ignore any previously cached values on failure.
2. useProcessEnvFallback (default: true)
- What it does: Adds
process.envvariables as the lowest-priority fallback. - Priority: Lowest – overridden by both cached env and remote env.
- Typical usage:
- Keep this
trueso that build-time env (EXPO_PUBLIC_...) is always the last line of defense.
- Keep this
3. throwOnError (default: false)
- What it does: If
true, the hook re-throws the fetch error instead of quietly falling back. - Effect:
- With
false(default), the hook logs the error, setserror, and uses the fallback chain. - With
true, you’ll need an error boundary or explicit try/catch at the call site in server-like contexts.
- With
- Recommendation for starter kit customers:
- Keep this
falseunless you have strict guarantees and custom error handling.
- Keep this
4. useProcessEnvOnly (default: false)
- What it does:
- Skips all remote/Supabase logic.
- Ignores cached environment.
- Immediately sets the environment to
process.envand marksremoteEnvReady = true.
- When to use:
- Local dev without Supabase.
- CI / test runs where remote fetch is undesirable.
- Temporary troubleshooting to rule out remote config issues.
5. cacheMaxAge (default: 5 minutes)
- What it does:
- Controls how long the last successful remote fetch is considered “fresh”.
- If the cached data is younger than
cacheMaxAge, the hook reuses it and skips a new fetch.
- When to reduce:
- You change remote config very frequently and need faster propagation.
- When to increase:
- You want to minimize Supabase reads and are okay with slower config changes.
How Values Are Parsed
Remote data is pulled from the Supabase app_configs table and mapped into AppConfig[]. For each record:
config.key– becomes the environment key.config.value– raw string from Supabase.config.type– one of the supported types (AppConfig["type"]).
parseConfigValue then converts value by type:
NUMBER→Number(value)BOOLEAN→value.toLowerCase() === "true"JSON→JSON.parse(value)(falls back to raw string on parse error)STRING,URL,EMAIL,DATE, default → leaves as string
Implication for customers:
- Ensure your
app_configs.typematches the actual value format. - For complex objects or arrays, store valid JSON and use
type = "JSON".
Example Supabase rows:
| key | value | type |
|---|---|---|
EXPO_PUBLIC_API_URL | https://api.example.com | STRING |
FEATURE_FLAG_NEW_UI | true | BOOLEAN |
RETRY_COUNT | 3 | NUMBER |
APP_FEATURES | {"beta":true,"maxUsers":100} | JSON |
Reading Config Values in Your App
Once useConfigEnvironment has run and completed, read the current environment via useEnvironment:
import { useEnvironment } from "@/state";
export function SomeScreen() {
const env = useEnvironment();
const apiUrl = env.EXPO_PUBLIC_API_URL as string | undefined;
const isNewUIEnabled = env.FEATURE_FLAG_NEW_UI as boolean | undefined;
// It’s your responsibility to handle undefined safely
if (!apiUrl) {
// Decide: show error, fallback, or disable a feature
}
return null;
}Fallback System
Priority Chain
The system uses a three-tier fallback system:
1. Remote Config (Supabase) ← HIGHEST PRIORITY
↓ (if fetch fails)
2. Cached Config (Zustand) ← MEDIUM PRIORITY
↓ (if no cache)
3. Process.env (.env file) ← LOWEST PRIORITYFallback Scenarios
Scenario 1: Successful Remote Fetch
Remote Config: { API_KEY: "remote-123" }
Cached Config: { API_KEY: "cached-456" }
Process.env: { API_KEY: "env-789" }
Result: { API_KEY: "remote-123" } ✅Scenario 2: Remote Fetch Fails (with cache)
Remote Config: ❌ Network error
Cached Config: { API_KEY: "cached-456" }
Process.env: { API_KEY: "env-789" }
Result: { API_KEY: "cached-456" } ⚠️Scenario 3: Remote Fetch Fails (no cache)
Remote Config: ❌ Network error
Cached Config: {} (empty)
Process.env: { API_KEY: "env-789" }
Result: { API_KEY: "env-789" } ⚠️Scenario 4: All Sources Fail
Remote Config: ❌ Network error
Cached Config: {} (empty)
Process.env: {} (empty)
Result: {} ❌ (app may not function correctly)Configuring Fallback Behavior
// Strict mode: Only use remote config
useConfigEnvironment({
usePersistentFallback: false,
useProcessEnvFallback: false,
throwOnError: true, // Crash if remote fetch fails
});
// Flexible mode: Use all fallbacks
useConfigEnvironment({
usePersistentFallback: true,
useProcessEnvFallback: true,
throwOnError: false, // Continue with fallbacks
});
// Development mode: Use only .env file
useConfigEnvironment({
useProcessEnvOnly: true, // Skip remote entirely
});Caching Strategy
Cache Behavior
const cacheMaxAge = 5 * 60 * 1000; // 5 minutes
// First fetch: Loads from remote
useConfigEnvironment({ cacheMaxAge });
// Within 5 minutes: Uses cached data (no network request)
// After 5 minutes: Fetches from remote againCache Invalidation
// Manual refresh (bypasses cache)
const { refresh } = useConfigEnvironment();
refresh(); // Forces immediate remote fetch
// Automatic refresh on app resume
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
refresh(); // Refresh when app comes to foreground
}
});
return () => subscription.remove();
}, [refresh]);Security Best Practices
1. Separate Public and Private Configs
-- Public configs viewable by everyone
CREATE POLICY "public_configs_readable"
ON app_configs FOR SELECT
USING (is_public = true);
-- Authenticated users can view their org configs
CREATE POLICY "org_configs_readable"
ON app_configs FOR SELECT
USING (
auth.uid() IS NOT NULL
AND (
is_public = true
OR organization_id = (
SELECT organization_id
FROM user_organizations
WHERE user_id = auth.uid()
)
)
);
-- Enable RLS
ALTER TABLE app_configs ENABLE ROW LEVEL SECURITY;2. Implement Rate Limiting
-- Add rate limiting to RLS policy
CREATE POLICY "rate_limited_access"
ON app_configs FOR SELECT
USING (
is_public = true
AND (
SELECT COUNT(*)
FROM config_access_logs
WHERE user_id = auth.uid()
AND created_at > NOW() - INTERVAL '1 hour'
) < 100
);