Frontend

Shared Package

The shared package at packages/shared/ contains Zod validation schemas, TypeScript types, and constants shared between the web app, admin panel, and any other frontend app in the monorepo.

Package Structure

The shared package is a pnpm workspace member that both apps/web and apps/admin depend on. Import from it using the workspace alias (e.g., @myapp/shared/schemas).

packages/shared/
packages/shared/
├── package.json
├── tsconfig.json
├── schemas/              # Zod validation schemas
│   ├── user.ts           # User auth schemas (Login, Register, etc.)
│   ├── post.ts           # Generated: CreatePost, UpdatePost schemas
│   ├── blog.ts           # Generated: CreateBlog, UpdateBlog schemas
│   └── index.ts          # Re-exports all schemas
├── types/                # TypeScript interfaces
│   ├── user.ts           # User, AuthResponse interfaces
│   ├── api.ts            # ApiResponse, PaginatedResponse, ApiError
│   ├── upload.ts         # Upload interface
│   ├── post.ts           # Generated: Post interface
│   ├── blog.ts           # Generated: Blog interface
│   └── index.ts          # Re-exports all types
└── constants/
    └── index.ts          # ROLES, API_ROUTES, etc.
packages/shared/package.json
{
  "name": "@myapp/shared",
  "version": "0.8.0",
  "private": true,
  "main": "./index.ts",
  "types": "./index.ts",
  "exports": {
    "./schemas": "./schemas/index.ts",
    "./types": "./types/index.ts",
    "./constants": "./constants/index.ts"
  },
  "dependencies": {
    "zod": "^3.22.0"
  }
}

Zod Schemas

Each resource has Create and Update Zod schemas. The base User schemas are scaffolded with the project. When you run grit generate resource Post, a new schema file is generated and automatically added to the barrel export.

User Schemas (Built-in)

packages/shared/schemas/user.ts
import { z } from "zod";

export const LoginSchema = z.object({
  email: z.string().email("Please enter a valid email"),
  password: z.string().min(1, "Password is required"),
});

export const RegisterSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Please enter a valid email"),
  password: z.string().min(8, "Password must be at least 8 characters"),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords do not match",
  path: ["confirmPassword"],
});

export const UpdateUserSchema = z.object({
  name: z.string().min(2).optional(),
  email: z.string().email().optional(),
  role: z.enum(["admin", "editor", "user"]).optional(),
  active: z.boolean().optional(),
});

export const ForgotPasswordSchema = z.object({
  email: z.string().email("Please enter a valid email"),
});

export const ResetPasswordSchema = z.object({
  token: z.string().min(1, "Token is required"),
  password: z.string().min(8, "Password must be at least 8 characters"),
});

// Inferred types from schemas
export type LoginInput = z.infer<typeof LoginSchema>;
export type RegisterInput = z.infer<typeof RegisterSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
export type ForgotPasswordInput = z.infer<typeof ForgotPasswordSchema>;
export type ResetPasswordInput = z.infer<typeof ResetPasswordSchema>;

Generated Resource Schemas

Running grit generate resource Post --fields "title:string,content:text,published:bool" generates:

packages/shared/schemas/post.ts
import { z } from "zod";

export const CreatePostSchema = z.object({
  title: z.string().min(1, "Title is required"),
  content: z.string().min(1, "Content is required"),
  published: z.boolean(),
});

export const UpdatePostSchema = z.object({
  title: z.string().min(1).optional(),
  content: z.string().min(1).optional(),
  published: z.boolean().optional(),
});

export type CreatePostInput = z.infer<typeof CreatePostSchema>;
export type UpdatePostInput = z.infer<typeof UpdatePostSchema>;

TypeScript Types

Type interfaces mirror Go structs. They are auto-generated by grit sync or created alongside schemas by grit generate resource. The API response wrapper types standardize how the frontend handles all API responses.

packages/shared/types/user.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "editor" | "user";
  avatar: string;
  active: boolean;
  email_verified_at: string | null;
  created_at: string;
  updated_at: string;
}

export interface LoginRequest {
  email: string;
  password: string;
}

export interface RegisterRequest {
  name: string;
  email: string;
  password: string;
}

export interface AuthResponse {
  user: User;
  tokens: {
    access_token: string;
    refresh_token: string;
    expires_at: number;
  };
}
packages/shared/types/api.ts
export interface ApiResponse<T> {
  data: T;
  message?: string;
}

export interface PaginatedResponse<T> {
  data: T[];
  meta: {
    total: number;
    page: number;
    page_size: number;
    pages: number;
  };
}

export interface ApiError {
  error: {
    code: string;
    message: string;
    details?: Record<string, string>;
  };
}
packages/shared/types/upload.ts
export interface Upload {
  id: number;
  filename: string;
  original_name: string;
  mime_type: string;
  size: number;
  path: string;
  url: string;
  thumbnail_url?: string;
  user_id: number;
  created_at: string;
  updated_at: string;
}

