Why Code Quality in Boilerplates Matters More Than Features (2026)
TL;DR
A feature-rich boilerplate with poor code quality is a technical debt bomb. You'll ship faster initially, then spend 3x more time refactoring, debugging, and unraveling bad patterns. Code quality signals — type safety, error handling, testing, security — predict how painful your first 3 months will be. Features can be added; bad architecture is expensive to fix.
Key Takeaways
- Type safety in the codebase predicts type safety in your additions
- Error handling patterns propagate — if the boilerplate swallows errors, you will too
- Security patterns are contagious — bad webhook handling = you'll copy it
- Test absence = test absence — teams that buy no-test boilerplates rarely add tests
- Epic Stack has the highest code quality bar in the free ecosystem
Why You Copy What You See
The most underrated aspect of boilerplates: you copy their patterns.
When you add a new API route, you look at existing API routes. When you add a new model, you look at existing models. If those examples are good, you write good code. If they're bad, you propagate the badness.
// Low-quality boilerplate pattern
// routes/api/users.ts
export default async function handler(req, res) {
const data = await db.query('SELECT * FROM users WHERE id = ' + req.query.id)
res.json(data)
}
// When you add a new route, you copy this pattern:
// routes/api/projects.ts (YOUR code, following the pattern)
export default async function handler(req, res) {
const data = await db.query('SELECT * FROM projects WHERE user_id = ' + req.query.userId)
res.json(data)
}
// Now YOU have SQL injection in your routes
You didn't introduce bad patterns — you inherited them and didn't know better.
Signal 1: TypeScript Discipline
The difference between TypeScript as safety vs TypeScript as decoration:
// TypeScript as decoration (RED FLAG)
// @ts-ignore everywhere
// any types hiding errors
// Non-null assertions (!) hiding null issues
async function updateUser(id: any, data: any): Promise<any> {
// @ts-ignore
return db.users.update(id, data);
}
// TypeScript as safety (GREEN FLAG)
import { z } from 'zod';
import type { Prisma } from '@prisma/client';
const UpdateUserInput = z.object({
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
});
async function updateUser(
id: string,
input: z.infer<typeof UpdateUserInput>
): Promise<Prisma.UserGetPayload<{ select: { id: true; name: true; email: true } }>> {
const validated = UpdateUserInput.parse(input); // Throws if invalid
return db.user.update({
where: { id },
data: validated,
select: { id: true, name: true, email: true },
});
}
The second version catches bugs at compile time and validates at runtime. The first version hides them until production.
Signal 2: Error Handling Discipline
// Poor error handling (propagates in your codebase)
async function createSubscription(userId: string, planId: string) {
const result = await stripe.subscriptions.create({ /* ... */ });
return result; // No error handling — exceptions bubble up as 500
}
// Good error handling
export class PaymentError extends Error {
constructor(
message: string,
public code: 'card_declined' | 'insufficient_funds' | 'generic',
public stripeCode?: string
) {
super(message);
this.name = 'PaymentError';
}
}
async function createSubscription(userId: string, planId: string) {
try {
const result = await stripe.subscriptions.create({ /* ... */ });
return { success: true, subscription: result };
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError) {
throw new PaymentError(
err.message,
err.code === 'insufficient_funds' ? 'insufficient_funds' : 'card_declined',
err.code
);
}
// Log unexpected errors before re-throwing
logger.error('Unexpected Stripe error', { userId, planId, error: err });
throw new PaymentError('Payment processing failed', 'generic');
}
}
Boilerplates that handle errors correctly teach you to handle errors correctly.
Signal 3: Security Patterns
The security pattern you see in auth, you'll copy in your product:
// Poor authorization pattern (often copied)
export async function GET(req: Request) {
const { userId } = getSession(req);
const document = await db.document.findUnique({
where: { id: req.params.id },
});
return Response.json(document); // Returns document regardless of ownership!
}
// Correct authorization pattern
export async function GET(
req: Request,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session) return new Response('Unauthorized', { status: 401 });
const document = await db.document.findUnique({
where: {
id: params.id,
userId: session.user.id, // Enforces ownership in the query
},
});
if (!document) return new Response('Not found', { status: 404 });
// Returns 404 for both "not found" and "not authorized" (prevents enumeration)
return Response.json(document);
}
The first pattern creates IDOR (Insecure Direct Object Reference) vulnerabilities everywhere in your product — because that's the pattern you copied.
The Epic Stack Standard
Epic Stack sets the highest code quality bar in the free boilerplate ecosystem:
What makes it exceptional:
- 60+ tests: Auth, profile, billing — the critical paths are tested
- TypeScript strict:
strict: truein tsconfig — no implicit any - Zod everywhere: Runtime validation on all inputs
- Proper error boundaries: React error boundaries + logger integration
- Security review: Community security audit on core auth
// Epic Stack error handling in routes
// app/routes/settings+/profile.change-password.tsx
export const action = async ({ request }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const formData = await request.formData();
await validateCSRF(formData, request.headers);
const submission = await parseWithZod(formData, {
schema: ChangePasswordFormSchema.superRefine(
async ({ currentPassword, newPassword }, ctx) => {
if (!currentPassword || !newPassword) return;
const user = await verifyUserPassword({ id: userId }, currentPassword);
if (!user) {
ctx.addIssue({
path: ['currentPassword'],
code: z.ZodIssueCode.custom,
message: 'Incorrect password.',
});
}
}
),
async: true,
});
if (submission.status !== 'success') {
return json(submission.reply({ hideFields: ['currentPassword', 'newPassword'] }), {
status: submission.status === 'error' ? 400 : 200,
});
}
await updateUserPassword({ userId, password: submission.value.newPassword });
return redirectWithToast(`/settings/profile`, { type: 'success', title: 'Password Updated' });
};
This is how production-quality auth route handlers look. Most boilerplates don't come close.
Code Quality by Boilerplate
| Boilerplate | TypeScript | Error Handling | Security | Testing |
|---|---|---|---|---|
| Epic Stack | Strict | Excellent | Excellent | 60+ tests |
| Makerkit | Strict | Good | Good | Basic |
| T3 Stack | Strict | Good | Good | None |
| ShipFast | Good | Acceptable | Good | None |
| Supastarter | Good | Good | Good | None |
| Budget options | Varies | Poor-Acceptable | Poor-Good | None |
Making the Right Trade-Off
Features vs quality is a false choice for most use cases. Makerkit and Supastarter have both: comprehensive features AND good code quality.
The real decision:
- Choose Epic Stack if code quality and tests matter more than features (serious products with engineering culture)
- Choose Makerkit/Supastarter if you need features + quality (most B2B SaaS)
- Avoid boilerplates scoring low on both features AND quality (bad value at any price)
Compare code quality indicators across boilerplates on StarterPick.
Check out this boilerplate
View Epic Stack on StarterPick →