Frontend Application Architecture in React (No FSD Needed)
Hi everyone, I’m Pavel Rozhkov, lead frontend developer at Doubletapp. Our company specializes in custom development, and in our work with React projects, we rely heavily on our architectural guidelines, which we’re constantly refining. This set of conventions defines how project code is organized.
Our guideline helps us:
- Easily swap team members between projects. New developers can step in or reinforce teams without a lengthy onboarding process.
- Streamline development time. Many questions like “Where should this go?” or “How should this be structured?” are resolved in advance.
- Maintain older projects since they follow the same principles.
- Improve code quality by focusing on important tasks instead of rethinking basic organizational questions.
- Onboard new team members with clear documentation.
Why Not Just Use FSD?
In conversations with the community, many describe their guidelines as a hybrid of Feature-Sliced Design (FSD) and custom adaptations that suit their specific needs. That’s perfectly valid since architecture is a tool meant to solve business problems, and those vary.
Additionally, FSD has a steep learning curve. One challenge is its paradigm shift: FSD organizes projects primarily around Entities, which might not even include UI elements. Instead, we propose a more straightforward approach — building apps around Interfaces in a way that feels intuitive.
Project Template and Architecture
We’ve prepared a public repository as a template for creating apps with our methodology. It includes a fully configured project setup and the latest stack we use. The template will remain up to date, so feel free to use it for your projects.
Core Application Code Structure (primarily /src)
• Api
• App
• Assets
• Components
◦ Pages
◦ Wrapper
◦ Layouts
◦ Widgets
◦ Dummies
◦ UI
• Constants
• Hooks
• Model
• Stores
• Styles
• Utils
We’ll dive deeper into each category below.
/Components
One of the key questions in React architecture is how to divide the interface into components. Here’s our approach:
We use 6 types of components:
• Pages
• Widgets
• Dummies
• UI
• Wrappers (HOCs)
• Layouts
When creating a new page, we identify elements that could be reused in the app. These are placed in their corresponding directories.
Other layouts or elements specific to a component are stored directly within that component’s folder or directory. This is true for each type of component.
Now let’s have a closer look at the types of components and how we define them:
Pages
Represent full-page components passed to the router. They may include business logic if necessary.
Layouts
Define templates for arranging interface elements. A common example is a layout component for a page, which includes a header, footer, and a children prop for displaying page-specific content. Layouts can also accept components through slots as props and arrange them as needed.
Wrappers (HOCs)
Helper components for extending or modifying functionality. Example: a wrapper that adds animations to its children.
Widgets
Self-contained components with complete functionality, including business logic. Examples: headers, login forms, product lists, or banners.
Dummies
Complex display components that receive necessary data via props. They don’t contain business logic, only display logic, like toggling block visibility. A common example is a product card, where we pass product details and a callback for the “add to cart” button, making the component reusable with different business logic.
UI
Basic interface elements like buttons, inputs, loaders, and tooltips. May include local state or display logic.
Import Rules:
Components are either neutral or hierarchical:
Neutral Components:
• Layouts
• Wrappers
No restrictions on imports.
Hierarchical Components (from top to bottom):
• Pages
• Widgets
• Dummies
• UI
Each component can only be imported into a higher-level component. For example, UI can’t contain Widget, but Widget can include lower-level components or just have its own layout and logic. Imports are also allowed within the same level.
Component Folder Structure
Each component is stored in a dedicated folder named in PascalCase (e.g. MyComponent). The folder structure includes the following:
- MyComponent.tsx
The main component file, named after the folder it resides in. If the component accepts props, the Props type is defined in this file, right above the component declaration. Additional type definitions can also be included here if needed.
- index.ts
A file to simplify import paths for the component. It can also act as a single entry point for exported materials from the directory.
- Styles.module.scss
Contains styles specific to the component.
- types.ts (optional)
Holds type definitions for the component. For example, if the component interacts with a backend model that doesn’t match the frontend representation (e.g. a form structure differing from backend requirements), those types are stored here.
- constants.ts (optional)
Any static data related to the component.
- useMyComponent.ts (for components with business logic)
A hook to handle the component’s business logic. Separating responsibilities in this way makes the code more maintainable and reusable. If the business logic needs to be reused with a different UI, it can be easily implemented.
- Components (optional)
If part of the component code can be extracted into a child component used solely by the parent, it is stored here. The structure of these components follows the same rules as their parent. Nesting can be any level deep.
You can expand this list with additional resources necessary for the component as long as they are exclusive to that part of the application.
/Models
This directory contains type definitions shared across the application. Includes descriptions of server requests/responses and client-side models not tied to a specific interface.
Why Centralize Models?
Different parts of the app often need models from various categories. Storing models in one place simplifies reuse, reduces duplication, and provides a clear “map” of all available types.
We create a folder for each model category:
• Common
• Auth
• Users
• etc.
Each folder contains:
• api.ts
• client.ts
Models from both of these categories can be used in our application wherever needed.
api.ts
Describes server request and response structures.
Server-related models are named with Request and Response suffixes.
Example:
interface User {
id: string;
name: string;
bio: string;
avatar: ImageDTO;
}
export interface GetUsersRequest {
limit: number;
offset: number;
}
export interface GetUsersResponse {
count: number;
items: User[];
}
Sometimes we need to create a model for a backend entity, used to describe endpoints from different categories. For example, a File that can appear in the response of various endpoints. We place this model in the Common folder and add the DTO suffix to avoid potential name conflicts with client models and to indicate it’s a server-side type.
export interface FileDTO {
id: string
fileUrl?: string
fileName?: string
fileSize?: number
}
client.ts
Contains client-side models that are not tied to backend structures but may be used across the application.
Example:
export interface SelectOption {
value: string
label: string
}
NOTE: If a backend model style is different from a frontend style (e.g. backend models use snake_case while the frontend uses camelCase), we use Axios interceptors to standardize the format to camelCase.
Alternatively, serialization functions can be written for each request to translate data formats, but this approach has proven less practical in our experience.
export const http = axios.create({
baseURL: BASE_URL,
headers: {
'X-API-KEY': BASE_API_KEY,
'Content-Type': 'application/json'
}
})
const responseInterceptors = {
onSuccess: (response: AxiosResponse) => {
if (response.data && response.headers['content-type'] === 'application/json') {
response.data = camelizeKeys(response.data)
}
return response.data ? response.data : response
},
onError: (error: Error) => Promise.reject(error)
}
const requestInterceptors = {
onSuccess: (config: InternalAxiosRequestConfig) => {
config.params = decamelizeKeys(config.params)
if (config.data && config.headers['Content-Type'] === 'application/json') {
config.data = decamelizeKeys(config.data)
}
return config
},
onError: (error: Error) => Promise.reject(error)
}
http.interceptors.request.use(requestInterceptors.onSuccess, requestInterceptors.onError)
http.interceptors.response.use(responseInterceptors.onSuccess, responseInterceptors.onError)
/Api
This directory contains functions for interacting with the server across the entire application. Similar to the models directory, files are organized by the entities they manage:
• auth.ts
• users.ts
• products.ts
• etc.
Example:
// auth.ts
import { http } from 'config/axios/http'
import { privateHttp } from 'config/axios/privateHttp'
import {
VerifyContactRequest,
VerifyContactResponse,
VerifyCodeRequest,
VerifyCodeResponse,
UpdateTokensResponse,
UpdateTokensRequest
} from 'models/auth/api'
import { SuccessResponse } from 'models/common/api'
export const updateTokens = (data: UpdateTokensRequest) =>
http.post<UpdateTokensResponse, UpdateTokensResponse>('/auth/update-tokens', data)
export const verifyContact = (data: VerifyContactRequest) =>
http.post<VerifyContactResponse, VerifyContactResponse>('/auth/verify/contact', data)
export const verifyCode = (data: VerifyCodeRequest) =>
http.post<VerifyCodeResponse, VerifyCodeResponse>('/auth/verify/contact/code', data)
export const logout = () => privateHttp.post<SuccessResponse, SuccessResponse>('/auth/logout')
/App
This is the initializing layer of the application, containing everything necessary to launch it.
• providers
• styles
• types
• hooks
• …
• App.ts
Initialization happens in the App.tsx component (/app/App.tsx). Here, global styles, application fonts, providers, the router, type declarations (d.ts), hooks, and other essential resources are imported.
Example:
import { DEFAULT_ARIA_LOCALE } from 'constants/variables'
import { RouterProvider } from '@tanstack/react-router'
import { I18nProvider } from 'react-aria'
import { router } from './constants/router'
import { withAppProviders } from './providers/appProvider'
import './styles/global.scss'
function App() {
return (
<I18nProvider locale={DEFAULT_ARIA_LOCALE}>
<RouterProvider router={router} />
</I18nProvider>
)
}
export default withAppProviders(App)
/Assets
This directory contains all media files for the project (images, icons, audio, videos). It is organized into subdirectories by category:
• icon
• images
• audio
• video
Within each category, files can also be organized further. For example: /icons/arrows/regular-arrow.svg
/Constants
Contains all constants required by the application. Example:
// permissions.ts
export const rules: Rules<UserRole, Permissions, AbacUser> = {
[UserRole.VISITOR]: {
[Permissions.READ_PRIVATE_PAGES]: false
},
[UserRole.TUTOR]: {
[Permissions.READ_PRIVATE_PAGES]: true,
[Permissions.EDIT_CHATROOM]: (adminId, user) => adminId === user?.uuid,
[Permissions.READ_SETTINGS_DOCUMENTS]: true,
[Permissions.READ_SETTINGS_MEMBERSHIP]: false,
[Permissions.CREATE_KIDS_ONLY_CHATROOM]: false,
}
}
/Hooks
Contains application-wide hooks. Example:
// useWindowSize.ts
import { useLayoutEffect, useState } from 'react'
const useWindowSize = () => {
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 })
const handleSize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
})
}
useLayoutEffect(() => {
handleSize()
window.addEventListener('resize', handleSize)
return () => window.removeEventListener('resize', handleSize)
}, [])
return windowSize
}
export default useWindowSize
/Stores
Contains state manager stores. The team uses zustand for state management.
• alertStore.ts
• userStore.ts
• uiStore.ts
• …
/Styles
Holds global styles for the application.
• layout
• mixin
• variables
• …
/Utils
Contains various utility functions, such as debounce, compose, localStorage management, etc.
Utility files are named based on their function categories. Examples:
• common.ts
• storageManager.ts
• validators.ts
• …
Conclusion
These rules have been invaluable in helping the team develop projects quickly and maintain them without issues. This approach works well for both small applications (~100–1000 hours of frontend development) and long-term projects (~1000+ hours of frontend development).
The same principles can be adapted or extended to fit the specific needs of an application. The key is to maintain a consistent overall approach.
Try this structure for your project. It might work well for you too. And remember, designing applications is an art! 😊