A solo developer's project structure is the difference between shipping features and drowning in tech debt. As a full-stack developer running Anjeer Labs, I've learned that a good structure isn't about following trends—it's about creating a system that scales with you, not against you. My approach prioritizes clarity, maintainability, and the ability to hand off code without a week of handover. This is how I structure my projects to stay productive and sane.
Why a strict folder structure matters for solo devs
When you're the only one writing code, it's tempting to let organization slide. I've made that mistake. The cost comes due when you return to a feature after three months and spend an hour just finding the relevant files. My rule is simple: if I can't locate a component, utility, or API route within 10 seconds, the structure has failed.
I use a modified version of the bulletproof src/-based structure, but with clear domain separation. Here’s the core layout for a typical Next.js/TypeScript full-stack project:
src/
├── app/ # Next.js 13+ app router pages & layouts
├── components/ # Shared React components
│ ├── ui/ # Primitives (Button, Card, Input)
│ └── features/ # Domain-specific (InvoiceList, UserProfile)
├── lib/ # Core utilities, configs, and shared logic
├── hooks/ # Custom React hooks
├── services/ # External API clients and SDK wrappers
├── types/ # Global TypeScript definitions
├── utils/ # Pure helper functions
└── scripts/ # Build/deployment/node scripts
The key is the components/features/ directory. It groups components by business domain, not by technology. This mirrors how you think about the product, making navigation intuitive.
Do you really need a monorepo as a solo developer?
Probably not, unless you're building a suite of tightly coupled tools or a mobile app with a shared backend. Early in a project, a monorepo adds overhead. I start with a single repository and only split when I have a clear, independent module—like a shared design system package or a separate admin dashboard.
When that time comes, I use Turborepo. Its caching is a game-changer for solo productivity. Here's a minimal turbo.json to get started:
{
"pipeline": {
"build": {
"outputs": [".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false
},
"lint": {}
}
}
The tipping point is when you find yourself copying the same lib/utils.ts file between projects. That's when a packages/ directory for shared code becomes worth the setup.
How to manage environment variables and secrets
This is non-negotiable: secrets belong in .env.local, and that file is in .gitignore. I commit a .env.example file with placeholder keys and required structure. For a full-stack app, I split variables by context:
# .env.example
# Database
DATABASE_URL="postgresql://..."
# Authentication
NEXTAUTH_SECRET="your-secret-here"
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# External Services
STRIPE_SECRET_KEY="sk_test_..."
I load these using process.env in Next.js or via a validated config object. For type safety, I extend the Env namespace:
// src/types/env.d.ts
declare namespace NodeJS {
export interface ProcessEnv {
DATABASE_URL: string;
NEXTAUTH_SECRET: string;
GITHUB_CLIENT_ID: string;
GITHUB_CLIENT_SECRET: string;
}
}
This prevents runtime errors from missing environment variables during development.
When should you create a custom hook or a utility function?
If logic involves React state or lifecycle, it becomes a hook. If it's pure data transformation, it's a utility. This separation keeps your utils/ folder free of React dependencies and your hooks/ folder focused on component behavior.
For example, formatting a currency is a utility:
// src/utils/currency.ts
export function formatCurrency(amount: number, locale = 'en-IN'): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'INR',
}).format(amount);
}
Fetching and managing a user's profile data is a hook:
// src/hooks/use-user-profile.ts
import { useState, useEffect } from 'react';
import { getUserProfile } from '@/services/user-service';
export function useUserProfile(userId: string) {
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getUserProfile(userId).then(setProfile).finally(() => setLoading(false));
}, [userId]);
return { profile, loading };
}
This pattern makes code predictable and easy to test.
The one script that saves me hours every month
A scripts/ directory isn't just for DevOps. I keep a scripts/seed.ts file for populating my database with development data and a scripts/clean-build.ts for clearing all caches and doing a fresh build. Running them is a single command: tsx scripts/seed.ts.
Here's a simple seed script using Prisma:
// scripts/seed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.user.create({
data: {
email: 'dev@example.com',
name: 'Development User',
},
});
console.log('✅ Database seeded');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
Automating repetitive setup tasks means I spend less time configuring and more time building.
Your project structure is a living document—refactor it when it starts to fight you.