Prerequisites

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.

components/Greeting.tsx
// A React component is just a function that returns JSX
interface GreetingProps {
name: string;
role?: string; // optional prop
children?: 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 handling
function 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.

types.ts
// Interface for object shapes
interface User {
id: number;
name: string;
email: string;
role: "ADMIN" | "EDITOR" | "USER"; // union type
avatar?: string; // optional
}
// Type for unions and computed types
type Status = "active" | "inactive" | "banned";
type UserRole = User["role"]; // extracts "ADMIN" | "EDITOR" | "USER"
// Generics
interface ApiResponse<T> {
data: T;
message: string;
}
// Component props with TypeScript
interface 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 maps
const 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.

components/UserProfile.tsx
"use client";
import { useState, useEffect } from "react";
function UserProfile({ userId }: { userId: number }) {
// useState: declare reactive state
const [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 changes
setLoading(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].

app/ folder structure
# 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-vs-client.tsx
// SERVER COMPONENT (default - no directive needed)
// Can: fetch data, access DB, read files, use async/await
// Cannot: use useState, useEffect, event handlers, browser APIs
async 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>
<input
value={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.

components/ProductCard.tsx
// Tailwind uses utility classes directly in JSX
function ProductCard({ product }: { product: Product }) {
return (
<div className="rounded-xl border border-border bg-card p-6
hover:bg-accent/10 transition-colors
sm:p-4 md:p-6 lg:p-8">
{/* Responsive: sm/md/lg prefixes */}
<h3 className="text-lg font-semibold text-foreground
sm: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.

hooks/use-posts.ts
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
// Fetching data with useQuery
function usePosts(page: number = 1) {
return useQuery({
queryKey: ["posts", { page }], // unique cache key
queryFn: 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 useMutation
function 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 refetches
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}
// Using in a component
function 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.

schemas/user.ts
import { z } from "zod";
// Define a schema
const 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 schema
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Result:
// {
// name: string;
// email: string;
// age?: number;
// role: "ADMIN" | "EDITOR" | "USER";
// isActive: boolean;
// tags?: string[];
// }
// Validate data
const 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.

components/LoginForm.tsx
"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 form
handleSubmit, // wraps your submit handler with validation
formState: { errors, isSubmitting },
} = useForm<LoginInput>({
resolver: zodResolver(LoginSchema), // Zod validates on submit
});
const onSubmit = async (data: LoginInput) => {
// data is fully validated and typed
const 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
// 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 file
export 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.ts
// lib/api-client.ts
import 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 request
apiClient.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.