Building Interactive Documentation with PersonQL and MDX


MDX (Markdown + JSX) enables you to embed interactive React components directly in your documentation. This creates engaging experiences that let users try features without leaving the docs.

Why Interactive Documentation?

Traditional documentation is static - users read about features but can’t try them. Interactive documentation lets users:

  • Test features live without setting up a development environment
  • See immediate results from code changes
  • Learn by doing instead of just reading
  • Copy working code that they’ve already tested

PersonQL’s documentation uses MDX extensively to provide interactive examples throughout our guides.

Basic MDX Syntax

MDX combines Markdown with JSX:

# Regular Markdown Heading

This is normal Markdown text with **bold** and _italic_.

import { Button } from './components/Button';

<Button onClick={() => alert('Hello!')}>
  Click me - I'm a React component!
</Button>

Back to regular Markdown.

Setting Up MDX in Your Project

Installation

npm install @astrojs/mdx

Configuration

// astro.config.mjs
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';

export default defineConfig({
  integrations: [mdx()],
});

Interactive PersonQL Examples

Live Sign-In Demo

Here’s an interactive sign-in form you can test right in the documentation:

import { SignInForm } from '../components/SignInForm';

## Try It Now

<SignInForm
  apiKey="pk_demo_test123"
  onSuccess={(user) => alert(`Signed in as ${user.email}`)}
/>

This form uses PersonQL's authentication API. Enter `demo@personql.com` and code `123456` to test.

SDK Installation Wizard

Create an interactive SDK selector:

import { SDKSelector } from '../components/SDKSelector';

## Choose Your SDK

<SDKSelector
  onSelect={(sdk) => {
    // Show installation instructions for selected SDK
    console.log('Selected:', sdk);
  }}
/>

Permission Checker

Let users test permission logic interactively:

import { PermissionChecker } from '../components/PermissionChecker';

## Test Permission Logic

<PermissionChecker
  permissions={['projects.read', 'projects.write', 'projects.delete']}
  userRole="editor"
/>

Try changing the role to see which permissions are granted.

Building Interactive Components

Simple Counter Example

// components/Counter.tsx
import React, { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div style={{ padding: '1rem', border: '1px solid #ccc', borderRadius: '8px' }}>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
      <button onClick={() => setCount(0)}>
        Reset
      </button>
    </div>
  );
}

Use it in MDX:

import { Counter } from '../components/Counter';

## Interactive Counter

<Counter />

This component maintains state and responds to user interaction.

Code Playground

Create a live code editor:

// components/CodePlayground.tsx
import React, { useState } from 'react';
import { PersonQL } from '@personql/react';

export function CodePlayground({ initialCode }) {
  const [code, setCode] = useState(initialCode);
  const [output, setOutput] = useState('');

  const runCode = async () => {
    try {
      const personql = new PersonQL({ apiKey: 'pk_demo_test' });
      // Execute user code safely
      const result = await eval(code);
      setOutput(JSON.stringify(result, null, 2));
    } catch (error) {
      setOutput(`Error: ${error.message}`);
    }
  };

  return (
    <div className="playground">
      <textarea
        value={code}
        onChange={(e) => setCode(e.target.value)}
        rows={10}
        style={{ width: '100%', fontFamily: 'monospace' }}
      />
      <button onClick={runCode}>Run Code</button>
      <pre>{output}</pre>
    </div>
  );
}
import { CodePlayground } from '../components/CodePlayground';

## Try This Code

<CodePlayground initialCode={`
// Sign in anonymously
const user = await personql.auth.signInAnonymously();
console.log(user);
`} />

Edit the code and click "Run Code" to see results.

Best Practices

1. Keep Components Focused

Each interactive component should demonstrate one concept:

# ✅ Good - Focused on one feature
<EmailOTPDemo />

## Explanation
This demo shows email OTP authentication...

# ❌ Bad - Too many features in one component
<CompleteAuthSystemDemo />

2. Provide Reset Functionality

Users should be able to reset to initial state:

export function Demo() {
  const [state, setState] = useState(initialState);

  return (
    <div>
      {/* Demo UI */}
      <button onClick={() => setState(initialState)}>
        Reset Demo
      </button>
    </div>
  );
}

3. Handle Errors Gracefully

Show error messages that help users understand what went wrong:

export function APIDemo() {
  const [error, setError] = useState('');

  const handleSubmit = async () => {
    try {
      const result = await personql.api.call();
      // Handle success
    } catch (err) {
      if (err.code === 'rate_limited') {
        setError('Too many requests. Please wait a moment and try again.');
      } else {
        setError(`Error: ${err.message}`);
      }
    }
  };

  return (
    <div>
      {/* Demo UI */}
      {error && <div className="error">{error}</div>}
    </div>
  );
}

