Server Utilities
Server utilities in Nuxt Crouton provide powerful helpers for creating secure, multi-tenant API endpoints and managing team authorization. These utilities are designed to work seamlessly with Nuxt's server API routes and integrate with the client-side collection system.
Auto-Imported: All server utilities are automatically imported by Nuxt. No import statements are needed in your server routes.
createExternalCollectionHandler
Creates API endpoints that transform and serve external data in Crouton's collection format. This utility bridges external data sources with Crouton's client-side collection system.
Function Signature
export function createExternalCollectionHandler<T>(
fetchFn: ExternalCollectionFetchFn<T>,
transform: ExternalCollectionTransform<T>
): (event: H3Event<EventHandlerRequest>) => Promise<any[]>
Type Definitions
/**
* Transform function that converts external data to Crouton format
* Must return at minimum: { id: string, title: string }
* The 'title' field is used by CroutonReferenceSelect for display
*/
export type ExternalCollectionTransform<T> = (item: T) => {
id: string
title: string
[key: string]: any
}
/**
* Fetch function that retrieves data from your external system
* Receives the H3Event for access to params, auth, etc.
*/
export type ExternalCollectionFetchFn<T> = (
event: H3Event<EventHandlerRequest>
) => Promise<T[]> | T[]
Purpose & Use Cases
- External Data Integration: Connect external systems (SuperSaaS, NuxSaaS, custom APIs) to Crouton
- Reference Dropdowns: Power
CroutonReferenceSelectwith dynamic data - Data Transformation: Convert external formats to Crouton's standardized structure
- Multi-Tenancy: Combine with team auth to create tenant-specific endpoints
- Query Filtering: Support
?ids=parameter for selective data fetching
Parameters
| Parameter | Type | Description |
|---|---|---|
fetchFn | ExternalCollectionFetchFn<T> | Async function that retrieves items from your system. Receives H3Event for access to route params, auth, headers. Can throw errors to trigger proper error responses. |
transform | ExternalCollectionTransform<T> | Function that converts each item from your system to Crouton format. Must return object with at least { id, title }. Additional fields become available in the UI. |
Return Value
Returns an H3 event handler function ready for use in Nuxt server routes:
(event: H3Event<EventHandlerRequest>) => Promise<any[]>
The handler returns an array of transformed items, each containing:
id(string, required) - Unique identifier for the itemtitle(string, required) - Display name in UI components[key: string](any) - Additional fields from your system
Example 1: Basic External Collection
// server/api/teams/[id]/users/index.get.ts
export default createExternalCollectionHandler(
// Step 1: Fetch data from your system
async (event) => {
const teamId = getRouterParam(event, 'id')
if (!teamId) {
throw createError({
statusCode: 400,
statusMessage: 'Team ID is required'
})
}
// Return raw data from your system
return await getActiveTeamMembers(teamId)
},
// Step 2: Transform to Crouton format
(member) => ({
id: member.userId,
title: member.name, // Required for dropdown display
email: member.email,
avatarUrl: member.avatarUrl,
role: member.role
})
)
Usage in Components:
<script setup lang="ts">
// Reference select will fetch from /api/teams/[id]/users
const selectedUserId = ref('')
</script>
<template>
<CroutonReferenceSelect
v-model="selectedUserId"
collection="teams"
relationship="users"
display-field="name"
/>
</template>
Example 2: Authorization with Team Context
// server/api/teams/[id]/projects/index.get.ts
export default createExternalCollectionHandler(
async (event) => {
const teamId = getRouterParam(event, 'id')
const { user } = await requireUserSession(event)
// Verify user is a team member
const isMember = await isTeamMember(teamId, user.id)
if (!isMember) {
throw createError({
statusCode: 403,
statusMessage: 'You are not a member of this team'
})
}
// Fetch team-specific projects
return await db
.select()
.from(tables.projects)
.where(eq(tables.projects.teamId, teamId))
.all()
},
(project) => ({
id: project.id,
title: project.name,
description: project.description,
status: project.status,
teamId: project.teamId
})
)
Example 3: Query Parameter Filtering
// server/api/admin/users/index.get.ts
export default createExternalCollectionHandler(
async (event) => {
const currentUser = await requireAuth(event)
const query = getQuery(event)
// Check for admin access
if (currentUser.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Admin access required'
})
}
// Get optional filters from query string
const roleFilter = query.role as string | undefined
const includeBanned = query.includeBanned === 'true'
// Build query with filters
const conditions = []
if (!includeBanned) {
conditions.push(or(
eq(tables.users.banned, false),
eq(tables.users.banned, null)
))
}
if (roleFilter) {
conditions.push(eq(tables.users.role, roleFilter))
}
return await db
.select()
.from(tables.users)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.all()
},
(user) => ({
id: user.id,
title: user.name,
email: user.email,
role: user.role,
banned: user.banned,
image: user.image
})
)
// Usage:
// GET /api/admin/users
// GET /api/admin/users?role=admin
// GET /api/admin/users?includeBanned=true
// GET /api/admin/users?ids=uuid1,uuid2,uuid3
Example 4: Subscription Data Integration
// server/api/users/[id]/subscriptions/index.get.ts
export default createExternalCollectionHandler(
async (event) => {
const userId = getRouterParam(event, 'id')
const { user } = await requireUserSession(event)
// Only users can access their own subscription data
if (user.id !== userId) {
throw createError({
statusCode: 403,
statusMessage: 'Unauthorized'
})
}
// Fetch with relations
return await db
.select({
subscription: tables.subscriptions,
plan: tables.subscriptionPlans
})
.from(tables.subscriptions)
.leftJoin(
tables.subscriptionPlans,
eq(tables.subscriptions.planId, tables.subscriptionPlans.id)
)
.where(eq(tables.subscriptions.userId, userId))
.all()
},
(subscription) => ({
id: subscription.subscription.id,
title: subscription.plan?.name || 'Unknown Plan',
status: subscription.subscription.status,
periodEnd: subscription.subscription.periodEnd,
cancelAtPeriodEnd: subscription.subscription.cancelAtPeriodEnd,
planId: subscription.subscription.planId,
features: subscription.plan?.features
})
)
Example 5: Error Handling & Validation
// server/api/teams/[id]/documents/index.get.ts
export default createExternalCollectionHandler(
async (event) => {
const teamId = getRouterParam(event, 'id')
// Validate input
if (!teamId || typeof teamId !== 'string') {
throw createError({
statusCode: 400,
statusMessage: 'Invalid or missing team ID'
})
}
// Validate team exists
const team = await db
.select()
.from(tables.teams)
.where(eq(tables.teams.id, teamId))
.get()
if (!team) {
throw createError({
statusCode: 404,
statusMessage: 'Team not found'
})
}
// Validate user access
const { user } = await requireUserSession(event)
const access = await checkTeamAccess(user.id, teamId, 'read')
if (!access) {
throw createError({
statusCode: 403,
statusMessage: 'You do not have permission to view this team\'s documents'
})
}
try {
return await db
.select()
.from(tables.documents)
.where(eq(tables.documents.teamId, teamId))
.all()
} catch (error) {
console.error('[documents] Database error:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch documents'
})
}
},
(doc) => {
// Validate transform output
if (!doc.id || !doc.title) {
console.warn('[documents] Invalid document structure:', doc)
return {
id: doc.id || 'unknown',
title: doc.title || 'Untitled'
}
}
return {
id: doc.id,
title: doc.title,
description: doc.description,
documentType: doc.type,
uploadedAt: doc.createdAt,
uploadedBy: doc.uploadedBy
}
}
)
Features
- Automatic Error Handling: Wraps your code in try-catch with proper HTTP error responses
- Query Parameter Support: Built-in support for
?ids=id1,id2,id3filtering - Type-Safe: Full TypeScript generics support for your data type
- Auto-Import: No import needed - Nuxt auto-imports the utility
- H3Event Access: Full access to route params, auth, headers, query params
Integration with useCollectionProxy
Client-side composables automatically integrate with external collection handlers:
// composables/useProjects.ts
import { useCollectionProxy } from '@friendlyinternet/nuxt-crouton'
export function useTeamProjects(teamId: string) {
// Automatically calls /api/teams/[id]/projects
const { items, loading, refresh } = useCollectionProxy('teams', teamId, 'projects')
return { items, loading, refresh }
}
Team Authorization Utilities
resolveTeamAndCheckMembership
Resolves a team by slug or ID and verifies the current user is a team member.
Function Signature
export async function resolveTeamAndCheckMembership(
event: any
): Promise<{
team: Team
user: User
membership: TeamMember
}>
Purpose & Use Cases
- Multi-Tenant Route Protection: Ensure user has access to team resources
- Team Slug Resolution: Handle both slug and ID-based URLs
- Membership Verification: Single function call replaces multiple database queries
- Clean Error Handling: Returns 404 for missing teams, 403 for unauthorized access
Parameters
| Parameter | Type | Description |
|---|---|---|
event | H3Event | The Nuxt server event, automatically provides team slug/ID from route params |
Return Value
{
team: Team // The resolved team object
user: User // The authenticated user
membership: TeamMember // The membership record proving user is in team
}
Example 1: Basic Team Route
// server/api/teams/[id]/settings.get.ts
export default defineEventHandler(async (event) => {
// Resolves team by slug or ID, verifies user membership
const { team, user } = await resolveTeamAndCheckMembership(event)
return {
teamId: team.id,
teamName: team.name,
userId: user.id,
settings: team.settings
}
})
Example 2: With Team Data
// server/api/teams/[id]/index.get.ts
export default defineEventHandler(async (event) => {
const { team, user, membership } = await resolveTeamAndCheckMembership(event)
// Fetch team statistics
const memberCount = await db
.select({ count: count() })
.from(tables.teamMembers)
.where(eq(tables.teamMembers.teamId, team.id))
.get()
const projectCount = await db
.select({ count: count() })
.from(tables.projects)
.where(eq(tables.projects.teamId, team.id))
.get()
return {
id: team.id,
name: team.name,
slug: team.slug,
description: team.description,
avatarUrl: team.avatarUrl,
memberCount: memberCount.count,
projectCount: projectCount.count,
userRole: membership.role,
currentUserId: user.id
}
})
Example 3: Role-Based Access Control
// server/api/teams/[id]/members.delete.ts
export default defineEventHandler(async (event) => {
const { team, membership } = await resolveTeamAndCheckMembership(event)
// Check user has admin role
if (membership.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Only team admins can manage members'
})
}
const { memberId } = await readBody(event)
// Delete the member
await db
.delete(tables.teamMembers)
.where(
and(
eq(tables.teamMembers.teamId, team.id),
eq(tables.teamMembers.id, memberId)
)
)
return { success: true }
})
Example 4: Update Team Settings
// server/api/teams/[id]/settings.patch.ts
export default defineEventHandler(async (event) => {
const { team, membership } = await resolveTeamAndCheckMembership(event)
// Verify owner permission
if (membership.role !== 'owner') {
throw createError({
statusCode: 403,
statusMessage: 'Only team owner can update settings'
})
}
const body = await readBody(event)
// Validate input
const schema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
avatarUrl: z.string().url().optional()
})
const validated = schema.parse(body)
// Update team
const updated = await db
.update(tables.teams)
.set(validated)
.where(eq(tables.teams.id, team.id))
.returning()
.get()
return updated
})
isTeamMember
Checks if a specific user is a member of a team. Useful for conditional logic in API routes.
Function Signature
export async function isTeamMember(
teamId: string,
userId: string
): Promise<boolean>
Purpose & Use Cases
- Conditional Authorization: Skip full membership fetch if you only need boolean result
- Batch Operations: Check access for multiple users efficiently
- System Operations: Verify access in scheduled tasks or background jobs
- Webhook Validation: Ensure webhook sender has team access
Parameters
| Parameter | Type | Description |
|---|---|---|
teamId | string | The team's ID (not slug) |
userId | string | The user's ID to check |
Return Value
Returns a boolean: true if user is a team member, false otherwise.
Example 1: Conditional Logic
// server/api/teams/[teamId]/invite.post.ts
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const { teamId } = getRouterParams(event)
const { inviteEmail } = await readBody(event)
// Only team members can invite others
const isInviter = await isTeamMember(teamId, user.id)
if (!isInviter) {
throw createError({
statusCode: 403,
statusMessage: 'You are not a member of this team'
})
}
// Send invite
return await sendTeamInvite(teamId, inviteEmail)
})
Example 2: Batch Access Check
// server/utils/teamAccess.ts
export async function filterAccessibleTeams(
userId: string,
teamIds: string[]
): Promise<string[]> {
const accessible = []
for (const teamId of teamIds) {
const isMember = await isTeamMember(teamId, userId)
if (isMember) {
accessible.push(teamId)
}
}
return accessible
}
// Usage in API:
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const { teamIds } = await readBody(event)
// Verify user can access each team
const allowed = await filterAccessibleTeams(user.id, teamIds)
return { allowedTeams: allowed }
})
Example 3: Webhook Validation
// server/api/webhooks/stripe.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const signature = getHeader(event, 'stripe-signature')
// Verify Stripe signature
const webhookSecret = useRuntimeConfig().stripeWebhookSecret
const verified = stripe.webhooks.constructEvent(
body,
signature,
webhookSecret
)
const { type, data } = verified
if (type === 'customer.subscription.updated') {
const customerId = data.object.customer
// Find user with this Stripe ID
const user = await db
.select()
.from(tables.users)
.where(eq(tables.users.stripeCustomerId, customerId))
.get()
if (!user) return { received: true }
// Verify user still has access (in case they were removed from all teams)
const isActiveMember = await isTeamMember(user.primaryTeamId, user.id)
if (!isActiveMember) {
// Downgrade service
await suspendUserServices(user.id)
}
return { received: true }
}
return { received: true }
})
Example 4: Background Job Access Control
// server/tasks/cleanupOldFiles.ts
export default defineEventHandler(async (event) => {
// System task - fetch all teams and files
const teams = await db
.select()
.from(tables.teams)
.all()
for (const team of teams) {
// Get all files in team
const files = await db
.select()
.from(tables.files)
.where(
and(
eq(tables.files.teamId, team.id),
lt(tables.files.createdAt, oneMonthAgo)
)
)
.all()
for (const file of files) {
// Verify uploader still has team access
const uploaderStillMember = await isTeamMember(team.id, file.uploadedBy)
if (!uploaderStillMember) {
// Safe to delete - uploader is no longer in team
await deleteFile(file.id)
}
}
}
})
Best Practices for Server-Side Code
1. Always Validate and Authorize
// ✅ Good - comprehensive validation
export default defineEventHandler(async (event) => {
// Step 1: Authenticate
const { user } = await requireUserSession(event)
// Step 2: Get and validate team
const teamId = getRouterParam(event, 'id')
if (!teamId) {
throw createError({
statusCode: 400,
statusMessage: 'Team ID is required'
})
}
// Step 3: Verify membership
const isMember = await isTeamMember(teamId, user.id)
if (!isMember) {
throw createError({
statusCode: 403,
statusMessage: 'Unauthorized'
})
}
// Now safe to proceed
})
// ❌ Bad - assumes access
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const teamId = getRouterParam(event, 'id')
// Missing validation!
})
2. Use External Collection Handler for Lists
// ✅ Good - use createExternalCollectionHandler
export default createExternalCollectionHandler(
async (event) => {
const { team } = await resolveTeamAndCheckMembership(event)
return await db.select().from(tables.items).where(...)
},
(item) => ({ id: item.id, title: item.name, ... })
)
// ❌ Bad - manual transformation
export default defineEventHandler(async (event) => {
const { team } = await resolveTeamAndCheckMembership(event)
const items = await db.select().from(tables.items).where(...)
return items.map(item => ({...})) // Error handling is your problem
})
3. Proper Error Handling
// ✅ Good - specific error messages and codes
try {
const data = await externalAPI.fetch(params)
return data
} catch (error) {
if (error instanceof ValidationError) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid input: ' + error.message
})
}
if (error instanceof NotFoundError) {
throw createError({
statusCode: 404,
statusMessage: 'Resource not found'
})
}
console.error('Unexpected error:', error)
throw createError({
statusCode: 500,
statusMessage: 'Internal server error'
})
}
// ❌ Bad - generic errors
try {
return await something()
} catch (error) {
throw createError({
statusCode: 500,
statusMessage: 'Something went wrong'
})
}
4. Type Your Server Code
// ✅ Good - fully typed
interface TeamStats {
membersCount: number
projectsCount: number
filesSize: number
}
export default defineEventHandler(async (event): Promise<TeamStats> => {
const { team } = await resolveTeamAndCheckMembership(event)
const stats: TeamStats = {
membersCount: await countTeamMembers(team.id),
projectsCount: await countTeamProjects(team.id),
filesSize: await calculateTeamFilesSize(team.id)
}
return stats
})
// ❌ Bad - untyped
export default defineEventHandler(async (event) => {
const { team } = await resolveTeamAndCheckMembership(event)
return {
membersCount: await countTeamMembers(team.id),
projectsCount: await countTeamProjects(team.id),
filesSize: await calculateTeamFilesSize(team.id)
}
})
5. Logging and Debugging
// ✅ Good - contextual logging
const teamId = getRouterParam(event, 'id')
const { user } = await requireUserSession(event)
console.log('[teams.get]', {
teamId,
userId: user.id,
timestamp: new Date().toISOString()
})
try {
const data = await fetchTeamData(teamId)
return data
} catch (error) {
console.error('[teams.get] Error:', {
teamId,
userId: user.id,
error: error instanceof Error ? error.message : String(error)
})
throw error
}
// ❌ Bad - no context
console.log('Error:', error)
Security Considerations
1. Team Isolation
Always verify team ownership before returning team-specific data:
// ✅ Secure - check membership on every request
const { team } = await resolveTeamAndCheckMembership(event)
// ❌ Insecure - trusting client-provided teamId
const teamId = getQuery(event).teamId
const data = await db.select().from(tables.items)
.where(eq(tables.items.teamId, teamId))
2. Role-Based Access Control
Use membership roles to gate sensitive operations:
export default defineEventHandler(async (event) => {
const { membership } = await resolveTeamAndCheckMembership(event)
if (membership.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Admin access required'
})
}
// Safe to proceed with admin operations
})
3. Input Validation
Always validate request body and query parameters:
import { z } from 'zod'
const updateSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
isPublic: z.boolean().optional()
})
export default defineEventHandler(async (event) => {
const { team } = await resolveTeamAndCheckMembership(event)
const body = await readBody(event)
// Validate and throw 400 on invalid data
const validated = updateSchema.parse(body)
return await updateTeam(team.id, validated)
})
4. Rate Limiting
Consider rate limiting for expensive operations:
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
// Check rate limit
const requests = await rateLimit.check(user.id, 'list-items', {
maxRequests: 100,
windowMs: 60000 // 1 minute
})
if (!requests.success) {
throw createError({
statusCode: 429,
statusMessage: 'Too many requests. Please try again later.'
})
}
// Proceed
})
Troubleshooting
Handler Not Auto-Imported
If createExternalCollectionHandler is not found:
- Ensure Nuxt app is running (server needs to build imports)
- Check file is in
server/orapp/server/directory - Restart dev server:
pnpm dev
404 on Team Routes
If team endpoints return 404:
- Verify team slug or ID matches URL param
- Check user is actually a team member
- Confirm database has team record
Membership Check Fails
If resolveTeamAndCheckMembership throws 403:
- Log in with correct user account
- Verify user was added to team
- Check
teamMemberstable in database
External Collection Returns Empty
If CroutonReferenceSelect shows no options:
- Verify endpoint returns data:
curl http://localhost:3000/api/path - Check
idandtitlefields are present in response - Verify fetch function isn't throwing error (check server console)
TypeScript Errors with H3Event
If TypeScript complains about H3Event:
// ✅ Correct import
import type { H3Event, EventHandlerRequest } from 'h3'
// Or just use 'any' for quick development
export function resolveTeamAndCheckMembership(event: any) {
Related Resources
- useCollectionProxy - Client-side collection integration
- CroutonReferenceSelect - UI component for external collections
- Team-Based Auth - Team architecture and authorization patterns
- H3 Documentation - HTTP server framework