Next.js & React for Grit Developers
A focused guide to the React and Next.js concepts you need to build with Grit. If you know another language or framework but are new to the React ecosystem, this page covers everything from components and hooks to data fetching and validation -- the building blocks behind Grit's admin panel and web app.
React Basics
React components are JavaScript functions that return JSX -- a syntax that looks like HTML but compiles to JavaScript. Every piece of UI in a React app is a component. Components accept props (input data) and can render other components as children. You handle events with inline functions and conditionally render elements using JavaScript expressions like && and ternaries.
// A React component is just a function that returns JSXinterface GreetingProps {name: string;role?: string; // optional propchildren?: React.ReactNode;}function Greeting({ name, role = "user", children }: GreetingProps) {return (<div className="p-4 rounded-lg border"><h2>Hello, {name}!</h2>{role === "admin" && <span className="text-red-500">Admin</span>}{children}</div>);}// Usage<Greeting name="Alice" role="admin"><p>Welcome back.</p></Greeting>// Event handlingfunction Counter() {const handleClick = (e: React.MouseEvent) => {console.log("Clicked!", e.target);};return <button onClick={handleClick}>Click me</button>;}
In Grit
Every file in app/ and components/ is a React component. Pages like page.tsx export a default component for the route. Reusable UI pieces live in components/ and are imported wherever needed. Grit generates both page components and shared components when you scaffold a project.
TypeScript Essentials
TypeScript adds static types to JavaScript, catching bugs at compile time instead of runtime. Use interface to describe object shapes and type for unions, intersections, and computed types. Generics let you write reusable typed code (like ApiResponse<T>). TypeScript infers types wherever possible, so you don't need to annotate everything -- but explicit types on function parameters and return values make your code self-documenting.
// Interface for object shapesinterface User {id: number;name: string;email: string;role: "ADMIN" | "EDITOR" | "USER"; // union typeavatar?: string; // optional}// Type for unions and computed typestype Status = "active" | "inactive" | "banned";type UserRole = User["role"]; // extracts "ADMIN" | "EDITOR" | "USER"// Genericsinterface ApiResponse<T> {data: T;message: string;}// Component props with TypeScriptinterface UserCardProps {user: User;onEdit: (id: number) => void;variant?: "compact" | "full";}function UserCard({ user, onEdit, variant = "full" }: UserCardProps) {return (<div onClick={() => onEdit(user.id)}><span>{user.name}</span>{variant === "full" && <span>{user.email}</span>}</div>);}// Record type for key-value mapsconst statusColors: Record<Status, string> = {active: "text-green-500",inactive: "text-gray-500",banned: "text-red-500",};
In Grit
All Grit code is TypeScript. Shared types live in packages/shared/src/types/ and are generated from your Go models by grit sync. Both the admin panel and web app import these types, ensuring your frontend always matches your backend data structures.
useState & useEffect
React hooks are functions that let components have state and side effects. useState declares a piece of reactive state -- when you call the setter function, React re-renders the component with the new value. useEffect runs side effects like API calls, subscriptions, or timers. The dependency array controls when the effect re-runs, and the cleanup function handles teardown.
"use client";import { useState, useEffect } from "react";function UserProfile({ userId }: { userId: number }) {// useState: declare reactive stateconst [user, setUser] = useState<User | null>(null);const [loading, setLoading] = useState(true);const [count, setCount] = useState(0);// useEffect: run side effects (API calls, subscriptions, etc.)useEffect(() => {// This runs when userId changessetLoading(true);fetch(`/api/users/${userId}`).then((res) => res.json()).then((data) => {setUser(data.data);setLoading(false);});// Cleanup function (runs before re-run or unmount)return () => {console.log("Cleaning up previous effect");};}, [userId]); // dependency array: re-run when userId changes// [] = run once on mount// no array = run every render (avoid this)if (loading) return <div>Loading...</div>;return (<div><h1>{user?.name}</h1><p>Count: {count}</p><button onClick={() => setCount((prev) => prev + 1)}>Increment</button></div>);}
In Grit
Grit uses useState throughout the admin panel for local UI state (search filters, pagination, modal visibility) and in the web app for form inputs and toggles. You rarely need raw useEffect for data fetching because React Query handles that, but it's still used for things like keyboard shortcuts and resize listeners.
Next.js App Router
Next.js uses file-based routing: folders inside app/ become URL routes automatically. A page.tsx file makes a route accessible, layout.tsx wraps child pages with shared UI (like a sidebar), and loading.tsx shows a loading state. Route groups with parentheses like (auth) organize routes without adding a URL segment. Dynamic routes use square brackets like [id].
# Next.js App Router maps folders to URL routes:## app/# ├── page.tsx → /# ├── layout.tsx → wraps all pages# ├── loading.tsx → loading UI for /# ├── not-found.tsx → 404 page# ├── (auth)/ → route group (no URL segment)# │ ├── login/page.tsx → /login# │ └── register/page.tsx → /register# ├── (dashboard)/ → route group with its own layout# │ ├── layout.tsx → dashboard layout (sidebar, navbar)# │ ├── page.tsx → / (dashboard home)# │ └── users/# │ ├── page.tsx → /users# │ └── [id]/# │ └── page.tsx → /users/123 (dynamic route)# └── api/ → API routes (not used in Grit)# Key files:# - page.tsx → the UI for a route (required to make a route accessible)# - layout.tsx → shared UI that wraps child pages (persists across navigation)# - loading.tsx → loading state shown while page data loads# - error.tsx → error boundary for a route segment
In Grit
Both apps/web and apps/admin use the App Router exclusively. The admin app uses a (dashboard) route group with a shared layout containing the sidebar and navbar. Auth pages sit in an (auth) group with a minimal layout. When you run grit generate resource, it creates new page files inside the appropriate route group.
Server vs Client Components
In the App Router, components are server components by default. They run on the server, can be async, and can fetch data directly -- but they cannot use hooks or browser APIs. Add "use client" at the top of a file to make it a client component, which runs in the browser and can use useState, useEffect, event handlers, and everything interactive.
// SERVER COMPONENT (default - no directive needed)// Can: fetch data, access DB, read files, use async/await// Cannot: use useState, useEffect, event handlers, browser APIsasync function UsersPage() {const res = await fetch("http://localhost:8080/api/users");const data = await res.json();return (<div><h1>Users</h1><UserList users={data.data} /></div>);}// CLIENT COMPONENT (needs "use client" directive)// Can: use hooks, event handlers, browser APIs, interactivity// Cannot: be async, directly access server resources"use client";import { useState } from "react";function UserList({ users }: { users: User[] }) {const [search, setSearch] = useState("");const filtered = users.filter((u) =>u.name.toLowerCase().includes(search.toLowerCase()));return (<div><inputvalue={search}onChange={(e) => setSearch(e.target.value)}placeholder="Search users..."/>{filtered.map((user) => (<div key={user.id}>{user.name}</div>))}</div>);}
In Grit
In Grit, page-level components are typically server components that render the page shell. Interactive parts like DataTable, FormBuilder, and any component using React Query hooks are marked "use client". The general rule: keep server components for layout and structure, push interactivity down to the smallest client component possible.
Tailwind CSS & shadcn/ui
Tailwind CSS is a utility-first framework where you style elements by composing small CSS classes directly in your JSX. Instead of writing margin-top: 1rem in a CSS file, you write mt-4 on the element. Responsive design uses breakpoint prefixes (sm:, md:, lg:). shadcn/ui provides pre-built, accessible components (Button, Card, Dialog, Input) that use Tailwind under the hood and can be customized by editing the source files directly.
// Tailwind uses utility classes directly in JSXfunction ProductCard({ product }: { product: Product }) {return (<div className="rounded-xl border border-border bg-card p-6hover:bg-accent/10 transition-colorssm:p-4 md:p-6 lg:p-8">{/* Responsive: sm/md/lg prefixes */}<h3 className="text-lg font-semibold text-foregroundsm:text-base md:text-lg">{product.name}</h3><p className="text-sm text-muted-foreground mt-2">{product.description}</p><span className="text-primary font-bold text-xl mt-4 block">${product.price}</span></div>);}// shadcn/ui components (pre-built, customizable)import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog";function Example() {return (<Card><CardHeader><CardTitle>Create User</CardTitle></CardHeader><CardContent className="space-y-4"><Input placeholder="Name" /><Input placeholder="Email" type="email" /><Button variant="default">Save</Button><Button variant="outline">Cancel</Button><Button variant="destructive">Delete</Button></CardContent></Card>);}
In Grit
All styling in Grit is Tailwind -- there are no custom CSS files except for the base globals.css which defines CSS variables for the dark theme. UI components come from shadcn/ui and are scaffolded into components/ui/. You can customize any shadcn component by editing it directly since it's your own code, not a node_modules dependency.
React Query (TanStack Query)
React Query is the data fetching layer in Grit. useQuery fetches and caches server data with automatic background refetching, stale-time management, and loading/error states. useMutation handles create, update, and delete operations with callbacks like onSuccess to invalidate caches. The QueryClientProvider at the root of the app provides the cache to all components.
"use client";import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";import { apiClient } from "@/lib/api-client";// Fetching data with useQueryfunction usePosts(page: number = 1) {return useQuery({queryKey: ["posts", { page }], // unique cache keyqueryFn: async () => {const { data } = await apiClient.get(`/api/posts?page=${page}`);return data; // { data: Post[], meta: {...} }},staleTime: 30 * 1000, // data is fresh for 30 seconds});}// Creating data with useMutationfunction useCreatePost() {const queryClient = useQueryClient();return useMutation({mutationFn: async (newPost: CreatePostInput) => {const { data } = await apiClient.post("/api/posts", newPost);return data;},onSuccess: () => {// Invalidate the cache so the list refetchesqueryClient.invalidateQueries({ queryKey: ["posts"] });},});}// Using in a componentfunction PostsPage() {const { data, isLoading, error } = usePosts(1);const createPost = useCreatePost();if (isLoading) return <div>Loading...</div>;if (error) return <div>Error: {error.message}</div>;return (<div><button onClick={() => createPost.mutate({ title: "New Post" })}>Add Post</button>{data?.data.map((post: Post) => (<div key={post.id}>{post.title}</div>))}</div>);}
In Grit
All data fetching in Grit goes through React Query hooks stored in hooks/. When you run grit generate resource, it creates a complete hook file with useList, useGet, useCreate, useUpdate, and useDelete hooks. Components never call fetch or axios directly.
Zod Validation
Zod is a TypeScript-first schema validation library. You define the shape and constraints of your data with a fluent API, then use z.infer to extract the TypeScript type from the schema -- so your validation rules and types are always in sync. Zod supports strings, numbers, booleans, enums, arrays, nested objects, optional fields, and custom validation messages. Use safeParse for error handling without exceptions.
import { z } from "zod";// Define a schemaconst CreateUserSchema = z.object({name: z.string().min(2, "Name must be at least 2 characters"),email: z.string().email("Invalid email address"),age: z.number().min(18, "Must be 18 or older").optional(),role: z.enum(["ADMIN", "EDITOR", "USER"]),isActive: z.boolean().default(true),tags: z.array(z.string()).optional(),});// Extract the TypeScript type from the schematype CreateUserInput = z.infer<typeof CreateUserSchema>;// Result:// {// name: string;// email: string;// age?: number;// role: "ADMIN" | "EDITOR" | "USER";// isActive: boolean;// tags?: string[];// }// Validate dataconst result = CreateUserSchema.safeParse({name: "Alice",email: "alice@example.com",role: "ADMIN",});if (result.success) {console.log(result.data); // typed as CreateUserInput} else {console.log(result.error.issues);// [{ path: ["email"], message: "Invalid email address" }]}
In Grit
Zod schemas live in packages/shared/src/schemas/ and are shared across the admin panel and web app. When you run grit generate resource, it creates Zod schemas matching your Go model fields. The grit sync command can regenerate schemas from Go types to keep everything aligned.
React Hook Form
React Hook Form manages form state, validation, and submission with minimal re-renders. The useForm hook returns register (connects inputs), handleSubmit (validates before calling your handler), and formState (errors, submission state). Combined with the Zod resolver, your form validation uses the same schemas you already defined -- no duplicate validation logic.
"use client";import { useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import { z } from "zod";const LoginSchema = z.object({email: z.string().email("Invalid email"),password: z.string().min(8, "Password must be at least 8 characters"),});type LoginInput = z.infer<typeof LoginSchema>;function LoginForm() {const {register, // connects inputs to the formhandleSubmit, // wraps your submit handler with validationformState: { errors, isSubmitting },} = useForm<LoginInput>({resolver: zodResolver(LoginSchema), // Zod validates on submit});const onSubmit = async (data: LoginInput) => {// data is fully validated and typedconst response = await fetch("/api/auth/login", {method: "POST",body: JSON.stringify(data),});};return (<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"><div><label>Email</label><input {...register("email")} type="email" className="input" />{errors.email && (<span className="text-red-500 text-sm">{errors.email.message}</span>)}</div><div><label>Password</label><input {...register("password")} type="password" className="input" />{errors.password && (<span className="text-red-500 text-sm">{errors.password.message}</span>)}</div><button type="submit" disabled={isSubmitting}>{isSubmitting ? "Signing in..." : "Sign In"}</button></form>);}
In Grit
Grit's admin panel includes a FormBuilder component that wraps React Hook Form with Zod validation, supporting 8 field types (text, textarea, number, select, date, toggle, checkbox, radio). For simple forms or custom requirements, you can use React Hook Form directly. The profile page and auth pages both use raw React Hook Form with Zod resolvers.
Custom Hooks
Custom hooks are functions starting with use that extract reusable logic from components. They can call other hooks, manage state, handle side effects, and return any values. Create a custom hook when: the same data-fetching logic appears in multiple components, a component's hook logic becomes complex, or you want to separate data concerns from UI rendering. Name files with the use- prefix in kebab-case.
// hooks/use-users.ts"use client";import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";import { apiClient } from "@/lib/api-client";import { User, CreateUserInput } from "@shared/types";// Encapsulate all user-related data fetching in one hook fileexport function useUsers(params?: {page?: number;search?: string;role?: string;}) {return useQuery({queryKey: ["users", params],queryFn: async () => {const { data } = await apiClient.get("/api/users", { params });return data;},});}export function useUser(id: number) {return useQuery({queryKey: ["users", id],queryFn: async () => {const { data } = await apiClient.get(`/api/users/${id}`);return data.data as User;},enabled: !!id, // only fetch if id exists});}export function useCreateUser() {const queryClient = useQueryClient();return useMutation({mutationFn: async (input: CreateUserInput) => {const { data } = await apiClient.post("/api/users", input);return data;},onSuccess: () => {queryClient.invalidateQueries({ queryKey: ["users"] });},});}// When to make a custom hook:// 1. Logic is used in 2+ components// 2. A component has complex state/effect logic// 3. You want to separate data fetching from UI
In Grit
Grit generates resource-specific hooks like hooks/use-users.ts and hooks/use-posts.ts. The admin panel also has hooks/use-resource.ts (a generic hook powered by resource definitions) and hooks/use-system.ts for system pages. Following the convention of one hook file per data domain keeps your codebase organized.
API Client & Folder Structure
The API client is an Axios instance with a base URL pointing to the Go backend and interceptors that automatically attach JWT tokens to every request and redirect to the login page on 401 responses. All hooks import this shared client, so authentication is handled in one place. The frontend follows a clear folder structure:app/ for routes, components/ for UI, hooks/ for data logic, and lib/ for utilities.
// lib/api-client.tsimport axios from "axios";export const apiClient = axios.create({baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080",headers: { "Content-Type": "application/json" },});// Automatically attach JWT token to every requestapiClient.interceptors.request.use((config) => {const token = localStorage.getItem("token");if (token) {config.headers.Authorization = `Bearer ${token}`;}return config;});// Handle 401 responses (expired token)apiClient.interceptors.response.use((response) => response,(error) => {if (error.response?.status === 401) {localStorage.removeItem("token");window.location.href = "/login";}return Promise.reject(error);});// Folder structure overview://// apps/web/ (or apps/admin/)// ├── app/ → Routes (pages, layouts, loading states)// │ ├── (auth)/ → Auth pages (login, register)// │ ├── (dashboard)/ → Dashboard pages with shared layout// │ └── layout.tsx → Root layout (providers, fonts)// ├── components/ → Reusable UI components// │ ├── ui/ → shadcn/ui primitives (Button, Input, etc.)// │ └── layout/ → Sidebar, Navbar, etc.// ├── hooks/ → React Query hooks (use-users.ts, etc.)// ├── lib/ → Utilities (api-client.ts, utils.ts)// └── public/ → Static assets (images, fonts)
In Grit
When you run grit new, the API client is scaffolded at lib/api-client.ts in both the admin and web apps. It reads NEXT_PUBLIC_API_URL from your .env file. All generated hooks import from this single client, so you never need to configure authentication headers or base URLs in individual components.