React/Vite Integration
Complete guide for integrating the Users API into your React/Vite static site.
Overview
This tutorial shows you how to:
- Configure the API connection (hardcoded production values)
- Create API utility functions
- Build a form component
- Handle errors gracefully
- Optionally fetch and use the schema dynamically
Step 1: Project Setup
Create Vite Project
If you don't have a project yet:
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
Install Dependencies
No additional dependencies needed - we'll use the built-in fetch API.
Step 2: Configuration
Create Config Module
Create src/config/users.ts with hardcoded production values:
/**
* Users service configuration.
*
* Production values are hardcoded. For local development, you can override
* USERS_API_BASE_URL by setting VITE_USERS_API_BASE_URL in .env file.
*/
// Production API URL (can be overridden with VITE_USERS_API_BASE_URL for local dev)
export const USERS_API_BASE_URL =
import.meta.env.VITE_USERS_API_BASE_URL || "https://users-api.brainylab.io";
// App identifier - unique per project
export const USERS_APP_ID = "my-app-id";
Key Points:
- Production API URL is hardcoded (no env vars needed in production)
- App ID is hardcoded per project (unique identifier for your app)
- Optional
.envfile can override API URL for local development - No environment variables required for deployment
Optional: Local Development Override
If you need to test against a local API, create a .env file:
VITE_USERS_API_BASE_URL=http://127.0.0.1:3000
Note: The .env file is only needed for local development. Production uses the hardcoded value.
Step 3: API Utility Functions
Create src/lib/api.ts:
import { USERS_API_BASE_URL, USERS_APP_ID } from "../config/users";
export interface UserData {
email: string;
data?: Record<string, unknown>;
}
export interface CreateUserResponse {
id: string;
createdAt: string;
updatedAt: string;
}
export interface SchemaField {
key: string;
label: string;
kind: "text" | "checkbox" | "select" | "number" | "date";
required: boolean;
minLength?: number;
maxLength?: number;
regex?: string;
enum?: string[];
min?: number;
max?: number;
}
export interface UserSchema {
version: number;
rejectUnknown: boolean;
fields: SchemaField[];
}
/**
* Submit user data to the API
*/
export async function createUser(
email: string,
data: Record<string, unknown>
): Promise<CreateUserResponse> {
const url = `${USERS_API_BASE_URL}/v1/users`;
const res = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-app-id": USERS_APP_ID,
},
body: JSON.stringify({ email, data }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as {
message?: unknown;
};
const message =
typeof data?.message === "string" ? data.message : "Request failed";
throw new Error(message);
}
return await res.json();
}
/**
* Fetch the user schema for this app
*/
export async function fetchSchema(): Promise<UserSchema> {
const url = `${USERS_API_BASE_URL}/v1/schema`;
const res = await fetch(url, {
headers: {
"x-app-id": USERS_APP_ID,
},
});
if (!res.ok) {
throw new Error(`Failed to fetch schema: ${res.status} ${res.statusText}`);
}
return await res.json();
}
Step 4: Basic Form Component
Create src/components/UserForm.tsx:
import { useState, FormEvent } from "react";
import { createUser } from "../lib/api";
export function UserForm() {
const [email, setEmail] = useState("");
const [fullName, setFullName] = useState("");
const [consentRequired, setConsentRequired] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(false);
setIsSubmitting(true);
try {
await createUser(email, {
fullName: fullName.trim(),
consentRequired,
});
setSuccess(true);
// Reset form
setEmail("");
setFullName("");
setConsentRequired(false);
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="user-form">
<h2>Sign Up</h2>
{error && (
<div className="error" role="alert">
{error}
</div>
)}
{success && (
<div className="success" role="alert">
Thank you! Your information has been submitted.
</div>
)}
<div className="form-group">
<label htmlFor="email">
Email <span className="required">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className="form-group">
<label htmlFor="fullName">Full Name</label>
<input
id="fullName"
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={consentRequired}
onChange={(e) => setConsentRequired(e.target.checked)}
disabled={isSubmitting}
required
/>
<span>
I consent to the terms and conditions{" "}
<span className="required">*</span>
</span>
</label>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
</form>
);
}
Step 5: Advanced: Schema-Driven Form
For a dynamic form that adapts to your app's schema:
Create src/components/SchemaDrivenForm.tsx:
import { useState, useEffect, FormEvent } from "react";
import {
createUser,
fetchSchema,
type UserSchema,
type SchemaField,
} from "../lib/api";
export function SchemaDrivenForm() {
const [schema, setSchema] = useState<UserSchema | null>(null);
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(true);
// Fetch schema on mount
useEffect(() => {
fetchSchema()
.then(setSchema)
.catch((err) => {
console.error("Failed to fetch schema:", err);
setError("Failed to load form configuration");
})
.finally(() => setLoading(false));
}, []);
const handleFieldChange = (key: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(false);
setIsSubmitting(true);
try {
await createUser(email, formData);
setSuccess(true);
setEmail("");
setFormData({});
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
} finally {
setIsSubmitting(false);
}
};
const renderField = (field: SchemaField) => {
const value = formData[field.key];
switch (field.kind) {
case "text":
return (
<div key={field.key} className="form-group">
<label htmlFor={field.key}>
{field.label}
{field.required && <span className="required">*</span>}
</label>
<input
id={field.key}
type="text"
value={(value as string) || ""}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
required={field.required}
minLength={field.minLength}
maxLength={field.maxLength}
pattern={field.regex}
disabled={isSubmitting}
/>
</div>
);
case "checkbox":
return (
<div key={field.key} className="form-group">
<label>
<input
type="checkbox"
checked={value === true}
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
required={field.required}
disabled={isSubmitting}
/>
<span>
{field.label}
{field.required && <span className="required">*</span>}
</span>
</label>
</div>
);
case "select":
return (
<div key={field.key} className="form-group">
<label htmlFor={field.key}>
{field.label}
{field.required && <span className="required">*</span>}
</label>
<select
id={field.key}
value={(value as string) || ""}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
required={field.required}
disabled={isSubmitting}>
<option value="">Select...</option>
{field.enum?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
);
case "number":
return (
<div key={field.key} className="form-group">
<label htmlFor={field.key}>
{field.label}
{field.required && <span className="required">*</span>}
</label>
<input
id={field.key}
type="number"
value={(value as number) || ""}
onChange={(e) =>
handleFieldChange(field.key, parseFloat(e.target.value) || 0)
}
required={field.required}
min={field.min}
max={field.max}
disabled={isSubmitting}
/>
</div>
);
case "date":
return (
<div key={field.key} className="form-group">
<label htmlFor={field.key}>
{field.label}
{field.required && <span className="required">*</span>}
</label>
<input
id={field.key}
type="date"
value={(value as string) || ""}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
required={field.required}
disabled={isSubmitting}
/>
</div>
);
default:
return null;
}
};
if (loading) {
return <div>Loading form...</div>;
}
if (!schema) {
return <div>Failed to load form configuration</div>;
}
return (
<form onSubmit={handleSubmit} className="schema-driven-form">
<h2>Sign Up</h2>
{error && (
<div className="error" role="alert">
{error}
</div>
)}
{success && (
<div className="success" role="alert">
Thank you! Your information has been submitted.
</div>
)}
<div className="form-group">
<label htmlFor="email">
Email <span className="required">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
{schema.fields.map(renderField)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
</form>
);
}
Step 6: Use in Your App
Update src/App.tsx:
import { UserForm } from "./components/UserForm";
// or
// import { SchemaDrivenForm } from "./components/SchemaDrivenForm";
function App() {
return (
<div className="app">
<UserForm />
{/* or <SchemaDrivenForm /> */}
</div>
);
}
export default App;
Step 7: Styling (Optional)
Add basic styles in src/index.css:
.user-form,
.schema-driven-form {
max-width: 500px;
margin: 2rem auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="email"],
.form-group input[type="number"],
.form-group input[type="date"],
.form-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.form-group input[type="checkbox"] {
margin-right: 0.5rem;
}
.required {
color: red;
}
.error {
padding: 1rem;
background-color: #fee;
border: 1px solid #fcc;
border-radius: 4px;
color: #c00;
margin-bottom: 1rem;
}
.success {
padding: 1rem;
background-color: #efe;
border: 1px solid #cfc;
border-radius: 4px;
color: #0c0;
margin-bottom: 1rem;
}
button {
padding: 0.75rem 1.5rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
button:hover:not(:disabled) {
background-color: #0056b3;
}
Testing
Development
npm run dev
Visit http://localhost:5173 and test your form.
Production Build
npm run build
The built files will be in dist/ - ready to deploy as a static site.
Next Steps
- Deployment Guide - Deploy your static site to Dokku
- API Reference - Complete API documentation