Next.js9 min read
Next.js project structure
The same skeleton on every front end: App Router, Server Components by default, code organized by feature. You know where to look before you even open the project.
Our front-end stack
| Need | Standard choice |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack), React 19.2, strict TypeScript |
| Interface | Tailwind CSS 4 and shadcn/ui components copied into components/ui |
| Forms and validation | Zod 4 + React Hook Form, Server Actions |
| Translations | next-intl 4, JSON files per language and per screen |
| Data | Server-side fetch to the Laravel API, TanStack Query only for real-time client data |
| Tests | Vitest + Testing Library, Playwright for user journeys |
| Quality | ESLint (flat config), Prettier, Husky + lint-staged |
The tree
The app folder only holds routing. Business code lives in features, one folder per feature, so everything about invoices sits in one place.
Hover a file to see its role. Purple files are the ones you'll open most.
Server or Client Component?
Every component is a Server Component by default: it runs on the server and sends no JavaScript to the browser. Only add "use client" when you must, on the smallest possible leaf.
Examples:
- Does it use useState, useEffect or another state hook?
Avoid
app/[locale]/(app)/invoices/page.tsx
'use client'; // the whole page ships to the browser
export default function InvoicesPage() {
const [invoices, setInvoices] = useState([]);
useEffect(() => {
fetch('/api/invoices') // waterfall after hydration
.then((r) => r.json())
.then(setInvoices);
}, []);
return <InvoiceTable invoices={invoices} />;
}Do
app/[locale]/(app)/invoices/page.tsx
// Server Component by default: no 'use client'
export default async function InvoicesPage() {
const invoices = await getInvoices(); // runs on the server
return (
<>
<InvoiceFilters /> {/* small client leaf */}
<InvoiceTable invoices={invoices} />
</>
);
}The pattern to copy
The server page reads searchParams, delegates loading to a component wrapped in Suspense, and only hydrates the interactive filter.
src/app/[locale]/(app)/invoices/page.tsx
import { getTranslations } from 'next-intl/server';
import { Suspense } from 'react';
import { InvoiceFilters } from '@/features/invoices/components/invoice-filters';
import { InvoiceTable } from '@/features/invoices/components/invoice-table';
import { InvoiceTableSkeleton } from '@/features/invoices/components/invoice-table.skeleton';
type Props = {
params: Promise<{ locale: string }>;
searchParams: Promise<{ status?: string; page?: string }>;
};
export default async function InvoicesPage({ searchParams }: Props) {
const { status, page } = await searchParams;
const t = await getTranslations('invoices');
return (
<>
<h1 className="text-2xl font-semibold">{t('title')}</h1>
<InvoiceFilters /> {/* client leaf: interactive */}
<Suspense key={`${status}-${page}`} fallback={<InvoiceTableSkeleton />}>
<InvoiceTable status={status} page={Number(page ?? 1)} /> {/* server: fetches */}
</Suspense>
</>
);
}src/features/invoices/components/invoice-filters.tsx
'use client';
import { useTranslations } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
const statuses = ['draft', 'sent', 'paid'] as const;
export function InvoiceFilters() {
const t = useTranslations('invoices.filters');
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const [pending, startTransition] = useTransition();
function select(status: string) {
const next = new URLSearchParams(params);
next.set('status', status);
next.delete('page');
startTransition(() => router.replace(`${pathname}?${next}`));
}
return (
<div role="group" aria-label={t('label')} aria-busy={pending}>
{statuses.map((s) => (
<button key={s} type="button" onClick={() => select(s)} aria-pressed={params.get('status') === s}>
{t(s)}
</button>
))}
</div>
);
}src/features/invoices/queries.ts
import 'server-only'; // build fails if a client component imports this file
import { apiFetch } from '@/lib/api-client';
import { invoiceListSchema } from '../schemas';
export async function getInvoices(params: { status?: string; page: number }) {
const query = new URLSearchParams({ page: String(params.page), ...(params.status && { status: params.status }) });
const json = await apiFetch(`/v1/invoices?${query}`, { next: { tags: ['invoices'] } });
return invoiceListSchema.parse(json);
}TypeScript and conventions
Strict mode catches mistakes before review. noUncheckedIndexedAccess forces you to handle a missing array item.
tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": false,
"paths": { "@/*": ["./src/*"] }
}
}- Named exports everywhere, except
page.tsx,layout.tsxand other Next.js special files. - Typed props with a
type Propsabove the component, no empty interfaces. - Absolute imports with
@/, never../../../. - No hard-coded colors: only theme Tailwind tokens (
bg-primary,text-muted-foreground). - Secrets are only read in server files. Only
NEXT_PUBLIC_variables reach the browser, and they are public.