NEVER USE INLINE STYLES. All visual styling must use Tailwind CSS utility classes. Never use the style prop or style={{ }} on any element under any circumstances.
NEVER USE ARBITRARY TAILWIND VALUES. Never write classes like text-[10px], px-[28px], min-h-[400px], tracking-[0.18em], or any [...] bracket syntax. Always use Tailwind's standard scale: text-xs, text-sm, text-base, text-lg, etc. for font sizes; p-1, p-2, px-4, py-2, etc. for spacing; min-h-96, min-h-screen, etc. for sizing.
NEVER USE PRIMITIVE HTML ELEMENTS IN PAGE FILES. In any page.tsx, use components for all structural and layout concerns. Header, footer, cards, and any reusable layout must be extracted into components and imported. Do not write raw <header>, <footer>, <article>, or equivalent structural HTML directly in a page file.
FOOTER ALWAYS LIVES IN src/shared/components/layout/footer.tsx. Never write footer markup inline in a page or layout.
IT IS CRITICAL TO USE EXACT SAME CODEBASE PATTERNS. NEVER CREATE A FILE OR FOLDER THAT IS OUT OF CONTEXT. EVERY FILE HAS ITS OWN DESIGNATED FOLDER — USE IT ON EVERY PROMPT, NO EXCEPTIONS.
Rules that must never be broken:
- This is a feature-based architecture. Every domain file lives inside
src/features/<feature>/in its designated subfolder (schema/,repository/,service/,store/,hooks/,components/,validations/,types/). - Shared, cross-feature code goes in
src/shared/under its designated subfolder (components/,hooks/,lib/,middleware/,providers/,types/,utils/,const/). - Static constants and arrays go in
src/shared/const/<name>.const.ts. Never inline constants in page files. - Never create
_data/,_components/, or any underscore-prefixed folders anywhere. - Never create one-off utility files, helper files, or random folders outside the structure above.
- Before creating any file, identify which existing folder it belongs to and place it there.
A production-ready Next.js 16 starter using the App Router with TypeScript, Tailwind CSS, shadcn/ui, NextAuth v5, Mongoose, Zustand, and Zod.
Tech stack:
- Framework: Next.js 16 (App Router)
- Auth: NextAuth v5 (Google OAuth + Credentials, JWT sessions)
- Styling: Tailwind CSS + shadcn/ui
- Database: Mongoose (MongoDB ODM)
- Validation: Zod + react-hook-form + @hookform/resolvers
- State: Zustand (vanilla store + context pattern)
- Testing: Vitest
- Language: TypeScript (strict mode)
Features own all their domain code under src/features/<feature>/:
schema/- Mongoose schema, model, inferred typerepository/- DB access layer (only layer that imports Mongoose models)service/- business logic, always returns{ data, status }store/- Zustand vanilla store factoryhooks/- store hook (e.g.,useAuthStore.ts) + action hooks (e.g.,use-login.ts)components/- feature-specific React componentsvalidations/- Zod schemas and inferred typestypes/- TypeScript types for this feature
Shared code lives in src/shared/. Global type augmentations live in src/types/.
Static constants and arrays live in src/shared/const/<name>.const.ts. Never co-locate data files with pages. Never create _data/, _components/, or any underscore-prefixed folders anywhere in the project.
Never use interface. Always use type.
// correct
type User = { id: string; name: string };
// wrong
interface User { id: string; name: string }Never use unknown casts. Use proper schema types throughout the system.
Mongoose schema types use InferSchemaType extended with _id:
export type UserDocument = InferSchemaType<typeof UserSchema> & {
_id: mongoose.Types.ObjectId;
};Each feature defines its Mongoose schema, model, and inferred type in features/<feature>/schema/<name>.schema.ts. Always extend InferSchemaType with _id so downstream code never needs unknown casts.
import mongoose, { Schema, InferSchemaType } from 'mongoose';
const UserSchema = new Schema({ name: { type: String, required: true } }, { timestamps: true });
export type UserDocument = InferSchemaType<typeof UserSchema> & { _id: mongoose.Types.ObjectId };
export const UserModel = mongoose.models.User || mongoose.model('User', UserSchema);Route protection uses src/proxy.ts (not middleware.ts). It runs in the Edge Runtime and must NOT import any Node.js modules (crypto, mongoose, etc.) or the auth config. It only inspects the session cookie (authjs.session-token / __Secure-authjs.session-token) and redirects unauthenticated requests to /.
API routes are thin controllers only. Rules:
- Always use
validateBodyfrom@/shared/middleware/validate-body - Call the service, return JSON
- Always catch errors to 500
export async function POST(req: NextRequest) {
try {
const validated = await validateBody(req, SomeSchema);
if (validated instanceof NextResponse) return validated;
const result = await someService(validated.data);
return NextResponse.json(result.data, { status: result.status });
} catch {
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
}
}- Always return
{ data, status }(type:ServiceResult<T>from@/shared/types/common) - Never throw for handled cases, return error objects
- Call repository only. Never import Mongoose models directly in services.
export async function myService(input: InputType): Promise<ServiceResult<OutputType>> {
const item = await repository.findById(input.id);
if (!item) return { data: { error: 'NOT_FOUND' }, status: 404 };
return { data: item, status: 200 };
}- Only layer that imports Mongoose models.
- Call
await mongo.connect()at the start of every method. - Use
.lean()for read operations. - Never use
unknowncasts — use the feature'sDocumenttype directly. - Repositories only execute raw DB queries (
findOne,findById,find,create,updateOne,findByIdAndUpdate,findByIdAndDelete, etc.). No conditionals, no business decisions, no if/else logic based on data. That belongs in the service layer.
import { mongo } from '@/shared/lib/mongo';
import { UserDocument, UserModel } from '@/features/auth/schema/user.schema';
export const userRepository = {
async findById(id: string): Promise<UserDocument | null> {
await mongo.connect();
return UserModel.findById(id).lean() as Promise<UserDocument | null>;
},
async create(data: Omit<UserDocument, '_id' | 'createdAt' | 'updatedAt'>): Promise<string> {
await mongo.connect();
const doc = await UserModel.create(data);
return doc._id.toString();
},
};Zod schemas live in features/<feature>/validations/<feature>.validation.ts. Export the schema and inferred type.
import { z } from 'zod';
export const LoginSchema = z.object({ email: z.string().email(), password: z.string().min(8) });
export type LoginType = z.infer<typeof LoginSchema>;Three files per feature store:
features/<feature>/store/<feature>-store.ts- vanilla store factory usingcreateStorefromzustand/vanillafeatures/<feature>/hooks/use<Feature>Store.ts- context +useStorehookshared/providers/store-provider.tsx- combines all store contexts
All three files are required for each feature store.
Store hooks and action hooks are separate files:
useAuthStore.ts- reads/writes to store via contextuse-login.ts- action hook: handles async logic, updates store state
Never put async action logic in the store hook file.
Use react-hook-form with zodResolver. Never use manual state for forms.
const form = useForm<LoginType>({
resolver: zodResolver(LoginSchema),
defaultValues: { email: '', password: '' },
});All classes in shared/lib/ must use class syntax and export a singleton instance. Every file in shared/lib/ must have a co-located .spec.ts test file.
class MongoClientManager { ... }
export const mongo = new MongoClientManager();- Test files use
.spec.tsextension and are co-located with the source file. - Every file in
shared/lib/must have a.spec.ts— these are critical shared utilities. - In service tests, mock at the repository boundary. Never mock Mongoose models directly.
- Use
vi.mock('@/features/auth/repository/user.repository')(always@/alias, never relative../).
- Files: kebab-case (
use-login.ts,auth-store.ts) - Components: PascalCase (
LoginForm,Header) - Functions: camelCase (
loginService,fetchSessions) - Store hook files: PascalCase prefix (
useAuthStore.ts) - Constants: UPPER_SNAKE_CASE (
MAX_RETRIES)
- Always use
@/alias. Never use../for cross-directory imports. - Only
./(same directory) is allowed for relative imports.
For MongoDB ObjectId fields in Zod schemas, use .min(24).max(24):
userId: z.string().min(24).max(24)NextAuth uses JWT strategy with Google OAuth + Credentials providers. The jwt callback always fetches the user from DB to keep role/avatar fresh on every token refresh.
Never create next-auth.d.ts or any global NextAuth type augmentation files. Use inline local type SessionUser = { ... } casts wherever session user properties like id or role are needed.
OAuth users are persisted via userRepository.upsertOAuthUser(). passwordHash is optional (empty string for OAuth users).