Skip to main content

React/Vite Integration

Complete guide for integrating the Users API into your React/Vite static site.

Overview

This tutorial shows you how to:

  1. Configure the API connection (hardcoded production values)
  2. Create API utility functions
  3. Build a form component
  4. Handle errors gracefully
  5. 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 .env file 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