4. Use Loading States

Show feedback during async operations:

export function AsyncDemo() {
  const [loading, setLoading] = useState(false);

  const handleAction = async () => {
    setLoading(true);
    try {
      await someAsyncOperation();
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handleAction} disabled={loading}>
      {loading ? 'Loading...' : 'Run Demo'}
    </button>
  );
}

5. Make It Obvious It’s Interactive

Add visual cues that elements are interactive:

.interactive-demo {
  border: 2px dashed #3b82f6;
  padding: 1rem;
  border-radius: 8px;
  background: #f0f9ff;
}

.interactive-demo::before {
  content: '💡 Try it yourself';
  display: block;
  font-weight: bold;
  margin-bottom: 0.5rem;
  color: #3b82f6;
}

Advanced Patterns

State Management Across Components

Share state between multiple MDX components:

// context/DemoContext.tsx
import React, { createContext, useContext, useState } from 'react';

const DemoContext = createContext(null);

export function DemoProvider({ children }) {
  const [user, setUser] = useState(null);

  return (
    <DemoContext.Provider value={{ user, setUser }}>
      {children}
    </DemoContext.Provider>
  );
}

export function useDemoUser() {
  return useContext(DemoContext);
}
import { DemoProvider } from '../context/DemoContext';
import { SignInDemo } from '../components/SignInDemo';
import { UserProfile } from '../components/UserProfile';

<DemoProvider>
  ## Sign In

  <SignInDemo />

  ## Your Profile

  <UserProfile />
</DemoProvider>

The profile updates automatically when you sign in above.

Synchronized Code and Preview

Show code and its result side-by-side:

export function CodePreview({ code }) {
  const [output, setOutput] = useState('');

  useEffect(() => {
    // Execute code and update output
    const result = executeCode(code);
    setOutput(result);
  }, [code]);

  return (
    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
      <pre>{code}</pre>
      <div className="output">{output}</div>
    </div>
  );
}

Testing Interactive Documentation

Test your interactive components to ensure they work:

// __tests__/SignInForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { SignInForm } from '../components/SignInForm';

test('sign in form works in documentation', async () => {
  render(<SignInForm apiKey="pk_test_123" />);

  fireEvent.change(screen.getByPlaceholderText('Email'), {
    target: { value: 'test@example.com' }
  });

  fireEvent.change(screen.getByPlaceholderText('Code'), {
    target: { value: '123456' }
  });

  fireEvent.click(screen.getByText('Sign In'));

  await waitFor(() => {
    expect(screen.getByText(/signed in/i)).toBeInTheDocument();
  });
});

Performance Considerations

Code Splitting

Load interactive components only when needed:

import { lazy, Suspense } from 'react';

const HeavyComponent = lazy(() => import('./components/HeavyComponent'));

export function Demo() {
  return (
    <Suspense fallback={<div>Loading demo...</div>}>
      <HeavyComponent />
    </Suspense>
  );
}

Memoization

Prevent unnecessary re-renders:

import { memo } from 'react';

export const ExpensiveComponent = memo(({ data }) => {
  // Only re-renders when data changes
  return <div>{/* Render data */}</div>;
});

Security Considerations

Sandboxing User Code

If letting users run code, sandbox it:

// Use a Web Worker or iframe for isolation
const worker = new Worker('code-executor.js');

worker.postMessage({ code: userCode });

worker.onmessage = (event) => {
  setOutput(event.data.result);
};

Rate Limiting

Prevent abuse of interactive demos:

import { useRateLimit } from '../hooks/useRateLimit';

export function APIDemo() {
  const { canMakeRequest, requestCount } = useRateLimit({
    maxRequests: 10,
    windowMs: 60000 // 10 requests per minute
  });

  const handleSubmit = async () => {
    if (!canMakeRequest) {
      alert(`Rate limit exceeded. ${requestCount}/10 requests used.`);
      return;
    }

    // Make API request
  };
}

Examples in PersonQL Docs

Visit our documentation to see interactive examples in action:

Authentication Examples

Multi-Tenant Demos

API Playground

Resources

Conclusion

MDX transforms static documentation into interactive learning experiences. By combining Markdown’s simplicity with React’s power, you can create documentation that users actually enjoy reading and using.

Start building interactive documentation with PersonQL today!

View complete documentation →