By day 98, I've shipped enough features on production platforms to know that the first lines of code matter far more than the last ones. Setting up a solid Next.js starter isn't about adding every possible tool—it's about removing friction before your team writes business logic. This is what I configure before touching a single feature.
Foundation: TypeScript strict, env validation, path aliases, ESLint and Prettier
The foundation decides everything downstream. I always start with TypeScript in strict mode because the cost of catching a type error in development is infinitely lower than in production. Too many teams run TypeScript in loose mode and wonder why they're still chasing runtime errors.
Here's what goes in tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"moduleResolution": "bundler",
"module": "esnext",
"target": "es2020",
"lib": ["es2020", "dom", "dom.iterable"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/types/*": ["./src/types/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
Path aliases eliminate relative import chains like ../../../../lib/utils. Your IDE resolves them instantly, and refactoring becomes painless. I use @/* as the root and namespace subdirectories explicitly.
Environment variables require validation at runtime, not just build time. I use a simple env.ts:
// src/lib/env.ts
import { z } from 'zod';
const envSchema = z.object({
NEXT_PUBLIC_API_URL: z.string().url(),
DATABASE_URL: z.string().url(),
API_SECRET: z.string().min(20),
});
type Env = z.infer;
const env = (() => {
try {
return envSchema.parse(process.env);
} catch (error) {
console.error('Invalid environment variables:', error);
process.exit(1);
}
})();
export { env };
This catches misconfigured deployments immediately instead of silently failing hours later.
For linting and formatting, I configure ESLint with Next.js and TypeScript support, then let Prettier handle formatting. ESLint focuses on logic issues, Prettier on consistency. Here's a minimal ESLint config:
// .eslintrc.json
{
"extends": ["next/core-web-vitals", "prettier"],
"rules": {
"@next/next/no-html-link-for-pages": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
Run prettier --write . on save and you're done thinking about semicolons.
UI: Tailwind, cn helper, headless primitives, next/font
Tailwind CSS lets me move at the speed of design without maintaining a custom component library for six months. But raw Tailwind classes get unwieldy fast. I always create a cn utility for class composition:
// src/lib/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
clsx handles conditional classes, twMerge resolves Tailwind conflicts. This pattern surfaces everywhere:
// src/components/button.tsx
import { cn } from '@/lib/cn';
interface ButtonProps extends React.ButtonHTMLAttributes {
variant?: 'primary' | 'secondary';
}
export function Button({ variant = 'primary', className, ...props }: ButtonProps) {
return (
);
}
For complex interactions—modals, dropdowns, tooltips—I reach for Radix UI or Headless UI. These are unstyled, fully accessible primitives. You wire them with Tailwind. This separation means I control the DOM structure and styling, but inherit battle-tested accessibility logic.
Font loading is often overlooked until it causes layout shift. Next.js provides next/font to load fonts at build time and serve them as static assets:
// src/lib/fonts.ts
import { Inter, Fira_Mono } from 'next/font/google';
export const inter = Inter({ subsets: ['latin'] });
export const firaMono = Fira_Mono({ weight: '400', subsets: ['latin'] });
Then apply in your root layout and you've eliminated font flicker entirely.
Data: Data access layer, TanStack Query for client state, Zod schemas
I never let API calls live in React components. Instead, I build a thin data access layer that components import. This isolates business logic and makes testing straightforward:
// src/lib/api.ts
const API_URL = process.env.NEXT_PUBLIC_API_URL;
export async function fetchUser(id: string) {
const res = await fetch(`${API_URL}/users/${id}`, {
headers: { Authorization: `Bearer ${process.env.API_SECRET}` }
});
if (!res.ok) throw new Error(`User fetch failed: ${res.status}`);
return res.json();
}
export async function createPost(data: { title: string; body: string }) {
const res = await fetch(`${API_URL}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error('Post creation failed');
return res.json();
}
Then TanStack Query handles caching, refetching, and synchronization:
// src/hooks/use-user.ts
import { useQuery } from '@tanstack/react-query';
import { fetchUser } from '@/lib/api';
export function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
In components, you don't touch the network layer at all. This makes mocking trivial and keeps your component code focused on rendering.
For runtime validation, Zod schemas ensure API responses match what you expect:
// src/types/user.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
createdAt: z.string().datetime(),
});
export type User = z.infer;
// In your data layer
export async function fetchUser(id: string) {
const res = await fetch(`${API_URL}/users/${id}`);
if (!res.ok) throw new Error('User fetch failed');
const data = await res.json();
return UserSchema.parse(data); // Throws if shape is wrong
}
This catches API contract violations immediately and gives you type safety without any extra work.
Quality: Vitest, Testing Library, Playwright, error tracking
Testing isn't optional at scale. I configure three levels: unit tests (Vitest), component tests (Testing Library), and end-to-end tests (Playwright). Here's a unit test for the cn utility:
// src/lib/cn.test.ts
import { describe, it, expect } from 'vitest';
import { cn } from './cn';
describe('cn', () => {
it('merges classes', () => {
expect(cn('px-4', 'py-2')).toBe('px-4 py-2');
});
it('resolves tailwind conflicts', () => {
expect(cn('px-4', 'px-8')).toBe('px-8');
});
it('handles conditional classes', () => {
expect(cn('px-4', false && 'py-2')).toBe('px-4');
});
});
A component test with Testing Library:
// src/components/button.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Button } from './button';
describe('Button', () => {
it('renders with text', () => {
render(Click me);
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});
it('applies variant styles', () => {
const { container } = render(Test);
expect(container.querySelector('button')).toHaveClass('bg-gray-200');
});
});
And a Playwright E2E test for a critical user flow:
// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
For error tracking in production, I integrate Sentry or a similar service. This catches runtime errors your tests never will:
// src/instrumentation.ts (Next.js 13.4+)
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 1.0,
});
}
}
SEO: Metadata templates, sitemap.ts, robots.ts, OG images
SEO compounds over time. I set up metadata generation, sitemaps, and robots.txt from day one. Next.js 13+ makes this clean with file-based conventions.
Create metadata templates in your root layout:
// src/app/layout.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App',
},
description: 'A production-ready Next.js application',
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://myapp.com',
siteName: 'My App',
},
twitter: {
card: 'summary_large_image',
creator: '@myhandle',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
Generate a sitemap dynamically:
// src/app/sitemap.ts
import { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://myapp.com',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: 'https://myapp.com/about',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
];
}
And robots.txt:
// src/app/robots.ts
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: '/admin',
},
sitemap: 'https://myapp.com/sitemap.xml',
};
}
For Open Graph images, use the opengraph-image convention. You can generate them with Vercel's OG image library or serve static files. This ensures social shares look professional and drive clicks.
Common mistakes
- Skipping environment validation. Assuming your CI/CD always passes correct env vars is how staging and production break together. Always validate.
- Loose TypeScript with
anyeverywhere. Strict mode hurts initially, then saves hundreds of hours. Lean into it. - No data access layer. Letting each component fetch data independently creates duplicate requests, race conditions, and untestable code. Centralize it.
- Delaying testing setup. Adding tests later means rewriting code to be testable. Bake it in from day one—it actually speeds up feature development.
- Ignoring SEO metadata. By the time you realize your app isn't ranking, crawlers have cached old pages. Metadata templates take 20 minutes.
Key takeaways
- TypeScript strict mode + Zod runtime validation catches errors before production.
- Path aliases and a centralized data access layer eliminate import hell and keep your codebase navigable.
- Tailwind + headless UI primitives +
cngives you speed and control without maintaining a custom component library. - Three tiers of testing (unit, component, E2E) + error tracking covers both what you know to test and what you don't.
- SEO metadata, sitemaps, and robots.txt cost nothing when set up early and compound into real traffic.
- A solid starter isn't about having the most tools—it's about removing friction from the path between idea and production.






Comments
Loading comments…