# Ekx Nextjs App

> The Ekinoxis Next.js App Router conventions used across 18 projects — folder layout, server vs client components, server actions, route handlers, data fetching and caching, Tailwind v4 setup, and the version differences between Next 14, 15 and 16 in our portfolio. Use when starting a new app, adding a route or API endpoint, deciding where code should run, or debugging hydration, caching or "use client" boundary errors.

- Skill: `ekinoxis-evm/ekx-nextjs-app` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ekinoxis-evm/ekx-nextjs-app`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ekinoxis-evm/ekx-nextjs-app/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Ekinoxis-evm (https://skillmd.com/u/ekinoxis-evm)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ekinoxis-evm/ekx-nextjs-app

---


# Next.js (App Router)

18 of 26 projects. Versions in play span **14 → 16**, and the APIs differ in ways that
break silently. Check `package.json` before applying any pattern, and ask the
`context7` MCP server rather than answering from memory on version-specific APIs.

| Version | Where |
|---|---|
| 14 | one legacy marketing site |
| 15 | most projects |
| 16 | the newest storefronts |

---

## Layout

```
app/
  layout.tsx              # root: fonts, providers, metadata
  page.tsx
  (marketing)/            # route group — no URL segment
  dashboard/
    layout.tsx
    page.tsx
    loading.tsx           # instant Suspense fallback
    error.tsx             # 'use client' — error boundary
  api/
    webhooks/stripe/route.ts
components/
  ui/                     # primitives
lib/
  supabase/{client,server,admin}.ts
  utils.ts                # cn()
```

---

## Server by default

Every component is a Server Component unless it says `"use client"`. Push the boundary
**down**, not up: a page that needs one interactive button should not become a client
component wholesale.

```tsx
// app/dashboard/page.tsx  — server
export default async function Page() {
  const supabase = await createClient();
  const { data } = await supabase.from("bookings").select("*");
  return <BookingTable rows={data ?? []} />;   // table can be a client component
}
```

Rules that bite:
- Server Components can't use hooks, `onClick`, or browser APIs.
- Client Components can't be `async`.
- Props crossing the boundary must be serialisable — no functions, no `bigint`, no class instances. **`bigint` is the one that catches us**, coming back from viem. Convert with `.toString()` before passing down.
- A `"use client"` file makes everything it *imports* client too. Importing your Supabase admin client there ships the service-role key to the browser — hence `import "server-only"` in [`../ekx-supabase/SKILL.md`](../ekx-supabase/SKILL.md).

---

## Server actions vs route handlers

| Use | For |
|---|---|
| **Server action** | Form submits and mutations from our own UI. Less boilerplate, typed end to end. |
| **Route handler** | Webhooks, anything a third party calls, anything needing a raw body or custom status. |

```tsx
// server action
"use server";
export async function createBooking(formData: FormData) {
  const user = await requireUser();                 // ALWAYS re-authorise here
  await admin.from("bookings").insert({ ... });
  revalidatePath("/dashboard");
}
```

**A server action is a public HTTP endpoint.** It is not protected by being called
from a protected page — anyone can invoke it. Authorise inside every one.

---

## Caching — the version trap

Next 14/15 cache `fetch` aggressively by default; Next 16 with Cache Components
changes the model again. Be explicit rather than relying on defaults:

```ts
export const dynamic = "force-dynamic";   // never cache this route
export const revalidate = 60;             // ISR: 60s
const res = await fetch(url, { cache: "no-store" });
```

Supabase queries are not `fetch` and are never cached by Next — but the *page* they
render can be statically rendered at build time and go stale. On any page showing
per-user or live data, set `dynamic = "force-dynamic"` or read `cookies()` (which
opts the route into dynamic rendering automatically).

---

## Tailwind v4

Nine projects are on v4, which drops `tailwind.config.js` in favour of CSS:

```css
/* app/globals.css */
@import "tailwindcss";
@theme {
  --color-brand: #ff7a45;
  --font-display: "Chakra Petch", sans-serif;
}
```

```js
// postcss.config.mjs
export default { plugins: { "@tailwindcss/postcss": {} } };
```

v3 projects keep the JS config. Do not mix — the `@tailwindcss/postcss` plugin and a
`tailwind.config.js` in the same repo produce styles that work in dev and vanish in
the production build.

`cn()` helper (in every repo, should be a package):

```ts
export const cn = (...i: ClassValue[]) => twMerge(clsx(i));
```

---

## Environment variables

`NEXT_PUBLIC_*` is inlined into the client bundle **at build time**. Two consequences:

1. Changing one in Vercel requires a **redeploy**, not just a restart.
2. Anything secret must not carry the prefix.

---

## Gotchas

1. **`cookies()`/`headers()` are async** in 15+. `await` them.
2. **`params` and `searchParams` are Promises** in 15+.
3. **`bigint` across the client boundary** throws at runtime, not build.
4. **Server actions are public endpoints.** Authorise inside.
5. **Hydration mismatch** from `Date`, `Math.random`, or `localStorage` during render — move to `useEffect`.
6. **Stale `NEXT_PUBLIC_`** after an env change without redeploy.
7. **Mixing Tailwind v3 config with v4 plugin.**

