Form wrapper components are server-rendered Astro components that handle data fetching, authorization, error handling, and loading states for CRUD forms. They eliminate code duplication and provide a consistent pattern across all dashboard forms.

---
// ~130 lines of boilerplate per page
import { getDatabase } from '#libs/database'
import { logger } from '#utils/logger'
const csrfToken = Astro.locals.csrfToken ?? ''
let client: Client | null = null
let error: string | undefined
try {
const db = getDatabase()
const userDbId = await db.getUserIdByClerkId(Astro.locals.userId)
client = await db.getClientById(clientId, userDbId)
// ... more authorization logic ...
} catch (err) {
error = 'Failed to load client'
await logger.error('Error', { error: err.message })
}
---
{error ? (
<Alert severity="error">{error}</Alert>
) : (
<ClientFormRHF
client:load
csrfToken={csrfToken}
client={client}
/>
)}
---
// ~25 lines - all logic handled by wrapper
import ClientFormWrapper from '#components/astro/forms/ClientFormWrapper.astro'
---
<ClientFormWrapper clientId={clientId} />

For managing client records (create/edit).

---
import ClientFormWrapper from '#components/astro/forms/ClientFormWrapper.astro'
---
<!-- Create mode -->
<ClientFormWrapper />
<!-- Edit mode -->
<ClientFormWrapper clientId={clientId} />

Authorization:

  • Create: No restrictions
  • Edit: Owner or super_admin role

For managing events (create/edit, including recurring events).

---
import EventFormWrapper from '#components/astro/forms/EventFormWrapper.astro'
---
<!-- Create mode -->
<EventFormWrapper />
<!-- Edit mode -->
<EventFormWrapper eventId={eventId} />

Authorization:

  • Create: admin role required
  • Edit: Owner or admin role

For managing orders (create/edit).

---
import OrderFormWrapper from '#components/astro/forms/OrderFormWrapper.astro'
---
<!-- Create mode -->
<OrderFormWrapper />
<!-- Edit mode -->
<OrderFormWrapper orderId={orderId} />

Special Features:

  • Auto-fetches clients list for dropdown
  • Caches clients list for request duration (prevents duplicate fetches)

Authorization:

  • Create: No restrictions
  • Edit: Owner or super_admin role

For managing products (create/edit).

---
import ProductFormWrapper from '#components/astro/forms/ProductFormWrapper.astro'
---
<!-- Create mode -->
<ProductFormWrapper />
<!-- Edit mode -->
<ProductFormWrapper productId={productId} />

Authorization:

  • Create: No restrictions
  • Edit: Owner or super_admin role
---
import Auth from '#layouts/Auth.astro'
import { Row, Col } from '@fpkit/acss'
import ClientFormWrapper from '#components/astro/forms/ClientFormWrapper.astro'
import ClientFormHelp from '#components/astro/forms/help/ClientFormHelp.astro'
---
<Auth pageTitle="Create Client">
<Row as="section" gap="xl" className="container">
<Col span={8}>
<h1 class="text-3xl font-semibold mb-6">Create Client</h1>
<ClientFormWrapper />
</Col>
<Col span={4}>
<ClientFormHelp />
</Col>
</Row>
</Auth>
---
import Auth from '#layouts/Auth.astro'
import { Row, Col } from '@fpkit/acss'
import ClientFormWrapper from '#components/astro/forms/ClientFormWrapper.astro'
import ClientFormHelp from '#components/astro/forms/help/ClientFormHelp.astro'
const clientId = Astro.params.id
if (!clientId) {
return Astro.redirect('/dashboard/clients')
}
---
<Auth pageTitle="Edit Client">
<Row as="section" gap="xl" className="container">
<Col span={8}>
<h1 class="text-3xl font-semibold mb-6">Edit Client</h1>
<ClientFormWrapper clientId={clientId} />
</Col>
<Col span={4}>
<ClientFormHelp />
</Col>
</Row>
</Auth>

