How to Keep AI-Generated Code Modular
Source: Dev.to
Hi, my name is Paul, and I am a senior software engineer exclusively working for start‑ups.
I treat programming languages like an all‑you‑can‑eat buffet, and no matter the language, the one thing I value is modularization. For this, our OpenAI and Claude assistants aren’t very helpful. They are trained on a ton of old‑school projects where everything’s crammed into giant index.js files. Unsupervised vibe coding will often give you +1000 lines of code per file and a lot of repetition.
Why is it a problem?
In start‑ups, code is short‑lived. When vibe coding is used intensively, the AI will always find a way, and the burden of refactoring will never be needed. But that isn’t quite true in reality. Things change quickly, and implementing new components or behavior must be quick and risk‑free. The AI is very bad at evaluating the impact of one change. If I add a field to a model, what consequences will it have over the whole system? Am I introducing a security risk? Am I breaking something?
There comes the need for unit tests, of course, but the bigger need, imho, is for modularization. Keep the code isolated, unaware of the exterior, specific to one usage per function, tested and in a single file. That behavior saved me a lot of trouble and a lot of refactoring. While it makes development a little more verbose—a little more of a brain teaser—it does make it more robust, and a lot more fun.
And this is what this article is about: how to force your AI tools to generate modular code. For this we need to severely jail the models, prune all liberties, and kill every bit of creativity. But I work at Vybe.build, and keeping AI on a short, well‑designed leash is kind of our thing.
My Stack
For the sake of this article, I will use a stack I know very well and that I reach for almost every time I build a small SaaS or a “SaaS‑replacement” project. It’s not exotic, but it is extremely compatible with modular thinking and with AI‑assisted development.
- Next.js with the App Router
- TypeScript, everywhere
- Vercel for deployment (serverless, zero friction)
- Cursor as my AI‑powered editor (the same ideas apply to Claude Code, Copilot, etc.)
This stack matters because it forces you to think in terms of boundaries: server vs. client, routes vs. logic, data vs. UI. Boundaries are exactly what AI tends to ignore unless you make them impossible to cross.
You can find a starter project here.
A Specific File Organization
This is the structure I use:
./
├── app/ # Next.js App Router pages and routes
├── src/
│ ├── __template__/ # Template used to generate new modules
│ │ ├── api/ # Server‑side actions (DB writes, heavy logic, etc.)
│ │ ├── components/ # React components specific to the module
│ │ ├── hooks/ # React hooks
│ │ └── types/ # TypeScript types (shared client/server)
│ ├── auth/ # Authentication module
│ ├── db/ # Database module
│ └── ui/ # Shared UI library
│ ├── components/
│ ├── hooks/
│ └── globals.css
├── prisma/ # Database schema and migrations
└── public/ # Static assets
Each folder under src is a module. A module owns its logic, its hooks, its components, and its types. No reaching into other modules “just this once”. If something is shared, it goes into ui or a dedicated shared module.
The __template__ folder is important. It is the blueprint for every new module. When a new feature appears, I don’t start by writing code; I start by generating a module from that template. This removes a huge amount of decision‑making, both for me and for the AI.
I like the api / components / hooks / types split because it works well with Next.js’s dual environment. Server code and client code are clearly separated, and the AI has fewer excuses to mix everything together. Ultimately, what your modules are made of is up to you.
Next.js Structure
One rule I am very strict about: the app/ directory contains only very specific logic:
- Prop injections, server‑side data fetching for pages
- Access protection, authentication, return value for routes
Next.js has strong opinions about routing and execution contexts, and that’s fine. The app router defines pages and API routes—nothing more.
Anything related to business logic, data access, transformations, or even slightly reusable behavior lives in src/, inside a module.
Example Page
import { getUsers } from '@/src/user/api';
import { User } from '@/src/user/types';
export default async function UserPage() {
let users: User[] = [];
let error: string | null = null;
try {
users = await getUsers();
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred';
}
if (error) {
return Error: {error};
}
return <div>{/* render users */}</div>;
}
Fetching data and handling the route‑level error belongs to Next.js. Everything else belongs to the user module.
Example API Route
import { NextResponse } from 'next/server';
import { getUsers } from '@/src/user/api/getUsers';
import { auth } from '@/src/auth';
export const GET = withAuth(async (request: Request) => {
const session = await auth.api.getSession();
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const users = await getUsers();
return NextResponse.json(users);
});
Authentication checks and HTTP concerns stay in the route. The actual logic lives in the module.
This separation is boring, repetitive, and extremely effective. It also gives the AI a clear, enforceable boundary to respect, which is exactly what we need to keep our codebase modular and maintainable.
Routes Glue Things Together, Modules Do the Work
Now with AI
Once this structure is in place, AI becomes both incredibly powerful and incredibly dangerous. Left alone, it will always try to be helpful in the laziest possible way: it will code everything in the page, and you will end up doing multiple API calls for nothing, rewriting logic everywhere, etc.
The goal is simple: remove choices. If there is only one valid way to do things, the AI will eventually follow it.
Using EJS Templates to Generate Modules
The first step is to make creating a new module trivial and mechanical.
I use a small script (Node + EJS) that generates a module from the template folder. From the outside, it looks like this:
npm run create-module user
It generates the following file tree:
src/user/
├── api/
│ ├── getUser.ts
│ ├── getUsers.ts
│ ├── createUser.ts
│ └── updateUser.ts
├── components/
│ └── UserCard.tsx
├── hooks/
│ ├── useUser.ts
│ └── useUsers.ts
└── types/
└── User.ts
Nothing magical here. The API files are thin, explicit functions. The hooks are small wrappers, usually around React Query. Components are dumb by default. Types are shared between server and client.
This does two things:
- It removes friction for humans.
- It gives the AI a very strong prior.
Additional benefits:
- No giant service files.
- No “utils.ts” dumping ground.
When I ask the AI to “add a user feature”, it doesn’t invent a structure. It follows the one that already exists. If it doesn’t, I regenerate or fix the module instead of letting entropy creep in.
Custom ESLint Rules as Guard Rails
Templates handle the happy path. Linters handle the cheating.
A concrete example: API routes
In Next.js it is very easy to write this:
export const POST = async (req: Request) => {
// do stuff
};
But in my apps every route must go through a wrapper that injects context (session, user, permissions, etc.):
export const POST = withAuth(async ({ user, req }) => {
// do stuff
});
So I add a custom ESLint rule that enforces this. If a route exports POST, GET, PUT, etc., and it is not wrapped correctly, linting fails.
What is interesting is how AI reacts to this.
When the AI generates a route and forgets the wrapper, the linter error shows up immediately. The AI sees the error, understands the pattern, and fixes the code by adding the missing wrapper. You don’t have to explain security again or argue; you let the toolchain do the teaching.
This works for many things:
- Enforcing file boundaries
- Preventing cross‑module imports
- Forcing naming conventions
- Blocking “clever” shortcuts
Clear Instructions
This seems obvious but is worth repeating. Whether you are using Cursor rules, Claude Code instructions, or a system prompt elsewhere, the idea is the same: write down your constraints early and evolve them over time.
I don’t try to be exhaustive. I start with a short list:
- Where code is allowed to live
- How modules are structured
- What is forbidden (monolithic files, cross‑imports, business logic in routes, etc.)
Every time the AI surprises me in a bad way, I don’t fix it once; I add a rule.
I usually keep a big instruction block explaining the module structure, the rules around app/, and how to extend existing modules instead of creating new patterns. I won’t paste it here, but the exact content matters less than the habit: treat AI instructions like code. Version them. Improve them. Assume they will rot if you don’t.
The Result
Those tools have been around for a long time, and I mostly ignored them until recently. It’s only now, with AI doing a large part of the typing, that their impact on productivity really clicked for me.
I’m not advocating for modular code or “vibe coding” either. You should do whatever works for you. I’m just sharing what I’ve been doing, and in my case it works pretty well.
Hopefully it will give you some ideas.
Thanks for reading!