跪拜 Guibai
← Back to the summary

The Real Gap at 3 Years of Frontend: Project Organization, Not Framework APIs

Why Can't You Set Up a Project Properly After 3 Years of Frontend Development?

Many frontend developers enter a very awkward phase in their third year.

They can write pages, connect APIs, fix bugs, and are quite familiar with component libraries. When daily requirements come in, they can fill in code according to existing projects without much issue.

But as soon as the question changes:

Now, starting from scratch to build a long-term maintenance project, how would you organize it?

Many people start to feel uncertain.

Use Vite or Rsbuild? How to divide the directory? Where to put the API layer? Where to put shared types? How to handle login state? How to share code across multiple applications? How to manage environment variables? If the project grows later, how to avoid it becoming increasingly messy?

At this point, you realize that you are not incapable of writing code, but rather lack a higher-level ability: project organization skills.

This article does not talk about a new framework, nor does it teach you to copy a template. What I want to discuss is: why many people, after years of writing business code, still cannot set up a project well.

1. Being Able to Write Features Does Not Equal Being Able to Set Up a Project

When we first enter the industry, most of our time is spent on local tasks:

These tasks are all very important, but they train "local implementation ability."

Setting up a project from scratch tests a different kind of ability:

Many people still can't set up a project well after three years, not because they don't work hard, but because few people in their jobs systematically teach these things.

Projects in companies usually already exist. When you join, the directory is already set, the request layer is already encapsulated, the build configuration is already written, and the login state is already connected.

You fill in code within this structure every day, but are rarely forced to answer:

If you had to design this structure from the beginning, how would you do it? Why?

This is the gap.

2. People Who Can't Set Up Projects Well Usually Fall into These 5 Pitfalls

1. Directories Are Divided Only by File Type, Not by Responsibility

Many projects start like this:

src/
├── components/
├── utils/
├── api/
├── store/
├── views/
└── types/

It looks very standard, but as the project grows, it starts to get messy.

All common components go into components, all utility functions go into utils, all APIs go into api. It's fine at first, but after half a year, no one can clearly say which file belongs to whom.

The problem is not that this directory structure is wrong per se, but that it only answers "what type of file is it" without answering "which responsibility boundary does it belong to."

What really needs to be considered is:

If this question is not thought through first, the directory will eventually become a trash can.

2. The Request Layer Gets Heavier and Heavier, Until No One Dares to Touch It

Many projects' request layers start very simply: encapsulate an axios instance, add a token, add a 401 redirect to login.

Later, requirements increase:

If all logic is hardcoded into a single global axios singleton from the start, the request layer quickly becomes a huge implicit center.

It knows where the token is stored, knows how to jump routes, knows what UI message component to use, knows how to judge backend business codes.

This is trouble.

The request layer should originally only be responsible for "sending requests" and "handling protocols," but it ends up knowing the entire application.

The more a layer knows, the harder it is to reuse and the harder it is to migrate.

3. Environment Variables Are Read Everywhere, and Configuration Errors Are Only Discovered After Going Live

You often see this kind of code in many projects:

const baseURL = import.meta.env.VITE_API_BASE_URL || '';

Or:

const baseURL = process.env.API_BASE_URL ?? '';

It looks safe, but it's actually very dangerous.

If an environment variable is missing, the project doesn't report an error, but silently gives you an empty string. As a result, requests might hit the current domain, or behave abnormally in a certain environment.

What's more troublesome is that Vite, Webpack, and Rsbuild inject environment variables in ways that are not exactly the same. Business code directly reading import.meta.env or process.env everywhere is equivalent to tying the business layer to the build tool.

Today you use Vite, tomorrow you want to switch to Rsbuild, only to find that the business code is full of shadows of the build tool.

This is not a technology selection problem; it's a boundary problem.

4. Build Configuration and Business Code Interpenetrate

Many projects just want to be fast at the beginning, so everything is written directly into the tool configuration.

Business rules are stuffed into the Vite config, page rules into the Webpack config, and environment injection, aliases, entry points, and HTML templates are all tied to specific tools.

In the short term, it's fine. In the long term, it becomes a lock-in.

When the project is small, Vite is very comfortable. When the project grows, you might want to switch to Rsbuild or Rspack to improve build performance; when multi-page scenarios come, you might want to change the entry model.

At this point, if the business code already recognizes the build tool's face, migration is not "changing tools" but "performing surgery."

A truly stable project should not let the business layer directly depend on build tool details.

5. Standards Are Only Written in the README, No One Really Enforces Them

Many teams have standards documents, but the code still gets messier and messier.

The reason is simple: if standards are only written in the README, they rely on human memory for enforcement.

Newcomers may not read them, old employees may not follow them, and AI certainly doesn't know your team's implicit conventions.

For example:

If these things do not enter the toolchain, directory structure, and code constraints, they will become ineffective over time.

Project decay often happens not because no one understands the standards, but because the standards have not been embedded into the structure.

