Skip to content
Lantorian

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

NeedStandard choice
FrameworkNext.js 16 (App Router, Turbopack), React 19.2, strict TypeScript
InterfaceTailwind CSS 4 and shadcn/ui components copied into components/ui
Forms and validationZod 4 + React Hook Form, Server Actions
Translationsnext-intl 4, JSON files per language and per screen
DataServer-side fetch to the Laravel API, TanStack Query only for real-time client data
TestsVitest + Testing Library, Playwright for user journeys
QualityESLint (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.

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:
  1. Does it use useState, useEffect or another state hook?
Answer the questions or pick an example. The first "yes" decides.

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>
    </>
  );
}

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.tsx and other Next.js special files.
  • Typed props with a type Props above 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.