Customize error messages shown to users:

<OrderFormWrapper
orderId={orderId}
errorMessages={{
notFound: "This order doesn't exist or has been deleted.",
unauthorized: "You don't have permission to edit this order.",
fetchFailed: "Failed to load order data. Please try again."
}}
/>

Show a custom message during server-side data fetch:

<ProductFormWrapper
productId={productId}
loadingMessage="Loading product details..."
/>

Skip the loading skeleton and show errors only:

<EventFormWrapper
eventId={eventId}
showLoadingSkeleton={false}
/>

Override default authorization behavior:

<!-- Allow team_admin to edit without ownership -->
<OrderFormWrapper
orderId={orderId}
requiredRoles={['super_admin', 'team_admin']}
/>
<!-- Skip ownership check entirely (for admins) -->
<ClientFormWrapper
clientId={clientId}
skipOwnershipCheck={true}
/>

Pass pre-fetched data to skip wrapper’s server-side fetch:

---
// Fetch data in page for custom logic
const db = getDatabase()
const order = await db.getOrderById(orderId)
const clients = await db.getClients({ limit: 1000 })
---
<!-- Pass pre-fetched data -->
<OrderFormWrapper
orderId={orderId}
order={order}
clients={clients}
/>

Each form wrapper has a corresponding help component with usage guidance:

WrapperHelp ComponentPurpose
ClientFormWrapperClientFormHelpClient form guidance
EventFormWrapper—The event pages carry an upcoming-events rail instead of a help sidebar; field guidance lives in each field’s hint text
OrderFormWrapperOrderFormHelpOrder form guidance (services, status)
ProductFormWrapper—The product pages carry a recent-products rail instead of a help sidebar; field guidance lives in each field’s hint text
---
import ClientFormHelp from '#components/astro/forms/help/ClientFormHelp.astro'
---
<Row as="section" gap="xl" className="container">
<Col span={8}>
<!-- Form column -->
<ClientFormWrapper />
</Col>
<Col span={4}>
<!-- Help sidebar -->
<ClientFormHelp />
</Col>
</Row>

Form wrappers protect sensitive information by showing different error messages based on environment:

Development Mode (local development):

  • Detailed error messages with stack traces
  • Database errors show table names and constraints
  • Useful for debugging and troubleshooting

Production Mode (deployed application):

  • Generic user-safe error messages
  • No database schema or SQL syntax exposed
  • Prevents reconnaissance attacks

Implementation Pattern:

try {
// Fetch data
} catch (err) {
const isDev = import.meta.env.DEV
const detailedError = err instanceof Error ? err.message : 'Failed to load client'
error = errorMessages.fetchFailed ??
(isDev ? detailedError : 'Failed to load client. Please try again.')
// Full error details always logged server-side
await logger.error('Failed to fetch client', {
error: err instanceof Error ? err.message : 'Unknown error',
stack: err instanceof Error ? err.stack : undefined
})
}

Defense in Depth: User-safe messages + comprehensive server logs

Form wrappers validate ownership using manual post-fetch verification:

Pattern (Client, Event, Product wrappers):

// 1. Fetch entity by ID (no user constraint)
const client = await db.getClientById(clientId)
if (client) {
// 2. Manually check ownership
const clientCreatedBy = client.created_by
const isOwner = clientCreatedBy === userDbId
const canBypass = skipOwnershipCheck ||
(Astro.locals.userRole && requiredRoles.includes(Astro.locals.userRole))
// 3. Enforce authorization
if (!isOwner && !canBypass) {
client = null
error = 'You do not have permission to edit this client.'
await logger.warn('Client access denied - ownership check failed')
}
}

Why Manual Validation?:

  • Application-layer security check
  • Clear audit trail in logs
  • Flexible authorization logic
  • Consistent across all wrappers