3. The Core of Setting Up a Project Is Not Choosing a Tech Stack, but Designing Boundaries

When many people talk about setting up a project, their first reaction is to choose a tech stack:

These questions are certainly important, but they are not the first-layer questions.

The first-layer question should be:

In this project, which things should be isolated from each other? Which things should be unified? Which things will definitely change in the future?

I increasingly feel that the truly important things for a project foundation are 5 things:

  1. Layering: Different responsibilities are placed in different layers, not polluting each other.
  2. Dependency Direction: The bottom layer should not know the top layer; the top layer assembles the bottom layer through injection.
  3. Single Source of Truth: Versions, entry points, environment variables, page configurations must have a clear source.
  4. Explicit Failure: If configuration is missing, throw an error; don't silently fall back.
  5. Evolution Space: Today's tool choices should not block tomorrow's migration path.

If these 5 things are not thought through clearly, the more advanced the tech stack, the messier the project may become.

4. A Real Example: Why I Designed LYStack This Way

I recently organized a Vue3 Monorepo foundation, distilled from years of stepping into pitfalls, into an open-source project called LYStack.

It's not to prove "I wrote another scaffolding."

On the contrary, I don't really want to call it scaffolding. Scaffolding solves the problem of "quickly generating a project," whereas I am more concerned about:

When a project is maintained long-term, how to prevent it from decaying?

So there are several designs in LYStack, not for showing off skills, but to answer those boundary questions above.

1. Using Monorepo to Separate "Applications" and "Common Capabilities"

The structure of LYStack is roughly like this:

LYStack/
├── apps/
│   ├── example-vite/
│   ├── example-rsbuild/
│   └── example-rsbuild-mpa/
├── packages/
│   ├── build-config/
│   ├── shared/
│   ├── services/
│   └── ui/
├── pnpm-workspace.yaml
└── turbo.json

The point here is not "using Monorepo is advanced."

The point is that it separates responsibilities:

After this design, the application is no longer a big src where everything is stuffed in.

Common capabilities can be precipitated into underlying packages, and multiple applications can share them; the application layer is only responsible for its own business assembly.

This is the real problem Monorepo solves: not putting multiple projects into one repository, but giving shared capabilities a place to precipitate.

2. The Build Tool Is Locked into an Adapter, Business Code Doesn't Know It

In LYStack, there is a build-tool-agnostic configuration contract called AppBuildOptions.

The application only describes what it wants to build:

export default defineViteConfig({
  kind: 'spa',
  appName: 'example-vite',
  root: resolveRoot(import.meta.url),
  entry: 'index.html',
});

When switching to Rsbuild, the application configuration looks like this:

export default defineRsbuildConfig({
  kind: 'spa',
  appName: 'example-rsbuild',
  root: resolveRoot(import.meta.url),
  entry: 'src/main.ts',
});

You will find that the application layer does not directly touch the complex configurations of Vite or Rsbuild.

The real tool differences are locked inside the two adapters @repo/build-config/vite and @repo/build-config/rsbuild.

The idea behind this is:

The application only describes "what I want to build," and the adapter is responsible for translating it into "which tool to use to build."

In this way, when you want to change the build tool in the future, you change the adapter, not the business code.

This is a boundary.

3. The Service Layer Uses Dependency Inversion, Not Hardcoding Login State

LYStack does not use the approach of "a single global axios singleton hardcoding all logic," but instead creates an AxiosFactory.

Each backend service can have its own instance, its own interceptors, and its own way of handling business codes.

More critically, the services layer does not directly read localStorage, does not directly import the router, and is not bound to any specific UI message component.

When the application starts, these capabilities are injected in bootstrap():

configureServiceAuth({
  getToken: () => localStorage.getItem(STORAGE_TOKEN_KEY) ?? '',
  onAuthenticationFailure: () => {
    // In a real project, redirect to the login page here
    console.warn('[bootstrap] Authentication failed, please connect route jump logic');
  },
});

setErrorMessenger((msg: string) => {
  console.error(`[LYStack] ${msg}`);
});

The focus of this code is not the writing style, but the dependency direction.

The services layer does not care where the token comes from, nor does it care which page to jump to after a 401. It only exposes an injection point, which is assembled by the application layer at startup.

This way, services can be reused by different applications.

The same request layer can run in a normal web application, or in an Electron application; it can connect to localStorage, or to cookies or SSO.

This is the value of dependency inversion.

4. Environment Variables Are Unified, and Missing Ones Throw Errors Directly

In LYStack, the business layer does not directly read import.meta.env or process.env everywhere.

Environment variables are uniformly read from @repo/shared/env:

const apiBase = getEnv('PUBLIC_API_BASE_URL');

If a required environment variable is missing, it does not return an empty string, but throws an error directly.

This design comes from a very common lesson: the configuration is missing, but the code silently falls back with ?? '', resulting in all online requests hitting the wrong place, and it takes a long time to troubleshoot.