Constants

Shared constants define roles, API route paths, and configuration values. Using these constants prevents typos and provides type-safe route references across the entire frontend.

packages/shared/constants/index.ts
export const ROLES = {
  ADMIN: "admin",
  EDITOR: "editor",
  USER: "user",
} as const;

export const API_ROUTES = {
  AUTH: {
    LOGIN: "/api/auth/login",
    REGISTER: "/api/auth/register",
    REFRESH: "/api/auth/refresh",
    LOGOUT: "/api/auth/logout",
    ME: "/api/auth/me",
    FORGOT_PASSWORD: "/api/auth/forgot-password",
    RESET_PASSWORD: "/api/auth/reset-password",
  },
  USERS: {
    LIST: "/api/users",
    GET: (id: number) => `/api/users/${id}`,
    UPDATE: (id: number) => `/api/users/${id}`,
    DELETE: (id: number) => `/api/users/${id}`,
  },
  UPLOADS: {
    CREATE: "/api/uploads",
    LIST: "/api/uploads",
    GET: (id: number) => `/api/uploads/${id}`,
    DELETE: (id: number) => `/api/uploads/${id}`,
  },
  AI: {
    COMPLETE: "/api/ai/complete",
    CHAT: "/api/ai/chat",
    STREAM: "/api/ai/stream",
  },
  ADMIN: {
    JOBS_STATS: "/api/admin/jobs/stats",
    JOBS_LIST: (status: string) => `/api/admin/jobs/${status}`,
    JOBS_RETRY: (id: string) => `/api/admin/jobs/${id}/retry`,
    CRON_TASKS: "/api/admin/cron/tasks",
  },
  HEALTH: "/api/health",
  // grit:api-routes  (marker for code generation)
} as const;

How Schemas Work Across the Stack

Zod schemas serve as the single source of truth for validation. The same schema validates form input on the frontend and can be referenced when building Go struct tags for backend validation. This ensures consistent error messages and validation rules across the stack.

validation-flow.txt
┌─────────────────────────────────────────────────┐
│  Go Struct (source of truth)                    │
│  type Post struct {                             │
│    Title   string `binding:"required"`           │
│    Content string `binding:"required"`           │
│  }                                              │
└─────────────┬───────────────────────────────────┘
              │  grit sync / grit generate
              v
┌─────────────────────────────────────────────────┐
│  Zod Schema (packages/shared/schemas/post.ts)   │
│  CreatePostSchema = z.object({                  │
│    title: z.string().min(1),                    │
│    content: z.string().min(1),                  │
│  })                                             │
└─────────────┬──────────────┬────────────────────┘
              │              │
              v              v
      ┌──────────────┐  ┌──────────────┐
      │  apps/web    │  │  apps/admin  │
      │  form valid. │  │  form valid. │
      └──────────────┘  └──────────────┘

Example usage in a form component:

using-schemas-in-forms.tsx
import { CreatePostSchema, type CreatePostInput } from "@myapp/shared/schemas";
import { useCreatePost } from "@/hooks/use-posts";

function CreatePostForm() {
  const createPost = useCreatePost();
  const [form, setForm] = useState<CreatePostInput>({
    title: "",
    content: "",
    published: false,
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    // Validate with Zod before sending to API
    const result = CreatePostSchema.safeParse(form);
    if (!result.success) {
      // Display validation errors
      const errors = result.error.flatten().fieldErrors;
      // { title: ["Title is required"], content: [...] }
      return;
    }

    // Schema validation passed -- send to Go API
    createPost.mutate(result.data);
  };
}

Export Structure

Each directory has an index.ts barrel file that re-exports everything. The // grit:schemas and // grit:types markers are used by the code generator to inject new exports when a resource is generated.

packages/shared/schemas/index.ts
export {
  LoginSchema,
  RegisterSchema,
  UpdateUserSchema,
  ForgotPasswordSchema,
  ResetPasswordSchema,
  type LoginInput,
  type RegisterInput,
  type UpdateUserInput,
  type ForgotPasswordInput,
  type ResetPasswordInput,
} from "./user";
// grit:schemas  <-- new exports are injected here
packages/shared/types/index.ts
export type {
  User, LoginRequest, RegisterRequest, AuthResponse,
} from "./user";

export type {
  ApiResponse, PaginatedResponse, ApiError,
} from "./api";

export type { Upload } from "./upload";
// grit:types  <-- new type exports are injected here

Marker-based injection: When you run grit generate resource Invoice, the CLI finds the // grit:schemas marker and inserts the new export line directly above it. This means you never need to manually update barrel files.