Order Wrapper Exception:

  • Uses built-in ownership parameter: db.getOrderById(orderId, userDbId)
  • Simpler pattern when database method supports it

Form wrappers fetch data during server-side rendering (SSR), not client-side:

1. Request arrives at server
2. Wrapper fetches data from database (~20-100ms)
3. Loading skeleton shown during fetch (SSR)
4. Form rendered with data
5. Page sent to client (no client-side loading needed)

For edit mode, wrappers check authorization before rendering the form:

1. Check authentication (via Clerk)
2. Get user's database ID
3. Try ownership-based fetch (user owns entity?)
4. If not found, check required roles (e.g., super_admin)
5. If authorized, render form
6. If unauthorized, show error alert

OrderFormWrapper caches the clients list to prevent duplicate fetches:

First OrderFormWrapper on page:
└─ Fetch clients from database
└─ Cache in Astro.locals.cachedClients
Second OrderFormWrapper on page:
└─ Use cached clients (no fetch)
After request completes:
└─ Cache cleared automatically

All form wrappers share these common props:

{
// Entity ID for edit mode (auto-detects edit mode if provided)
clientId?: string // ClientFormWrapper
eventId?: string // EventFormWrapper
orderId?: string // OrderFormWrapper
productId?: string // ProductFormWrapper
// Explicit mode override (usually not needed)
mode?: 'create' | 'edit'
}
{
// API endpoint override (auto-generated if not provided)
apiEndpoint?: string
// Show error alerts (default: true)
showErrors?: boolean
// Hydration strategy for React form (default: 'load')
hydration?: 'load' | 'visible' | 'idle'
// Additional CSS class
className?: string
}
{
// Show loading skeleton during fetch (default: true)
showLoadingSkeleton?: boolean
// Loading message (default: "Loading...")
loadingMessage?: string
// Custom error messages
errorMessages?: {
notFound?: string
unauthorized?: string
fetchFailed?: string
}
}
{
// Required roles for edit without ownership (default: ['super_admin'])
requiredRoles?: string[]
// Skip ownership check (default: false)
skipOwnershipCheck?: boolean
}

If you have existing form pages using the old pattern, here’s how to migrate:

Determine which wrapper to use:

  • Client CRUD? → ClientFormWrapper
  • Event CRUD? → EventFormWrapper
  • Order CRUD? → OrderFormWrapper
  • Product CRUD? → ProductFormWrapper

Delete lines that:

  • Fetch CSRF token
  • Fetch entity data (client/event/order/product)
  • Check authorization
  • Handle errors
  • Show loading states

Replace removed code with wrapper:

<!-- Before -->
<ClientFormRHF
client:load
csrfToken={csrfToken}
client={client}
apiEndpoint={`/api/clients/edit/${clientId}`}
/>
<!-- After -->
<ClientFormWrapper clientId={clientId} />

Move help sidebar content to help component:

<!-- Before -->
<Col span={4}>
<Title as="h2" size="lg">Client Information</Title>
<p>Edit client details...</p>
<!-- More help content -->
</Col>
<!-- After -->
<Col span={4}>
<ClientFormHelp />
</Col>
  • ✅ Create mode works
  • ✅ Edit mode pre-populates data
  • ✅ Authorization enforced (owner or admin)
  • ✅ Errors displayed correctly
  • ✅ Loading skeleton shown (edit mode)

Problem: Blank page or loading skeleton persists.

Solutions:

  1. Check server logs for database errors
  2. Verify entity ID is valid
  3. Confirm user is authenticated (Astro.locals.userId)
  4. Verify database configuration

Problem: Error alert shows even though entity exists.

Solutions:

  1. Check user owns entity (database user_id matches)
  2. Verify user has required role (super_admin for default)
  3. Check server logs for authorization failures

Problem: Warning in logs: “CSRF token missing in FormWrapper”.

Solutions:

  1. Verify middleware is running (src/middleware.ts)
  2. Confirm page uses Auth layout
  3. Check Astro.locals.csrfToken is set

