This tutorial will guide you through implementing a centralized API endpoint configuration system in your Next.js + Rails application. By the end of this tutorial, you'll have a robust, maintainable system that eliminates brittle hardcoded API paths.
- Basic knowledge of Next.js and React
- Understanding of Axios for HTTP requests
- Familiarity with TypeScript (optional but recommended)
- How to set up a centralized API configuration system
- How to eliminate hardcoded API paths
- How to add new endpoints following best practices
- How to migrate existing hardcoded paths
- How to maintain and debug the system
Before implementing the solution, let's understand why hardcoded API paths are problematic:
// β BAD: Hardcoded paths scattered throughout your codebase
const response = await api.get('/api/v1/users');
const response = await api.post('/api/v1/assets', data);
const response = await api.get('/api/v1/drop_zones/123');
Problems:
- Brittle: Change the API structure? Update every file manually
- Error-prone: Easy to make typos in paths
- Inconsistent: Different developers might use different path formats
- Hard to maintain: No single source of truth
- Environment issues: Hard to switch between dev/staging/prod URLs
// β
GOOD: Centralized configuration
const response = await api.get(API_ENDPOINTS.USERS.LIST);
const response = await api.post(API_ENDPOINTS.ASSETS.CREATE, data);
const response = await api.get(API_ENDPOINTS.DROP_ZONES.SHOW('123'));
Benefits:
- Single source of truth: All paths defined in one place
- Type safety: TypeScript autocomplete and validation
- Easy refactoring: Change paths in one location
- Consistent: All developers use the same pattern
- Maintainable: Easy to update and debug
Create src/lib/config.ts
(or apps/web/src/lib/config.ts
in a monorepo):
// src/lib/config.ts
// Environment detection function
function detectApiUrl(): string {
if (typeof window === 'undefined') {
// Server-side rendering
return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8081';
}
// Client-side
if (process.env.NODE_ENV === 'production') {
return process.env.NEXT_PUBLIC_API_URL || 'https://your-api-domain.com';
}
return 'http://localhost:8081';
}
// Main API configuration
export const API_CONFIG = {
BASE_URL: detectApiUrl(),
VERSION: 'v1',
PREFIX: '/api/v1'
} as const;
// All API endpoints defined in one place
export const API_ENDPOINTS = {
// Authentication endpoints
AUTH: {
SIGN_IN: '/users/sign_in',
SIGN_UP: '/users',
ME: '/users/me',
SIGN_OUT: '/users/sign_out'
},
// User management
USERS: {
LIST: '/users',
SHOW: (id: string) => `/users/${id}`,
UPDATE: (id: string) => `/users/${id}`,
DELETE: (id: string) => `/users/${id}`
},
// Asset management
ASSETS: {
LIST: '/assets',
CREATE: '/assets',
SHOW: (id: string) => `/assets/${id}`,
UPDATE: (id: string) => `/assets/${id}`,
DELETE: (id: string) => `/assets/${id}`,
SEARCH: '/assets/search',
ADD_TAG: (id: string) => `/assets/${id}/add_tag`,
REMOVE_TAG: (id: string) => `/assets/${id}/remove_tag`
},
// Tag management
TAGS: {
LIST: '/tags',
CREATE: '/tags',
SHOW: (id: string) => `/tags/${id}`,
UPDATE: (id: string) => `/tags/${id}`,
DELETE: (id: string) => `/tags/${id}`
},
// Drop Zones (example of a complex feature)
DROP_ZONES: {
LIST: '/drop_zones',
CREATE: '/drop_zones',
SHOW: (id: string) => `/drop_zones/${id}`,
UPDATE: (id: string) => `/drop_zones/${id}`,
DELETE: (id: string) => `/drop_zones/${id}`,
SUBSCRIBE: (id: string) => `/drop_zones/${id}/subscribe`,
UNSUBSCRIBE: (id: string) => `/drop_zones/${id}/unsubscribe`,
FILES: (id: string) => `/drop_zones/${id}/files`,
UPLOAD: (id: string) => `/drop_zones/${id}/upload`
}
} as const;
Create src/lib/api.ts
:
// src/lib/api.ts
import axios from 'axios';
import { API_CONFIG } from './config';
// Create axios instance with base configuration
export const api = axios.create({
baseURL: `${API_CONFIG.BASE_URL}${API_CONFIG.PREFIX}`, // 'http://localhost:8081/api/v1'
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
withCredentials: true,
});
// Request interceptor for authentication
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized access
localStorage.removeItem('auth_token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
Here's how to use the centralized configuration in your React components:
// src/components/UserList.tsx
import React, { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { API_ENDPOINTS } from '@/lib/config';
interface User {
id: string;
email: string;
name: string;
}
const UserList: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
setLoading(true);
// β
Using centralized configuration
const response = await api.get(API_ENDPOINTS.USERS.LIST);
setUsers(response.data.data || []);
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
setLoading(false);
}
};
const createUser = async (userData: Partial<User>) => {
try {
// β
Using centralized configuration
const response = await api.post(API_ENDPOINTS.USERS.CREATE, { user: userData });
setUsers(prev => [response.data.data, ...prev]);
} catch (error) {
console.error('Failed to create user:', error);
}
};
const updateUser = async (id: string, userData: Partial<User>) => {
try {
// β
Using centralized configuration with dynamic ID
const response = await api.patch(API_ENDPOINTS.USERS.UPDATE(id), { user: userData });
setUsers(prev => prev.map(user => user.id === id ? response.data.data : user));
} catch (error) {
console.error('Failed to update user:', error);
}
};
const deleteUser = async (id: string) => {
try {
// β
Using centralized configuration with dynamic ID
await api.delete(API_ENDPOINTS.USERS.DELETE(id));
setUsers(prev => prev.filter(user => user.id !== id));
} catch (error) {
console.error('Failed to delete user:', error);
}
};
if (loading) return <div>Loading...</div>;
return (
<div>
<h2>Users</h2>
{users.map(user => (
<div key={user.id}>
{user.name} ({user.email})
<button onClick={() => updateUser(user.id, { name: 'Updated Name' })}>
Update
</button>
<button onClick={() => deleteUser(user.id)}>
Delete
</button>
</div>
))}
</div>
);
};
export default UserList;
For endpoints that require parameters, use the function syntax:
// src/components/AssetDetail.tsx
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '@/lib/api';
import { API_ENDPOINTS } from '@/lib/config';
const AssetDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [asset, setAsset] = useState(null);
const [tags, setTags] = useState([]);
useEffect(() => {
if (id) {
fetchAsset();
fetchAssetTags();
}
}, [id]);
const fetchAsset = async () => {
try {
// β
Dynamic endpoint with ID parameter
const response = await api.get(API_ENDPOINTS.ASSETS.SHOW(id!));
setAsset(response.data.data);
} catch (error) {
console.error('Failed to fetch asset:', error);
}
};
const addTag = async (tagId: string) => {
try {
// β
Dynamic endpoint with ID parameter
await api.post(API_ENDPOINTS.ASSETS.ADD_TAG(id!), { tag_id: tagId });
fetchAssetTags(); // Refresh tags
} catch (error) {
console.error('Failed to add tag:', error);
}
};
const removeTag = async (tagId: string) => {
try {
// β
Dynamic endpoint with ID parameter
await api.delete(API_ENDPOINTS.ASSETS.REMOVE_TAG(id!), {
data: { tag_id: tagId }
});
fetchAssetTags(); // Refresh tags
} catch (error) {
console.error('Failed to remove tag:', error);
}
};
// ... rest of component
};
Let's say you want to add a "Notifications" feature. Here's how to do it:
// src/lib/config.ts
export const API_ENDPOINTS = {
// ... existing endpoints
// New Notifications feature
NOTIFICATIONS: {
LIST: '/notifications',
CREATE: '/notifications',
SHOW: (id: string) => `/notifications/${id}`,
UPDATE: (id: string) => `/notifications/${id}`,
DELETE: (id: string) => `/notifications/${id}`,
MARK_READ: (id: string) => `/notifications/${id}/mark_read`,
MARK_ALL_READ: '/notifications/mark_all_read',
UNREAD_COUNT: '/notifications/unread_count'
}
} as const;
// src/components/Notifications.tsx
import React, { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { API_ENDPOINTS } from '@/lib/config';
interface Notification {
id: string;
title: string;
message: string;
read: boolean;
created_at: string;
}
const Notifications: React.FC = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
fetchNotifications();
fetchUnreadCount();
}, []);
const fetchNotifications = async () => {
try {
const response = await api.get(API_ENDPOINTS.NOTIFICATIONS.LIST);
setNotifications(response.data.data || []);
} catch (error) {
console.error('Failed to fetch notifications:', error);
}
};
const markAsRead = async (id: string) => {
try {
await api.patch(API_ENDPOINTS.NOTIFICATIONS.MARK_READ(id));
setNotifications(prev =>
prev.map(notification =>
notification.id === id
? { ...notification, read: true }
: notification
)
);
setUnreadCount(prev => Math.max(0, prev - 1));
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
const markAllAsRead = async () => {
try {
await api.patch(API_ENDPOINTS.NOTIFICATIONS.MARK_ALL_READ);
setNotifications(prev =>
prev.map(notification => ({ ...notification, read: true }))
);
setUnreadCount(0);
} catch (error) {
console.error('Failed to mark all notifications as read:', error);
}
};
const fetchUnreadCount = async () => {
try {
const response = await api.get(API_ENDPOINTS.NOTIFICATIONS.UNREAD_COUNT);
setUnreadCount(response.data.count || 0);
} catch (error) {
console.error('Failed to fetch unread count:', error);
}
};
return (
<div>
<h2>Notifications ({unreadCount} unread)</h2>
<button onClick={markAllAsRead}>Mark All as Read</button>
{notifications.map(notification => (
<div
key={notification.id}
style={{
opacity: notification.read ? 0.6 : 1,
fontWeight: notification.read ? 'normal' : 'bold'
}}
>
<h3>{notification.title}</h3>
<p>{notification.message}</p>
<small>{new Date(notification.created_at).toLocaleString()}</small>
{!notification.read && (
<button onClick={() => markAsRead(notification.id)}>
Mark as Read
</button>
)}
</div>
))}
</div>
);
};
export default Notifications;
Use these commands to find hardcoded API paths in your codebase:
# Find hardcoded /api/v1/ paths
grep -r "/api/v1/" src/
# Find hardcoded /v1/ paths
grep -r "/v1/" src/
# Find any hardcoded endpoint paths
grep -r "api\.get.*'/.*'" src/
grep -r "api\.post.*'/.*'" src/
grep -r "api\.patch.*'/.*'" src/
grep -r "api\.put.*'/.*'" src/
grep -r "api\.delete.*'/.*'" src/
// src/components/OldComponent.tsx
import { api } from '@/lib/api';
const OldComponent = () => {
const fetchData = async () => {
// β Hardcoded paths
const users = await api.get('/api/v1/users');
const assets = await api.get('/api/v1/assets');
const tags = await api.get('/api/v1/tags');
};
const createAsset = async (data) => {
// β Hardcoded path
return await api.post('/api/v1/assets', data);
};
const updateAsset = async (id, data) => {
// β Hardcoded path with string interpolation
return await api.patch(`/api/v1/assets/${id}`, data);
};
};
// src/components/NewComponent.tsx
import { api } from '@/lib/api';
import { API_ENDPOINTS } from '@/lib/config'; // β
Import configuration
const NewComponent = () => {
const fetchData = async () => {
// β
Using centralized configuration
const users = await api.get(API_ENDPOINTS.USERS.LIST);
const assets = await api.get(API_ENDPOINTS.ASSETS.LIST);
const tags = await api.get(API_ENDPOINTS.TAGS.LIST);
};
const createAsset = async (data) => {
// β
Using centralized configuration
return await api.post(API_ENDPOINTS.ASSETS.CREATE, data);
};
const updateAsset = async (id, data) => {
// β
Using centralized configuration with dynamic ID
return await api.patch(API_ENDPOINTS.ASSETS.UPDATE(id), data);
};
};
- Add missing endpoints to
API_ENDPOINTS
inconfig.ts
- Import
API_ENDPOINTS
in components that need it - Replace hardcoded paths with
API_ENDPOINTS
references - Test all API calls to ensure they work correctly
- Remove any unused imports
- Run the search commands to verify no hardcoded paths remain
export const API_ENDPOINTS = {
// Use UPPER_CASE for feature names
USERS: {
// Use UPPER_CASE for action names
LIST: '/users', // GET /users
CREATE: '/users', // POST /users
SHOW: (id) => `/users/${id}`, // GET /users/:id
UPDATE: (id) => `/users/${id}`, // PATCH/PUT /users/:id
DELETE: (id) => `/users/${id}`, // DELETE /users/:id
// Use descriptive names for custom actions
CHANGE_PASSWORD: (id) => `/users/${id}/change_password`,
UPLOAD_AVATAR: (id) => `/users/${id}/upload_avatar`,
DEACTIVATE: (id) => `/users/${id}/deactivate`
}
} as const;
export const API_ENDPOINTS = {
// Group by feature/resource
AUTH: {
SIGN_IN: '/users/sign_in',
SIGN_UP: '/users',
SIGN_OUT: '/users/sign_out',
REFRESH_TOKEN: '/users/refresh_token'
},
USER_MANAGEMENT: {
PROFILE: '/users/profile',
PREFERENCES: '/users/preferences',
NOTIFICATIONS: '/users/notifications'
},
ASSET_MANAGEMENT: {
UPLOAD: '/assets/upload',
BULK_DELETE: '/assets/bulk_delete',
EXPORT: '/assets/export'
}
} as const;
// src/hooks/useApi.ts
import { useState } from 'react';
import { api } from '@/lib/api';
import { API_ENDPOINTS } from '@/lib/config';
export const useApi = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const makeRequest = async <T>(
requestFn: () => Promise<T>
): Promise<T | null> => {
try {
setLoading(true);
setError(null);
const result = await requestFn();
return result;
} catch (err: any) {
const errorMessage = err.response?.data?.message || 'An error occurred';
setError(errorMessage);
return null;
} finally {
setLoading(false);
}
};
const fetchUsers = () =>
makeRequest(() => api.get(API_ENDPOINTS.USERS.LIST));
const createUser = (data: any) =>
makeRequest(() => api.post(API_ENDPOINTS.USERS.CREATE, data));
return {
loading,
error,
fetchUsers,
createUser
};
};
// src/__tests__/api.test.ts
import { api } from '@/lib/api';
import { API_ENDPOINTS } from '@/lib/config';
// Mock axios
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('API Configuration', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should use correct base URL', () => {
expect(api.defaults.baseURL).toBe('http://localhost:8081/api/v1');
});
it('should construct correct endpoint URLs', () => {
expect(API_ENDPOINTS.USERS.LIST).toBe('/users');
expect(API_ENDPOINTS.USERS.SHOW('123')).toBe('/users/123');
expect(API_ENDPOINTS.ASSETS.ADD_TAG('456')).toBe('/assets/456/add_tag');
});
it('should make correct API calls', async () => {
mockedAxios.get.mockResolvedValue({ data: { users: [] } });
await api.get(API_ENDPOINTS.USERS.LIST);
expect(mockedAxios.get).toHaveBeenCalledWith('/users');
});
});
- Open browser DevTools β Network tab
- Look for requests to the correct base URL (
http://localhost:8081/api/v1/...
) - Verify endpoint paths match your configuration
// Add temporary logging to debug API calls
const fetchUsers = async () => {
console.log('π Fetching users from:', API_ENDPOINTS.USERS.LIST);
console.log('π Full URL will be:', `${api.defaults.baseURL}${API_ENDPOINTS.USERS.LIST}`);
const response = await api.get(API_ENDPOINTS.USERS.LIST);
console.log('β
Response:', response.data);
return response.data;
};
# .env.local
NEXT_PUBLIC_API_URL=http://localhost:8081
# .env.production
NEXT_PUBLIC_API_URL=https://your-api-domain.com
// src/lib/config.ts
const getApiConfig = () => {
const environment = process.env.NODE_ENV;
switch (environment) {
case 'development':
return {
BASE_URL: 'http://localhost:8081',
TIMEOUT: 10000,
RETRY_ATTEMPTS: 3
};
case 'staging':
return {
BASE_URL: 'https://staging-api.yourdomain.com',
TIMEOUT: 15000,
RETRY_ATTEMPTS: 2
};
case 'production':
return {
BASE_URL: 'https://api.yourdomain.com',
TIMEOUT: 20000,
RETRY_ATTEMPTS: 1
};
default:
return {
BASE_URL: 'http://localhost:8081',
TIMEOUT: 10000,
RETRY_ATTEMPTS: 3
};
}
};
export const API_CONFIG = {
...getApiConfig(),
VERSION: 'v1',
PREFIX: '/api/v1'
} as const;
// src/lib/types.ts
export interface ApiEndpoint {
path: string;
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
requiresAuth?: boolean;
}
export interface ApiEndpoints {
[key: string]: ApiEndpoint | ((...args: any[]) => ApiEndpoint);
}
// src/lib/config.ts
export const API_ENDPOINTS: ApiEndpoints = {
USERS: {
LIST: { path: '/users', method: 'GET' },
CREATE: { path: '/users', method: 'POST', requiresAuth: true },
SHOW: (id: string) => ({ path: `/users/${id}`, method: 'GET' }),
UPDATE: (id: string) => ({ path: `/users/${id}`, method: 'PATCH', requiresAuth: true }),
DELETE: (id: string) => ({ path: `/users/${id}`, method: 'DELETE', requiresAuth: true })
}
} as const;
// src/lib/api.ts
import axios from 'axios';
import { API_CONFIG } from './config';
export const api = axios.create({
baseURL: `${API_CONFIG.BASE_URL}${API_CONFIG.PREFIX}`,
timeout: API_CONFIG.TIMEOUT,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
// Request interceptor
api.interceptors.request.use(
(config) => {
// Add timestamp to prevent caching
config.params = {
...config.params,
_t: Date.now()
};
// Add authentication token
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Log request in development
if (process.env.NODE_ENV === 'development') {
console.log(`π ${config.method?.toUpperCase()} ${config.url}`, config.data);
}
return config;
},
(error) => {
console.error('Request error:', error);
return Promise.reject(error);
}
);
// Response interceptor
api.interceptors.response.use(
(response) => {
// Log response in development
if (process.env.NODE_ENV === 'development') {
console.log(`β
${response.config.method?.toUpperCase()} ${response.config.url}`, response.data);
}
return response;
},
(error) => {
// Log error in development
if (process.env.NODE_ENV === 'development') {
console.error(`β ${error.config?.method?.toUpperCase()} ${error.config?.url}`, error.response?.data);
}
// Handle specific error cases
if (error.response?.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/login';
}
if (error.response?.status === 403) {
// Handle forbidden access
console.error('Access forbidden');
}
if (error.response?.status >= 500) {
// Handle server errors
console.error('Server error occurred');
}
return Promise.reject(error);
}
);
- Monthly Review: Check for any new hardcoded paths that might have been introduced
- Endpoint Updates: When backend API changes, update only the configuration file
- Performance Monitoring: Monitor API response times and error rates
- Documentation Updates: Keep endpoint documentation current
When adding a new feature:
- Plan the endpoints you'll need
- Add them to
API_ENDPOINTS
inconfig.ts
- Create components using the centralized configuration
- Test thoroughly to ensure all endpoints work
- Update documentation if needed
Solution: Check that the endpoint is defined in API_ENDPOINTS
and the path is correct
Solution: Verify the BASE_URL
is correct for your environment
Solution: Check that the token is being added correctly in the request interceptor
Solution: Ensure you're importing API_ENDPOINTS
correctly and using the right types
By following this tutorial, you've implemented a robust, maintainable API endpoint configuration system that:
- β Eliminates brittle hardcoded paths
- β Provides a single source of truth for all API endpoints
- β Makes it easy to add new endpoints
- β Simplifies maintenance and updates
- β Improves code consistency across your team
- β Reduces bugs and typos in API calls
- Apply this system to your existing codebase
- Train your team on the new patterns
- Set up monitoring to track API performance
- Create automated tests for your API configuration
- Document any custom patterns specific to your application
Remember: The key to success is consistency. Once you establish these patterns, stick to them throughout your application. Your future self (and your teammates) will thank you!