Developer portals are the front door to your API. They are where potential users decide whether to integrate with your product or move on to a competitor. Yet most teams treat documentation as an afterthought: hand-written pages that drift out of sync with the actual API within weeks of launch.

An API-first approach to documentation means your docs are generated from the source of truth — your OpenAPI spec, your Markdown guides, your changelog entries — and rebuilt automatically on every deploy. In this guide, we will build that workflow using the DocForge API as the conversion engine.

The Problem with Manual Developer Portals

Teams that maintain documentation by hand run into the same problems repeatedly:

The solution is to generate documentation from machine-readable sources and automate the entire pipeline from spec to published portal.

Architecture Overview

Our automated developer portal will have four components:

  1. OpenAPI spec — The single source of truth for your API's endpoints, request/response schemas, and authentication requirements. Lives in your repository as openapi.yaml.
  2. Markdown guides — Human-written conceptual guides, quickstart tutorials, and migration notes. These provide context that a generated reference alone cannot.
  3. Build script — Reads the spec and Markdown files, calls DocForge API endpoints to convert everything to HTML, and assembles the final portal.
  4. CI/CD pipeline — Triggers the build on every push and deploys the result to your hosting provider.

Step 1: Extracting Docs from Your OpenAPI Spec

An OpenAPI spec contains everything you need to generate a reference page for each endpoint: the path, method, summary, description, parameters, request body schema, and response schemas. The first step is to parse the spec and convert the descriptions from Markdown to HTML.

JavaScript Implementation

JavaScript
import { readFile } from 'node:fs/promises';

const YAML_TO_JSON = 'https://docforge-api.vercel.app/api/yaml-to-json';
const MD_TO_HTML = 'https://docforge-api.vercel.app/api/md-to-html';

async function loadSpec(specPath) {
  const yamlContent = await readFile(specPath, 'utf-8');

  // Convert YAML spec to JSON using DocForge
  const response = await fetch(YAML_TO_JSON, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ yaml: yamlContent })
  });

  const { json } = await response.json();
  return JSON.parse(json);
}

async function convertDescription(markdown) {
  if (!markdown) return '';

  const response = await fetch(MD_TO_HTML, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ markdown })
  });

  const { html } = await response.json();
  return html;
}

async function extractEndpoints(spec) {
  const endpoints = [];

  for (const [path, methods] of Object.entries(spec.paths)) {
    for (const [method, details] of Object.entries(methods)) {
      if (['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
        const descriptionHtml = await convertDescription(
          details.description
        );

        endpoints.push({
          path,
          method: method.toUpperCase(),
          summary: details.summary || '',
          descriptionHtml,
          parameters: details.parameters || [],
          requestBody: details.requestBody || null,
          responses: details.responses || {},
          tags: details.tags || []
        });
      }
    }
  }

  return endpoints;
}

Python Implementation

Python
import json
import requests
from pathlib import Path

YAML_TO_JSON = "https://docforge-api.vercel.app/api/yaml-to-json"
MD_TO_HTML = "https://docforge-api.vercel.app/api/md-to-html"

def load_spec(spec_path):
    yaml_content = Path(spec_path).read_text(encoding="utf-8")

    # Convert YAML spec to JSON using DocForge
    resp = requests.post(
        YAML_TO_JSON,
        json={"yaml": yaml_content}
    )
    result = resp.json()
    return json.loads(result["json"])

def convert_description(markdown):
    if not markdown:
        return ""

    resp = requests.post(
        MD_TO_HTML,
        json={"markdown": markdown}
    )
    return resp.json()["html"]

def extract_endpoints(spec):
    endpoints = []
    for path, methods in spec.get("paths", {}).items():
        for method, details in methods.items():
            if method in ("get", "post", "put", "patch", "delete"):
                endpoints.append({
                    "path": path,
                    "method": method.upper(),
                    "summary": details.get("summary", ""),
                    "description_html": convert_description(
                        details.get("description", "")
                    ),
                    "parameters": details.get("parameters", []),
                    "responses": details.get("responses", {}),
                    "tags": details.get("tags", [])
                })
    return endpoints

Step 2: Building the Reference Pages

With the endpoints extracted and descriptions converted to HTML, the next step is to render each endpoint into a documentation page. We use a template approach similar to the SSG pattern, but tailored for API reference content.

JavaScript
function renderEndpointPage(endpoint) {
  const paramRows = endpoint.parameters.map(p => `
    <tr>
      <td><code>${p.name}</code></td>
      <td>${p.in}</td>
      <td>${p.required ? 'Required' : 'Optional'}</td>
      <td>${p.description || ''}</td>
    </tr>`).join('\n');

  const responseBlocks = Object.entries(endpoint.responses)
    .map(([code, detail]) => `
    <h4>${code} ${detail.description || ''}</h4>
    ${detail.content ? renderSchema(detail.content) : ''}`)
    .join('\n');

  return `
  <section class="endpoint">
    <div class="method-badge method-${endpoint.method.toLowerCase()}">
      ${endpoint.method}
    </div>
    <h2><code>${endpoint.path}</code></h2>
    <p class="summary">${endpoint.summary}</p>
    <div class="description">${endpoint.descriptionHtml}</div>

    ${paramRows ? `
    <h3>Parameters</h3>
    <table>
      <thead>
        <tr><th>Name</th><th>In</th><th>Required</th><th>Description</th></tr>
      </thead>
      <tbody>${paramRows}</tbody>
    </table>` : ''}

    <h3>Responses</h3>
    ${responseBlocks}
  </section>`;
}

Step 3: Converting Markdown Guides

API reference pages are essential but not sufficient. Developers also need conceptual guides, quickstart tutorials, and troubleshooting pages. These are best written in Markdown and stored alongside your codebase.

Directory Layout
docs/
  guides/
    quickstart.md
    authentication.md
    error-handling.md
    rate-limiting.md
    migration-v1-to-v2.md
  reference/         # auto-generated from OpenAPI spec
  changelog/
    v2.1.0.md
    v2.0.0.md
    v1.0.0.md

The build script converts each Markdown guide to HTML the same way our SSG does — read the file, extract frontmatter, send the body to the DocForge /api/md-to-html endpoint, and inject the result into a template.

JavaScript
async function buildGuides(guidesDir) {
  const files = await getMarkdownFiles(guidesDir);
  const guides = [];

  for (const file of files) {
    const raw = await readFile(file, 'utf-8');
    const { metadata, body } = parseFrontmatter(raw);

    const response = await fetch(MD_TO_HTML, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ markdown: body })
    });

    const { html, meta } = await response.json();

    guides.push({
      slug: metadata.slug || file.replace(/\.md$/, ''),
      title: metadata.title,
      order: parseInt(metadata.order || '99', 10),
      html,
      headings: meta.headings
    });
  }

  // Sort guides by the order field from frontmatter
  return guides.sort((a, b) => a.order - b.order);
}

