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:
- Drift — The API changes but the docs do not. A new parameter gets added in code but never appears in the reference. A deprecated endpoint stays in the docs for months after removal.
- Inconsistency — Different team members write docs in different styles. Some endpoints have detailed examples while others have a single sentence. Error responses are documented for some endpoints and missing for others.
- Bottleneck — Documentation becomes a chore that nobody wants to own. It blocks releases because someone has to manually update the portal before shipping a new version.
- Versioning — Supporting multiple API versions means maintaining multiple copies of the documentation. Without automation, this quickly becomes unmanageable.
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:
- 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.
- Markdown guides — Human-written conceptual guides, quickstart tutorials, and migration notes. These provide context that a generated reference alone cannot.
- Build script — Reads the spec and Markdown files, calls DocForge API endpoints to convert everything to HTML, and assembles the final portal.
- 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
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
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.
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.
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.
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.
docs/
v1/
openapi.yaml
guides/
quickstart.md
authentication.md
v2/
openapi.yaml
guides/
quickstart.md
authentication.md
migration-from-v1.md
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.
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:
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:
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:
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:
- Keep the OpenAPI spec as the single source of truth — All endpoint descriptions, parameter documentation, and response schemas should live in the spec file, not in separate Markdown files. This eliminates drift.
- Use Markdown guides for concepts, not references — Guides should explain the "why" and "how" at a conceptual level. Authentication flows, rate limiting strategies, error handling patterns. These are things that do not fit naturally in an OpenAPI spec.
- Version your docs alongside your code — When you cut a new API version, copy the docs directory. This ensures that the v1 docs remain accurate even after you release v2.
- Cache API responses in CI — Use content-hash caching to avoid re-converting unchanged Markdown files on every build. This keeps build times fast as your documentation grows.
- Add link validation — After the build, scan the output for broken internal links. A simple regex pass that checks for href values pointing to missing files catches most issues.
- Include a search index — Generate a JSON search index during the build from the text content of all pages. Client-side search libraries can load this index without requiring a backend search service.
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