After building more than a few dozen web applications, I keep noticing the same thing: as frontend developers, we often make projects more complicated than they need to be. We bring in heavy libraries just to cover a couple of features. We add abstractions that end up confusing even us. We do not keep an eye on unnecessary re-renders. We do not think about bundle size. And then we wonder why the project has become slow, awkward, and difficult for new people on the team. I do not think every project should be built with “minimalism at any cost”. No. But for small and medium-sized products, there is often a lot of room to simplify life for both yourself and your team without sacrificing quality. In this article, I will show how I usually approach frontend development like this: calmly, without unnecessary noise, and without worshipping heavy solutions. One important thing up front: I will not go into every tool and every library in detail. Otherwise this article would turn into a reference manual. My goal is different — to show the general approach and the things I actually find useful. Why this is worth doing at all This approach has a few clear advantages. Clean code structure. The fewer unnecessary entities you have, the easier the project is to understand. If I can get by with the standard tools provided by the language and platform, I do. There is no need to invent an extra layer where one is not needed. Such a project is easier to maintain and easier to grow. And if it does become larger over time, you can always move to a more scalable structure and a more powerful state manager. A small bundle. For small and medium-sized projects, this is a very big win. One tidy bundle, with no unnecessary requests and no lazy loading, often gives excellent results. Modern browsers support compression, and Brotli usually gives better compression than gzip, although the compression itself can take a little longer. Easier onboarding. If a new developer joins the project, they will understand a simple structure much faster. That matters even more when there is more than one person on the team. Easier to work with AI tools. We are increasingly using models for code, tests, and refactoring. The clearer the project is, the easier it is for models to write code in it without making unnecessary mistakes. That does not mean AI will replace a developer. But it understands good, simple code better than code that is complex and overloaded. A developer with less experience can maintain such a product. That means less cost for the business. Clean code structure. The fewer unnecessary entities you have, the easier the project is to understand. If I can get by with the standard tools provided by the language and platform, I do. There is no need to invent an extra layer where one is not needed. Such a project is easier to maintain and easier to grow. And if it does become larger over time, you can always move to a more scalable structure and a more powerful state manager. Clean code structure. Clean code structure. The fewer unnecessary entities you have, the easier the project is to understand. If I can get by with the standard tools provided by the language and platform, I do. There is no need to invent an extra layer where one is not needed. Such a project is easier to maintain and easier to grow. And if it does become larger over time, you can always move to a more scalable structure and a more powerful state manager. A small bundle. For small and medium-sized projects, this is a very big win. One tidy bundle, with no unnecessary requests and no lazy loading, often gives excellent results. Modern browsers support compression, and Brotli usually gives better compression than gzip, although the compression itself can take a little longer. A small bundle. A small bundle. For small and medium-sized projects, this is a very big win. One tidy bundle, with no unnecessary requests and no lazy loading, often gives excellent results. Modern browsers support compression, and Brotli usually gives better compression than gzip, although the compression itself can take a little longer. Easier onboarding. If a new developer joins the project, they will understand a simple structure much faster. That matters even more when there is more than one person on the team. Easier onboarding. Easier onboarding. If a new developer joins the project, they will understand a simple structure much faster. That matters even more when there is more than one person on the team. Easier to work with AI tools. We are increasingly using models for code, tests, and refactoring. The clearer the project is, the easier it is for models to write code in it without making unnecessary mistakes. That does not mean AI will replace a developer. But it understands good, simple code better than code that is complex and overloaded. Easier to work with AI tools. Easier to work with AI tools. We are increasingly using models for code, tests, and refactoring. The clearer the project is, the easier it is for models to write code in it without making unnecessary mistakes. That does not mean AI will replace a developer. But it understands good, simple code better than code that is complex and overloaded. A developer with less experience can maintain such a product. That means less cost for the business. A developer with less experience can maintain such a product. A developer with less experience can maintain such a product. That means less cost for the business. There are also downsides. In real work, you often cannot choose everything yourself. A company may already have adopted MobX, Material UI, or another ecosystem, and you have to live with that. And very often the business insists on tasks and decisions that will throw all our minimalism straight in the bin. Design frameworks are convenient. We are used to them. And sometimes you really do not want to go back to the days when you had to build every button, modal, and tooltip by hand. But for the sake of simplicity, I often build my own buttons, text fields, and other basic components. That helps me keep full control over them, instead of pulling them in from a third-party design system where everything is overthought and weighed down with unnecessary features. This approach works best for startups, prototypes, and smaller products. For a very large and complex product, you may sometimes need to add more complexity than I would personally like. In real work, you often cannot choose everything yourself. A company may already have adopted MobX, Material UI, or another ecosystem, and you have to live with that. And very often the business insists on tasks and decisions that will throw all our minimalism straight in the bin. In real work, you often cannot choose everything yourself. In real work, you often cannot choose everything yourself. A company may already have adopted MobX, Material UI, or another ecosystem, and you have to live with that. And very often the business insists on tasks and decisions that will throw all our minimalism straight in the bin. Design frameworks are convenient. We are used to them. And sometimes you really do not want to go back to the days when you had to build every button, modal, and tooltip by hand. But for the sake of simplicity, I often build my own buttons, text fields, and other basic components. That helps me keep full control over them, instead of pulling them in from a third-party design system where everything is overthought and weighed down with unnecessary features. Design frameworks are convenient. Design frameworks are convenient. We are used to them. And sometimes you really do not want to go back to the days when you had to build every button, modal, and tooltip by hand. But for the sake of simplicity, I often build my own buttons, text fields, and other basic components. That helps me keep full control over them, instead of pulling them in from a third-party design system where everything is overthought and weighed down with unnecessary features. This approach works best for startups, prototypes, and smaller products. For a very large and complex product, you may sometimes need to add more complexity than I would personally like. This approach works best for startups, prototypes, and smaller products. This approach works best for startups, prototypes, and smaller products. For a very large and complex product, you may sometimes need to add more complexity than I would personally like. Where I start I like to begin with the simplest possible setup. Editor If you want to go to the extreme of minimalism, you can use Neovim. But I usually go with plain VS Code. It is free, easy to understand, and simple to configure. You can tailor it to your preferences with extensions and appearance settings without turning the whole thing into a religious argument about editors. Project foundation First I make sure I have a recent version of Node.js installed, then I create a project through Vite. Vite officially supports the preact-ts template, so it is easy to get started with. preact-ts Example: npm create vite@latest my-preact-ts-app -- --template preact-ts npm create vite@latest my-preact-ts-app -- --template preact-ts Why this approach? Preact is a lightweight alternative to React. In the official Preact materials, the emphasis is on small size, good performance, and closeness to the DOM. It also has one important difference from React: it does not use a synthetic event system, but works through the native addEventListener. That makes it closer to standard DOM behaviour. addEventListener I consider TypeScript essential. These days, most projects feel uncomfortable without it. It helps keep data types in order, catches mistakes earlier, and makes existing code easier to understand. Folder structure I like a simple structure. Nothing fancy. Something like this: components — shared components pages — pages services — classes and functions for working with the backend contexts — createContext providers — providers with logic hooks — shared hooks models — types, contracts, and interfaces utils — reusable functions consts — global constants components — shared components components pages — pages pages services — classes and functions for working with the backend services contexts — createContext contexts createContext providers — providers with logic providers hooks — shared hooks hooks models — types, contracts, and interfaces models utils — reusable functions utils consts — global constants consts You can make things more complicated with FSD, a modular structure, or your own scheme. But I often choose the simpler route. When a project is small or medium-sized, extra architecture only gets in the way. I prefer structure that helps rather than structure that starts living its own life. Of course, we do not always know from day one whether a project will become complex, but you can always spend a bit of time later and move, for example, to FSD. Personally, I usually stick with my own structure and mix it a little with modular architecture, turning components in the components folder and pages in the pages folder into modules that can have their own hooks, utilities, subcomponents, and constants. components pages Code quality tools ESLint, Prettier, and Stylelint have long since become standard. I do not think setting them up makes a project more complicated. On the contrary, they help keep the code clean and predictable. These tools add almost nothing to the frontend bundle, but they make a huge difference to the quality of work, not only in a team, but even when you are working alone. What to do about state This is usually where unnecessary complexity starts. In many small projects, plain React Context is enough. Not for everything, of course. But for some tasks — absolutely. For example, interface language. export interface LanguageContextType { language: ELanguage; setLanguage: (language: ELanguage) => void; } export const LanguageContext = createContext<LanguageContextType | null>(null); export const LanguageProvider = ({ children }: PropsWithChildren) => { const [language, setLanguage] = useState<ELanguage | null>(null); const [location] = useLocation(); const contextValue = useMemo(() => ({ language, setLanguage }), [language]); useEffect(() => { const languageInPath = location.split('/')[1] as ELanguage; const currentLanguage = languageInPath in ELanguage ? languageInPath : ELanguage.en; document.documentElement.lang = currentLanguage; setLanguage(currentLanguage); }, [location]); if (!contextValue.language) return <Loader />; return ( <LanguageContext.Provider value={contextValue as LanguageContextType}> {children} </LanguageContext.Provider> ); }; export interface LanguageContextType { language: ELanguage; setLanguage: (language: ELanguage) => void; } export const LanguageContext = createContext<LanguageContextType | null>(null); export const LanguageProvider = ({ children }: PropsWithChildren) => { const [language, setLanguage] = useState<ELanguage | null>(null); const [location] = useLocation(); const contextValue = useMemo(() => ({ language, setLanguage }), [language]); useEffect(() => { const languageInPath = location.split('/')[1] as ELanguage; const currentLanguage = languageInPath in ELanguage ? languageInPath : ELanguage.en; document.documentElement.lang = currentLanguage; setLanguage(currentLanguage); }, [location]); if (!contextValue.language) return <Loader />; return ( <LanguageContext.Provider value={contextValue as LanguageContextType}> {children} </LanguageContext.Provider> ); }; After that, any component can access the data like this: const languageContext = useContext(LanguageContext); const languageContext = useContext(LanguageContext); And that is it. No extra magic. If you still want something more convenient, you can use a lighter store such as Signals, Jotai, or Zustand. I do not see a problem with that. The main thing is not to choose a heavy tool just because it is fashionable. Use it only when it genuinely makes the code simpler. Working with backend data A lot of people automatically reach for axios. I have done that myself many times. But in many cases, plain fetch, wrapped in your own function, is enough. fetch export const fetcher = async <ResponseType, PayloadType = undefined>( url: string, method = 'GET', payload?: PayloadType, prefix = import.meta.env.VITE_BACKEND_URL + '/api/', ) => { const response = await fetch(prefix + url, { method, headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: payload ? JSON.stringify(payload) : null, }); if (response.ok) { return (await response.json()) as ResponseType; } const error = (await response.json()) as ErrorResponse; throw new ErrorResponse(error.message, error.httpStatus); }; export const fetcher = async <ResponseType, PayloadType = undefined>( url: string, method = 'GET', payload?: PayloadType, prefix = import.meta.env.VITE_BACKEND_URL + '/api/', ) => { const response = await fetch(prefix + url, { method, headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: payload ? JSON.stringify(payload) : null, }); if (response.ok) { return (await response.json()) as ResponseType; } const error = (await response.json()) as ErrorResponse; throw new ErrorResponse(error.message, error.httpStatus); }; The point is simple: do not bring in an extra dependency if you can solve the job with a short, clear wrapper. For reading and updating data, I would look at SWR. SWR has a minimal API, caching, revalidation, and request deduplication. This is exactly the kind of tool that does the job without weighing the project down. In the official documentation, SWR specifically describes useSWR and useSWRMutation, and for a standard REST example it shows a fetcher based on native fetch. useSWR useSWRMutation fetch Fetching data: const { data: cardData, error, isLoading, } = useSWR<Card, ErrorResponse>(cacheDataKey); const { data: cardData, error, isLoading, } = useSWR<Card, ErrorResponse>(cacheDataKey); Mutating data: const { trigger: triggerDeleteCardLink, isMutating: isDeletingCardLink } = useSWRMutation<CardLink, ErrorResponse, string, number, Card>( cacheDataKey || '', async (_url, { arg: cardLinkId }) => await fetcher<CardLink>(`card-links/${cardLinkId}`, 'DELETE'), { revalidate: false, populateCache: (cardLink, card) => { if (!card) throw new Error('Card not found'); return { ...card, links: card.links?.filter((link) => link.id !== cardLink.id), }; }, }, ); const { trigger: triggerDeleteCardLink, isMutating: isDeletingCardLink } = useSWRMutation<CardLink, ErrorResponse, string, number, Card>( cacheDataKey || '', async (_url, { arg: cardLinkId }) => await fetcher<CardLink>(`card-links/${cardLinkId}`, 'DELETE'), { revalidate: false, populateCache: (cardLink, card) => { if (!card) throw new Error('Card not found'); return { ...card, links: card.links?.filter((link) => link.id !== cardLink.id), }; }, }, ); I like this approach because it gives me control. I decide what gets updated and when. I do not hide the logic behind ten layers of abstraction. And in a small project, that is often the better choice. Tests There is almost never enough time for tests. That is true. But in a simple application, unit tests do not feel nearly as scary. AI tools are quite good at writing them now, especially when the code itself is not overloaded. I would not say tests can be ignored. Quite the opposite: when the project is simple, it is easier to write basic tests. And that helps a lot when you later need to change something without being afraid of breaking everything. Design frameworks My view here is simple. If a project genuinely needs a heavy framework, fine. But in small projects, it is often not needed. Sometimes it is easier to build components yourself. Then you have full control over appearance, behaviour, and code size. Yes, it takes a little more manual work. But afterwards you do not have to live inside someone else’s decisions, which are often difficult to reshape for your own needs. Other small libraries I do not like dragging large packages into a project for one or two functions. But there are some good small utilities that really do help. For className, I would use a small helper such as classcat, if it fits the style of the project. The idea is simple: no long ternary chains in TSX and no need to keep class logic in your head. className classcat Example: className={cc([ styles.tariff, isPremium ? styles.tariff_accent : styles.tariff_default, ])} className={cc([ styles.tariff, isPremium ? styles.tariff_accent : styles.tariff_default, ])} For icons, I like react-icons. It is a large library of SVG icons that supports tree shaking, meaning only the icons you actually use end up in the bundle. react-icons For routing, I prefer a minimal approach. In the Preact ecosystem there are solutions such as wouter, and wouter describes itself as a tiny router for React and Preact. In its repository, it is explicitly called a small router based on hooks. wouter wouter For utilities, I do not see a problem with lodash if it is genuinely justified. But I would not bring it in just out of habit. If you only need one function, it is often better to use just that one, or even write a small custom version. That keeps the code cleaner and the bundle lighter. lodash About static compression This is another thing that people often forget. You can write the code perfectly, and then still send the user heavy files without proper compression. I usually look towards Brotli. It gives very good compression and is well supported by modern browsers. The fact is that Brotli gives better compression than gzip, and in modern environments Brotli and gzip remain the main options for HTTP compression. If your frontend is already small, good compression makes the whole picture even better. Final thoughts My conclusion is simple: frontend does not have to be heavy, complicated, and bloated. In many projects, you can achieve a very good result without unnecessary dependencies and without complicated architecture. For example, I have managed to launch two real projects with bundle sizes of 50 KB and 100 KB. And these were fully working applications. My approach is this: start with standard tools; then use simple, clear tools; and only then move to more complex solutions, if they are genuinely needed. start with standard tools; then use simple, clear tools; and only then move to more complex solutions, if they are genuinely needed. This is not about a poor stack, and not about saving money for the sake of saving money. It is about common sense. About code that is easy to read. About a project that is easy to maintain. About a bundle that does not grow for no reason. And about a team that does not have to suffer living inside that code. To me, that is what proper frontend minimalism is: not doing extra work where things can be made simpler.