Step 4: Version Management

When your API supports multiple versions, your documentation needs to support them too. The cleanest approach is to store versioned specs and guides in separate directories, then build each version into its own URL namespace.

Directory Layout
docs/
  v1/
    openapi.yaml
    guides/
      quickstart.md
      authentication.md
  v2/
    openapi.yaml
    guides/
      quickstart.md
      authentication.md
      migration-from-v1.md
JavaScript
async function buildVersionedPortal(docsDir) {
  const versions = await readdir(docsDir);
  const portal = {};

  for (const version of versions) {
    const versionDir = join(docsDir, version);
    const specPath = join(versionDir, 'openapi.yaml');
    const guidesDir = join(versionDir, 'guides');

    // Load and parse the OpenAPI spec for this version
    const spec = await loadSpec(specPath);
    const endpoints = await extractEndpoints(spec);

    // Convert all guides for this version
    const guides = await buildGuides(guidesDir);

    portal[version] = {
      info: spec.info,
      endpoints,
      guides
    };

    // Write each version to its own directory
    const outDir = join(DIST_DIR, 'docs', version);
    await writeVersionPages(outDir, portal[version]);

    console.log(
      `Built ${version}: ${endpoints.length} endpoints, ${guides.length} guides`
    );
  }

  // Generate a version index page
  await writeVersionIndex(portal);
}

This approach produces clean URLs like /docs/v2/guides/quickstart and /docs/v1/reference/create-user. A version selector in the navigation lets users switch between versions.

Step 5: Building the Navigation Sidebar

A developer portal needs structured navigation. We can generate the sidebar from the guide frontmatter (using the order and title fields) and the endpoint tags from the OpenAPI spec.

JavaScript
function buildSidebar(endpoints, guides) {
  // Group endpoints by tag
  const tagGroups = {};
  for (const ep of endpoints) {
    const tag = ep.tags[0] || 'General';
    if (!tagGroups[tag]) tagGroups[tag] = [];
    tagGroups[tag].push(ep);
  }

  let html = '<nav class="sidebar">';

  // Guides section
  html += '<h3>Guides</h3><ul>';
  for (const guide of guides) {
    html += `<li><a href="/docs/${guide.slug}">${guide.title}</a></li>`;
  }
  html += '</ul>';

  // Reference section grouped by tag
  html += '<h3>API Reference</h3>';
  for (const [tag, eps] of Object.entries(tagGroups)) {
    html += `<h4>${tag}</h4><ul>`;
    for (const ep of eps) {
      html += `<li>
        <span class="method-${ep.method.toLowerCase()}">${ep.method}</span>
        <a href="/docs/reference${ep.path}">${ep.summary}</a>
      </li>`;
    }
    html += '</ul>';
  }

  html += '</nav>';
  return html;
}

