https://github.com/hngprojects/open-profile-fe
https://github.com/hngprojects/open-profile-fe
Last synced: 12 days ago
JSON representation
- Host: GitHub
- URL: https://github.com/hngprojects/open-profile-fe
- Owner: hngprojects
- Created: 2026-05-13T02:14:31.000Z (about 1 month ago)
- Default Branch: dev
- Last Pushed: 2026-06-03T12:07:05.000Z (18 days ago)
- Last Synced: 2026-06-03T12:15:12.647Z (18 days ago)
- Language: TypeScript
- Size: 9.76 MB
- Stars: 1
- Watchers: 0
- Forks: 1
- Open Issues: 4
-
Metadata Files:
- Readme: README.md
- Agents: AGENTS.md
Awesome Lists containing this project
README
# Next.js Starter
Next.js 16 + React 19 + Tailwind v4 + shadcn (radix-maia). Validated env, typed proxy, and the standard set of route conventions wired up.
## Stack
- **Next.js 16** App Router (`proxy.ts`, `forbidden.tsx`, `unauthorized.tsx`)
- **React 19**, **TypeScript** (strict)
- **Tailwind v4** with shadcn `radix-maia` style
- **`@t3-oss/env-nextjs`** + **Zod 4** for build-time env validation
## Getting started
```bash
pnpm install
cp .env.example .env.local # fill in values
pnpm dev
```
Open .
## Scripts
| Command | What it does |
| ---------------- | -------------------------------- |
| `pnpm dev` | Dev server |
| `pnpm build` | Production build (validates env) |
| `pnpm start` | Run the production build |
| `pnpm lint` | ESLint |
| `pnpm typecheck` | `tsc --noEmit` |
## Environment variables
Schemas live in [`src/env/`](./src/env), split by side:
- [`src/env/server.ts`](./src/env/server.ts) — server-only vars. t3-env throws at runtime if a client component reads it.
- [`src/env/client.ts`](./src/env/client.ts) — `NEXT_PUBLIC_*` vars, safe everywhere.
Both are imported in [`next.config.ts`](./next.config.ts) so the build fails on any malformed value. Set `SKIP_ENV_VALIDATION=1` to bypass (Docker, lint-only CI).
| Var | Side | Required | Notes |
| ---------------------- | ------ | -------- | ------------------------------------- |
| `NODE_ENV` | server | auto | `development` / `test` / `production` |
| `API_BASE_URL` | server | optional | Upstream API for server-side `fetch` |
| `API_SECRET` | server | optional | Bearer token forwarded server-side |
| `NEXT_PUBLIC_APP_URL` | client | optional | Defaults to `http://localhost:3000` |
| `NEXT_PUBLIC_APP_NAME` | client | optional | Defaults to `Next Starter` |
Use it like:
```ts
// Server code (route handlers, Server Components, Server Actions)
import { env } from "@/env/server";
await fetch(`${env.API_BASE_URL}/users`, {
headers: { Authorization: `Bearer ${env.API_SECRET}` },
});
// Client code or shared metadata
import { env } from "@/env/client";
console.log(env.NEXT_PUBLIC_APP_URL);
```
## Proxy (`src/proxy.ts`)
Replaces the legacy `middleware.ts` (Next.js 16 renamed it). It runs before the cache and:
- Generates an `x-request-id` and forwards it to the request headers + response
- Sets baseline security headers (`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`)
- Skips static assets via the matcher
Add auth gating, rewrites, or redirects there as needed. Note: `runtime` config is **not** allowed in `proxy.ts` — it always runs on Node.js.
## Route conventions wired up
| File | Purpose |
| ----------------------------- | ---------------------------------------- |
| `src/app/loading.tsx` | Root suspense fallback |
| `src/app/error.tsx` | Client error boundary (`unstable_retry`) |
| `src/app/not-found.tsx` | 404 page |
| `src/app/forbidden.tsx` | 403 page (calls `forbidden()`) |
| `src/app/unauthorized.tsx` | 401 page (calls `unauthorized()`) |
| `src/app/robots.ts` | `/robots.txt` |
| `src/app/sitemap.ts` | `/sitemap.xml` |
| `src/app/api/health/route.ts` | Liveness probe at `GET /api/health` |
`forbidden.tsx` and `unauthorized.tsx` require `experimental.authInterrupts: true`, already enabled in [`next.config.ts`](./next.config.ts).
## API layer (`src/api`)
All outbound HTTP calls to the backend, their TypeScript types, and **TanStack React Query option objects** belong under [`src/api`](./src/api). Prefer one folder per feature (for example [`src/api/auth/`](./src/api/auth)), with naming like:
| File | Responsibility |
| -------------- | -------------------------------------------------- |
| `*.service.ts` | Thin functions that call `callApi` (or similar) |
| `*.type.ts` | Request/response DTO types |
| `*.options.ts` | `mutationOptions` / `queryOptions` for React Query |
Shared helpers (`callApi`, errors, staleness presets) live in [`src/api/base`](./src/api/base). For queries that depend on route params or other inputs, set `enabled` with [`isQueryEnabled`](./src/api/base/base.util.ts) **inside** the corresponding `*.options.ts` factory so components stay dumb.
### Client-side requests
In **Client Components** (or other browser code), do not call services directly for data that should be cached, deduped, or retried. Use **TanStack React Query** and import the option object from the feature’s `*.options.ts` file.
- **Mutations** — export a **constant** created with `mutationOptions` (no parameters needed for the option itself).
- **Queries** — export a **function** that returns `queryOptions(...)`, so callers can pass IDs, filters, or other inputs into `queryKey`, `queryFn`, and **`enabled`** (typically via **`isQueryEnabled`** so the query skips until inputs are ready).
Examples:
```tsx
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { loginOption, getCurrentUserOption } from "@/api/auth/auth.options";
// Mutation: pass the exported option directly
const login = useMutation(loginOption);
// Query: invoke the option factory
const userQuery = useQuery(getCurrentUserOption());
```
When a query should only run after required inputs exist, encode that in **the options factory**, not at each `useQuery` call site. Use **`isQueryEnabled`** (from [`base.util.ts`](./src/api/base/base.util.ts)): it accepts a string, optional primitive, or a record of param values:
```ts
import { fetchUser } from "@/api/users/user.service";
import { isQueryEnabled } from "@/api/base/base.util";
import { queryOptions } from "@tanstack/react-query";
export function fetchUserOption(userId: string | undefined) {
return queryOptions({
queryKey: ["users", userId],
enabled: isQueryEnabled(userId),
// `queryFn` runs only when `enabled` is true
queryFn: ({ signal }) => fetchUser(userId!, { signal }),
});
}
```
```tsx
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchUserOption } from "@/api/users/user.options"; // illustrative
type Props = { userId: string | undefined };
export function UserPanel({ userId }: Props) {
const query = useQuery(fetchUserOption(userId));
// ...
}
```
Keep `queryFn` implementations in services (`*.service.ts`); options files wire keys, staleness, `enabled` / `isQueryEnabled`, and `mutationFn` / `queryFn` to those functions.
## Project layout
```
src/
├── api/ # Backend API: services, types, React Query options
│ ├── base/ # callApi, shared types/errors, staleness utils
│ └── auth/ # Example feature slice
├── app/ # App Router routes & file conventions
│ └── api/health/ # Liveness probe
├── components/ui/ # shadcn components (added via `pnpm dlx shadcn@latest add ...`)
├── lib/utils.ts # cn() helper
├── env/
│ ├── server.ts # Server-only env schema
│ └── client.ts # NEXT_PUBLIC_* env schema
└── proxy.ts # Next.js 16 proxy (formerly middleware)
```