Frontend Integration
Integrate Next.js, React, and other frontend frameworks with Magic Runtime's controller API. Type-safe client, error handling, and real-time patterns.
API Overview
The Magic Runtime exposes a REST API for executing controllers. All endpoints accept and return JSON.
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/execute/{controller} |
Execute a controller |
GET |
/api/controllers |
List deployed controllers |
GET |
/api/controllers/{name} |
Get controller metadata + contract |
GET |
/api/health |
Health check |
GET |
/api/readyz |
Readiness check |
Request / Response Example
# Execute a controller
curl -X POST https://your-magic-instance/api/execute/InvoiceSync \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"customer_id": "cust_123", "invoice_data": {"amount": 9900}}'
# Response
{
"result": { "sync_id": "inv_456", "status": "completed" },
"request_id": "req_abc123",
"duration_ms": 142,
"controller": "InvoiceSync",
"version": "1.0.0"
}
Tracing
All responses include request_id for tracing. Pass it in support tickets or log queries.
TypeScript Client
A type-safe client wrapper that handles authentication, error parsing, and response typing.
// lib/magic-client.ts
interface MagicResponse<T> {
result: T;
request_id: string;
duration_ms: number;
controller: string;
version: string;
}
interface MagicError {
error: string;
code: string; // E-code (e.g., E1001)
request_id: string;
detail?: string;
}
// Two auth modes: API Key (service-to-service) or Bearer token (user sessions)
type AuthMode =
| { mode: "api-key"; key: string }
| { mode: "bearer"; token: string };
class MagicClient {
private baseUrl: string;
private auth: AuthMode;
constructor(baseUrl: string, auth: AuthMode) {
this.baseUrl = baseUrl;
this.auth = auth;
}
private authHeader(): Record<string, string> {
return this.auth.mode === "api-key"
? { "X-API-Key": this.auth.key }
: { "Authorization": `Bearer ${this.auth.token}` };
}
async execute<TInput, TOutput>(
controller: string,
input: TInput
): Promise<MagicResponse<TOutput>> {
const res = await fetch(
`${this.baseUrl}/api/execute/${controller}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...this.authHeader(),
},
body: JSON.stringify(input),
}
);
if (!res.ok) {
const err: MagicError = await res.json();
throw new MagicExecutionError(err);
}
return res.json();
}
async listControllers(): Promise<string[]> {
const res = await fetch(`${this.baseUrl}/api/controllers`, {
headers: this.authHeader(),
});
return res.json();
}
}
class MagicExecutionError extends Error {
code: string;
requestId: string;
detail?: string;
constructor(err: MagicError) {
super(err.error);
this.name = "MagicExecutionError";
this.code = err.code;
this.requestId = err.request_id;
this.detail = err.detail;
}
}
// API Key mode (service-to-service, scripts, Server Actions)
export const magic = new MagicClient(
process.env.NEXT_PUBLIC_MAGIC_URL!,
{ mode: "api-key", key: process.env.MAGIC_API_KEY! }
);
// Bearer mode (user sessions, JWT forwarding)
// export const magic = new MagicClient(
// process.env.NEXT_PUBLIC_MAGIC_URL!,
// { mode: "bearer", token: userJwt }
// );
Next.js Integration
Use Server Actions to call Magic Runtime from Next.js components while keeping your API token server-side.
Server Action
// app/actions/sync-invoice.ts
"use server";
import { magic } from "@/lib/magic-client";
interface SyncInput {
customer_id: string;
invoice_data: { amount: number; currency: string };
}
interface SyncResult {
sync_id: string;
status: string;
}
export async function syncInvoice(input: SyncInput) {
const response = await magic.execute<SyncInput, SyncResult>(
"InvoiceSync",
input
);
return response.result;
}
API Route
// app/api/controllers/[name]/route.ts
import { magic } from "@/lib/magic-client";
import { NextRequest, NextResponse } from "next/server";
export async function POST(
req: NextRequest,
{ params }: { params: { name: string } }
) {
const input = await req.json();
try {
const result = await magic.execute(params.name, input);
return NextResponse.json(result);
} catch (err) {
if (err instanceof MagicExecutionError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: 422 }
);
}
return NextResponse.json(
{ error: "Internal error" },
{ status: 500 }
);
}
}
Security
Server Actions keep your MAGIC_API_KEY on the server. Never expose it to the browser.
React Hooks
A custom hook for executing controllers from client components, with loading state, error handling, and request tracing.
useMagic Hook
// hooks/use-magic.ts
import { useState, useCallback } from "react";
interface UseMagicOptions {
onError?: (error: Error) => void;
}
export function useMagic<TInput, TOutput>(
controller: string,
options?: UseMagicOptions
) {
const [data, setData] = useState<TOutput | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [requestId, setRequestId] = useState<string | null>(null);
const execute = useCallback(
async (input: TInput) => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/controllers/${controller}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Execution failed");
}
const response = await res.json();
setData(response.result);
setRequestId(response.request_id);
return response.result;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
options?.onError?.(error);
throw error;
} finally {
setLoading(false);
}
},
[controller, options]
);
return { execute, data, loading, error, requestId };
}
Usage in a Component
// components/InvoiceForm.tsx
"use client";
import { useMagic } from "@/hooks/use-magic";
export function InvoiceForm() {
const { execute, loading, error, requestId } = useMagic<
{ customer_id: string; invoice_data: object },
{ sync_id: string; status: string }
>("InvoiceSync");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await execute({
customer_id: formData.get("customerId") as string,
invoice_data: { amount: Number(formData.get("amount")), currency: "usd" },
});
}
return (
<form onSubmit={handleSubmit}>
<input name="customerId" placeholder="Customer ID" required />
<input name="amount" type="number" placeholder="Amount (cents)" required />
<button type="submit" disabled={loading}>
{loading ? "Syncing..." : "Sync Invoice"}
</button>
{error && <p className="error">{error.message}</p>}
{requestId && <p className="meta">Request: {requestId}</p>}
</form>
);
}
Error Handling
Magic Runtime uses structured E-codes to categorize errors. Map these to appropriate frontend behavior.
| Code | Category | Meaning | Frontend Action |
|---|---|---|---|
E1xxx |
CONTRACT | Input/output schema violation | Show validation errors to user |
E2xxx |
CAPABILITY | Controller lacks permission | Internal error — do not expose |
E3xxx |
EXECUTION | Controller runtime error | Show generic error + request_id |
E4xxx |
DEPLOYMENT | Controller not found | Check controller name spelling |
E5xxx |
AUTH | Authentication failure | Redirect to login |
Error Classification Helper
// lib/magic-errors.ts
export function classifyError(code: string): "validation" | "auth" | "internal" {
if (code.startsWith("E1")) return "validation";
if (code.startsWith("E5")) return "auth";
return "internal";
}
export function userMessage(code: string, detail?: string): string {
switch (classifyError(code)) {
case "validation":
return detail || "Please check your input and try again.";
case "auth":
return "Your session has expired. Please log in again.";
case "internal":
return "Something went wrong. Please try again or contact support.";
}
}
Debugging
Always log the request_id — it maps directly to Magic Runtime traces for debugging.
Authentication
Configure your environment with the Magic Runtime URL and API token.
# .env.local (Next.js)
NEXT_PUBLIC_MAGIC_URL=https://magic.your-company.com
# Mode A: API Key (service-to-service, Server Actions, SSR)
# This is the ADMIN_API_KEY value from your Magic Runtime .env file
MAGIC_API_KEY=your-admin-api-key-from-magic-runtime
# Mode B: Bearer / JWT (user sessions — token comes from your auth system)
# No env var needed; pass the JWT from the user's session
Warning
MAGIC_API_KEY is a server-side secret. Use the NEXT_PUBLIC_ prefix only for the base URL. The key must never reach the browser.
| Pattern | When to Use |
|---|---|
X-API-Key (API Key) |
Server Actions, API Routes, SSR, scripts — use MAGIC_API_KEY env var |
Authorization: Bearer (JWT) |
User sessions, SSO integration — token from your auth system |
| Session Proxy | Client components calling through your API route (API key stays server-side) |
Real-Time Updates
For real-time data, use polling or server-sent events until WebSocket support arrives in a future release.
Polling Hook
// hooks/use-magic-poll.ts
import { useEffect } from "react";
import { useMagic } from "./use-magic";
export function useMagicPoll<T>(
controller: string,
input: T,
intervalMs: number = 5000
) {
const { execute, data, loading } = useMagic(controller);
useEffect(() => {
execute(input);
const id = setInterval(() => execute(input), intervalMs);
return () => clearInterval(id);
}, [controller, intervalMs]);
return { data, loading };
}
Roadmap
WebSocket support is planned for v2.3 (Admin Console). Until then, use polling or server-sent events through your own API layer.
Testing
Test your Magic Runtime integration with mocked fetch calls using Vitest or Jest.
// __tests__/magic-client.test.ts
import { describe, it, expect, vi } from "vitest";
import { magic } from "@/lib/magic-client";
// Mock fetch for tests
global.fetch = vi.fn();
describe("MagicClient", () => {
it("executes a controller", async () => {
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
result: { sync_id: "inv_1", status: "completed" },
request_id: "req_test",
duration_ms: 50,
controller: "InvoiceSync",
version: "1.0.0",
}),
});
const res = await magic.execute("InvoiceSync", {
customer_id: "cust_1",
invoice_data: { amount: 100 },
});
expect(res.result.status).toBe("completed");
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/execute/InvoiceSync"),
expect.objectContaining({ method: "POST" })
);
});
it("throws MagicExecutionError on failure", async () => {
(fetch as any).mockResolvedValueOnce({
ok: false,
json: async () => ({
error: "Schema validation failed",
code: "E1001",
request_id: "req_err",
}),
});
await expect(
magic.execute("InvoiceSync", { bad: "input" })
).rejects.toThrow("Schema validation failed");
});
});