Step 6: CI/CD Integration

The entire portal should rebuild automatically when any source file changes. Here is a GitHub Actions workflow that builds the portal and deploys it on every push to the main branch:

.github/workflows/docs.yml
name: Build Developer Portal

on:
  push:
    branches: [main]
    paths:
      - 'docs/**'
      - 'openapi.yaml'
      - 'scripts/build-portal.js'

jobs:
  build-docs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Build portal
        run: node scripts/build-portal.js
        env:
          DOCFORGE_API_KEY: ${{ secrets.DOCFORGE_API_KEY }}

      - name: Validate HTML output
        run: |
          # Check that all expected pages were generated
          test -f dist/docs/index.html
          test -f dist/docs/v2/reference/index.html
          test -f dist/docs/v2/guides/quickstart/index.html
          echo "All expected pages present"

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          working-directory: ./dist/docs

The key advantage of this setup is that documentation deployment is fully automated. When an engineer changes the OpenAPI spec or updates a guide, the portal rebuilds and deploys within minutes. There is no manual step and no chance of docs drifting from the actual API.

Step 7: Generating the Changelog

Changelogs are often the most-read part of a developer portal. Developers check them to understand what changed before upgrading. We can generate the changelog from Markdown files, one per version:

JavaScript
async function buildChangelog(changelogDir) {
  const files = await getMarkdownFiles(changelogDir);
  const entries = [];

  for (const file of files) {
    const raw = await readFile(file, 'utf-8');
    const { metadata, body } = parseFrontmatter(raw);

    const response = await fetch(MD_TO_HTML, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ markdown: body })
    });

    const { html } = await response.json();

    entries.push({
      version: metadata.version,
      date: metadata.date,
      html
    });
  }

  // Sort by version (newest first)
  entries.sort((a, b) => b.version.localeCompare(a.version, undefined, {
    numeric: true
  }));

  // Combine all entries into a single changelog page
  const changelogHtml = entries.map(e => `
    <section class="changelog-entry">
      <h2>${e.version} <span class="date">${e.date}</span></h2>
      ${e.html}
    </section>`).join('\n');

  return changelogHtml;
}

Putting It All Together

The complete build script ties together all the steps into a single entry point:

JavaScript
async function buildPortal() {
  console.log('Building developer portal...');

  // 1. Load the OpenAPI spec
  const spec = await loadSpec('./openapi.yaml');
  console.log(`Loaded spec: ${spec.info.title} v${spec.info.version}`);

  // 2. Extract and convert endpoint documentation
  const endpoints = await extractEndpoints(spec);
  console.log(`Processed ${endpoints.length} endpoints`);

  // 3. Build conceptual guides from Markdown
  const guides = await buildGuides('./docs/guides');
  console.log(`Built ${guides.length} guides`);

  // 4. Build the changelog
  const changelog = await buildChangelog('./docs/changelog');
  console.log('Built changelog');

  // 5. Generate navigation sidebar
  const sidebar = buildSidebar(endpoints, guides);

  // 6. Write all output files
  await writeReferencePages(endpoints, sidebar);
  await writeGuidePages(guides, sidebar);
  await writeChangelogPage(changelog, sidebar);
  await writeIndexPage(spec.info, sidebar);

  console.log('Portal build complete.');
}

buildPortal();

Best Practices

After building developer portals for several projects using this approach, here are the patterns that work best:

Summary

An API-first documentation workflow eliminates the drift between your API and its documentation. By using the DocForge API as the conversion engine, you avoid installing Markdown and YAML parsing libraries in your build pipeline. The OpenAPI spec is your source of truth for reference docs, Markdown files provide conceptual guides, and a CI/CD pipeline ensures everything is rebuilt and deployed automatically on every push.

The DocForge endpoints used in this workflow — /api/md-to-html for Markdown conversion and /api/yaml-to-json for spec parsing — are both available on the free tier with 500 requests per day. For production portals with frequent builds, the Pro plan at $9/month provides 50,000 requests per day, which is more than enough for even the largest documentation sites.

Automate Your Developer Portal

500 requests/day free. No API key required. Build docs that never go stale.

Try It Live