This chapter details PIF AI’s frontend technology choices and the design decisions behind them: why Next.js 15 App Router, how React Server Components cut bundle size, shadcn/ui’s “no-lock-in” philosophy, two-layer form validation (zod on client + Pydantic on server), and the 5-locale i18n dictionary-management discipline.
npm installed — no long-term lock-in| Candidate | Strengths | Weaknesses | PIF AI Fit |
|---|---|---|---|
| Next.js 15 App Router | RSC bundle savings, Vercel integration, native TypeScript, mature SEO | Learning curve (Server vs Client) | ✅ Chosen |
| Remix | Strong web-standards philosophy, simpler | Smaller ecosystem | ❌ |
| Pure SPA (Vite + React) | Simple deploy | Slow first paint, weak SEO, needs separate BFF | ❌ |
Main drivers:
The core of the App Router is React Server Components (RSC). By default all components render on the server; only those with the "use client" directive ship to the browser.
// src/app/(dashboard)/products/page.tsx — Server Component (no "use client")
import { db } from "@/lib/db";
export default async function ProductsPage() {
// Server-side DB query; no API round-trip
const products = await db.product.findMany(...);
return <ProductList products={products} />;
}
// src/app/(dashboard)/products/filter.tsx — Client (interactive)
"use client";
import { useState } from "react";
export function Filter() {
const [q, setQ] = useState("");
return <input value={q} onChange={(e) => setQ(e.target.value)} />;
}
[!TIP] Rule: Default Server, escalate to Client only when needed. Interactive components (forms, dropdowns, modals) get
"use client"; static presentation (tables, badges, text) stays Server.
For the dashboard home page:
| Strategy | JavaScript (gzipped) | First TTI |
|---|---|---|
| Traditional SPA (all Client) | ~ 280 KB | ~ 2.8s |
| Next.js 15 RSC (this project) | ~ 95 KB | ~ 1.2s |
Measured values on the same test rig (MacBook M2, Chrome 137, Fast 3G throttle) on 2026-04-10.
shadcn/ui differs from Material UI / Chakra UI:
It is not a component library. It is how you build your component library.
The CLI copies source code into your repo (src/components/ui/) rather than installing a package. Consequences:
shadcn/ui is effectively three technologies together:
Radix UI (behavior/accessibility)
+ Tailwind CSS (styling)
+ shadcn CLI (code copying)
= shadcn/ui
Radix handles complex keyboard, focus management, ARIA; Tailwind handles visuals; you get fully controllable source.
| Component | Use |
|---|---|
Button |
site-wide button base |
Input, Textarea, Select |
form elements |
Dialog, Sheet |
build modals, SA review |
Table, DataTable |
product list, toxicology table |
Tabs, Accordion |
PIF 16-item sectioning |
Toast, Alert |
upload status |
Tooltip |
satisfies the constitutional “ZH+EN + tooltip” requirement |
PIF forms are complex (product has 8 fields, formulation is multi-row, test-report parsing has many parameters). We use four layers of validation:
flowchart LR
U[User input]
FE1["① Client zod schema<br/>real-time"]
FE2["② react-hook-form<br/>field state"]
BE1["③ Server Pydantic<br/>schema validation"]
BE2["④ DB CHECK constraints<br/>final gate"]
DB[(Database)]
U --> FE1 --> FE2
FE2 -.POST.-> BE1 --> BE2 --> DB
Figure 5.1: Four layers divided by role: (1) client real-time feedback; (2) form state on submit; (3) server authoritative validation; (4) DB-level final gate (e.g., CHECK (pif_status IN (...))). Client validation does not replace server validation — it is UX acceleration only.
// src/lib/schemas/product.ts
import { z } from "zod";
export const ProductCreateSchema = z.object({
name: z.string().min(1).max(500),
name_en: z.string().max(500).optional(),
category: z.enum([
"sunscreen", "hair_dye", "baby", "lip", "eye", "oral", "general"
]),
dosage_form: z.string().max(100).optional(),
intended_use: z.string().max(2000).optional(),
manufacturer_name: z.string().max(500).optional(),
registration_id: z.string()
.regex(/^衛?部粧製字第\d+號$/, "Invalid TFDA registration format")
.optional(),
});
export type ProductCreate = z.infer<typeof ProductCreateSchema>;
The server-side Pydantic schema at app/schemas/product.py parallels but does not re-use this — two independent sources of truth. Rules should stay consistent, though. Future refactor may auto-generate both from an OpenAPI spec.
zh-TW, default), English (en), Japanese (ja), Korean (ko), French (fr)localStorage across sessions// src/lib/i18n/index.tsx
export type Locale = "zh-TW" | "en" | "ja" | "ko" | "fr";
const translations: Record<Locale, Record<string, Record<string, string>>> = {
"zh-TW": zhTW, en, ja, ko, fr,
};
export function I18nProvider({ children }) {
const [locale, setLocaleState] = useState<Locale>("zh-TW");
// ... localStorage hydration + setter
const t = (key: string) => {
const [section, ...rest] = key.split(".");
return translations[locale]?.[section]?.[rest.join(".")] ?? key;
};
return <I18nContext.Provider value=>
{children}
</I18nContext.Provider>;
}
Usage: const { t } = useI18n(); <button>{t("common.login")}</button>
Each locale has its own JSON:
// src/lib/i18n/en.json (excerpt)
{
"common": {
"login": "Login", "register": "Register", "logout": "Logout", ...
},
"pifBuilder": {
"title": "PIF Builder", ...
}
}
All five locale files share the same structure: 17 sections × 423 keys. A Node script verifies key parity during CI.
Pages under src/app/(admin)/super-admin/* deliberately do not call useI18n(). Reasons:
This is a design decision, not an oversight. When adding super-admin strings, write them directly in Chinese — do not introduce useI18n().
Formulation files, test reports can be tens of megabytes. Proxying through the backend costs memory, bandwidth, and introduces upload stalls.
We use pre-signed URLs for direct upload:
sequenceDiagram
participant F as Frontend
participant B as Backend
participant S as S3 / R2
F->>B: POST /api/files/presign<br/>{filename, content_type, product_id}
B->>B: verify JWT + ACL
B->>S: GenerateUploadURL (15min expire)
S-->>B: pre-signed URL
B-->>F: presigned URL + file_key
F->>S: PUT (direct upload)
S-->>F: 200 OK
F->>B: POST /api/files/finalize {file_key}
B->>B: INSERT uploaded_files
B-->>F: 201 + file_id
Figure 5.2: Backend signs a one-time URL and records the final metadata; bytes never pass through backend bandwidth. Formulation encryption uses S3 server-side encryption (SSE-C) + application-layer AES-256 (double layer). See §11.
| Version | Date | Summary |
|---|---|---|
| v0.1 | 2026-04-19 | First draft. Next.js 15 RSC, shadcn/ui, two-layer form validation, 5-locale i18n |
© 2026 Baiyuan Tech. Licensed under CC BY-NC 4.0.