Problem: Order form shows “No clients available”.

Solutions:

  1. Verify clients exist in database
  2. Check query filters in OrderFormWrapper
  3. Restart dev server (cache is request-scoped)

Problem: Production shows “Failed to load…” instead of specific errors.

This is Expected Behavior:

  • Production uses generic messages to prevent information disclosure
  • Development mode shows detailed errors for debugging
  • Check server logs for full error details with correlation IDs

Solutions:

  1. Check server logs using correlation ID
  2. Use custom errorMessages prop for user-friendly production messages
  3. In development, detailed errors shown automatically
  • Use wrappers for all CRUD forms - Consistent pattern across app
  • Use help components - Provide guidance to users
  • Customize error messages - Make errors user-friendly
  • Test authorization - Verify ownership and role checks work
  • Check server logs - Debug issues with structured logging
  • Don’t bypass wrappers - Defeats the purpose of centralization
  • Don’t fetch data manually - Let wrappers handle it
  • Don’t hardcode authorization - Use wrapper props instead
  • Don’t skip testing - Authorization bugs are security issues
  • Don’t ignore errors - They indicate real problems
---
import Auth from '#layouts/Auth.astro'
import ProductFormWrapper from '#components/astro/forms/ProductFormWrapper.astro'
---
<Auth pageTitle="Create Product">
<section class="container">
<h1>Create Product</h1>
<ProductFormWrapper />
</section>
</Auth>
---
import Auth from '#layouts/Auth.astro'
import { Row, Col } from '@fpkit/acss'
import OrderFormWrapper from '#components/astro/forms/OrderFormWrapper.astro'
import OrderFormHelp from '#components/astro/forms/help/OrderFormHelp.astro'
const orderId = Astro.params.id
if (!orderId) {
return Astro.redirect('/dashboard/orders')
}
---
<Auth pageTitle="Edit Order">
<Row as="section" gap="xl" className="container">
<Col span={8}>
<h1 class="text-3xl font-semibold mb-6">Edit Order</h1>
<OrderFormWrapper
orderId={orderId}
loadingMessage="Loading order details..."
errorMessages={{
notFound: "Order not found. It may have been deleted.",
unauthorized: "You don't have permission to edit this order.",
fetchFailed: "Failed to load order. Please try again."
}}
/>
</Col>
<Col span={4}>
<OrderFormHelp />
</Col>
</Row>
</Auth>
---
import Auth from '#layouts/Auth.astro'
import EventFormWrapper from '#components/astro/forms/EventFormWrapper.astro'
const eventId = Astro.params.id
---
<Auth
pageTitle="Edit Event"
requiredRoles={['admin', 'event_manager']}
>
<section class="container">
<h1>Edit Event</h1>
<EventFormWrapper
eventId={eventId}
requiredRoles={['admin', 'event_manager']}
/>
</section>
</Auth>
  • Fetch Time: 20-100ms (during SSR)
  • Client Impact: Zero loading delay (data pre-fetched)
  • User Experience: No loading spinners or layout shift
  • OrderFormWrapper: Clients list cached per request
  • Benefit: N forms = 1 database query
  • Duration: Request lifetime only (no stale data)
  • Before: 1,040 lines (8 pages × 130 lines)
  • After: 960 lines (200 page lines + 600 wrapper lines + 160 help lines)
  • Reduction: 8% smaller, 76% less duplication

Form wrapper components provide a powerful pattern for building consistent, maintainable CRUD forms with minimal code. By centralizing data fetching, authorization, error handling, and loading states, they enable you to focus on form UI and business logic.

Key Benefits:

  • ✅ 76% code reduction in pages
  • ✅ Consistent authorization and error handling
  • ✅ Server-side rendering (no client loading states)
  • ✅ Request-scoped caching for performance
  • ✅ Flexible and extensible architecture