So I now lean more towards:

If the configuration is wrong, it should blow up as early as possible. Don't leave the problem for production.

This is also part of project organization ability.

Newbies like to "get it running first," but long-term maintenance projects need "errors exposed as early as possible."

5. Multi-Page Entries Use PageConfig as the Single Source of Truth

In LYStack, the page entries for the Rsbuild MPA are not based on scanning directories, nor are they scattered across a bunch of HTML files, but there is a page.config.ts:

export const pages = [
  {
    name: 'index',
    entry: './src/pages/index/main.ts',
    title: 'LYStack · Home',
  },
  {
    name: 'about',
    entry: './src/pages/about/main.ts',
    title: 'LYStack · About',
  },
];

When adding a new page, the page must be registered in this list.

This is called a single source of truth.

The larger the project, the less it can rely on "everyone just agrees." Entry points, versions, environments, routes, permissions—these things should all have a clear source.

Otherwise, the project may appear to run on the surface, but internally it will become increasingly uncontrollable.

5. So, What Should a 3-Year Frontend Developer Really Supplement?

If you can already independently write pages, connect APIs, and fix bugs, but panic when setting up a project from scratch, I suggest you don't just supplement framework APIs next.

What you really need to supplement are these questions:

1. Learn to Decompose Responsibilities

Don't just ask "where to put this file."

Ask:

Being able to answer these questions will naturally make the directory structure much clearer.

2. Learn to Look at Dependency Direction

Much code is not unwritable, but should not be written in that layer.

The request layer can handle protocols, but should not know about page routing.

The shared layer can contain constants and tools, but should not depend on business modules.

The build layer can handle Vite/Rsbuild differences, but should not leak into business components.

Once the dependency direction is messed up, the project becomes harder and harder to change.

3. Learn to Establish a Single Source of Truth

Versions should not be scattered in every package.

Environment variables should not be read everywhere.

Page entries should not rely on everyone's memory.

Shared types should not be copied and pasted.

For the same fact, it's best to have only one authoritative source in the project.

Otherwise, all your subsequent maintenance costs are paying off the debt for these duplicated facts.

4. Learn Explicit Failure

Newbies are afraid of errors; more experienced engineers are afraid of no errors.

Missing configuration, missing entry registration, empty environment variables, an adapter not supporting a certain mode—these should all be exposed as early as possible.

In LYStack, the Vite adapter currently does not support MPA, so it throws an error directly, rather than pretending to support it.

This is not a lack of features, but boundary honesty.

A project can have boundaries, but the boundaries must be clear.

5. Learn to Leave Room for the Future

Not all projects need to be very complex from the start.

But if you know clearly that the project will be maintained long-term, will involve multi-person collaboration, and will share capabilities across multiple applications, then you must consider evolution space from the beginning.

It's fine to use Vite today, but don't let business code be deeply coupled with Vite.

It's fine to have only one application today, but common capabilities need a place to precipitate.

It's fine if the login state is simple today, but the request layer should not hardcode the login implementation.

So-called architecture is not about making everything complex in advance, but about allowing key changes to have a place to happen.

6. Stop Just Asking "Is There a Template?", Ask "Why Is It Organized This Way?"

I used to really like finding templates.

Vue3 templates, admin panel templates, Electron templates, Monorepo templates, they all looked very complete.

But later I found that just knowing how to use templates is not enough.

Templates can help you start quickly, but they cannot make engineering judgments for you.

What is truly valuable is figuring out:

When you start asking these questions, you are no longer just "able to write code," but are building project organization ability.

This is also the reason I open-sourced LYStack.

I don't want it to be just a project that you clone and run. I hope it becomes a case study: we can use it to discuss which boundaries should be thought through first when a real project goes from 0 to 1.

7. Finally

It's not shameful to still not be able to set up a project well after 3 years of frontend development.

Because most people's work environments rarely train this skill.

What you encounter every day are existing projects, local requirements, and directories and rules set by others. You will naturally become better at writing features, but you won't necessarily naturally learn how to set up a project.

From "being able to write features" to "being able to organize a project," what's missing in between is not some framework API, but a set of engineering judgments:

These things are what frontend developers with 3-5 years of experience really need to break through.

Later, I will continue to use the LYStack open-source foundation as an example to dissect how a project should be organized from 0 to 1.

In the next article, I want to talk about:

How exactly should the directory structure be designed? Stop treating components and utils as trash cans.

If you are also stuck at the stage of "can write pages, but can't set up a project," you can follow this series.

Project address:

Comments

Top 3 of 4 from juejin.cn, machine-translated. The original thread is authoritative.

你我皆为过客

You've hit the nail on the head [thumbs up]

Liora_Yvonne

Thanks to everyone who read it carefully. I hope it inspires you, that's enough.

百变老鹰

Thanks for sharing

無名路人

Tell AI to generate a project framework for me