> ## Documentation Index
> Fetch the complete documentation index at: https://docs.markpdf.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Astro

> API Route, Astro Action and content collections in Astro to convert documents without exposing the key.

# Astro

Use a server endpoint (API Route or Astro Action) so that the API key never reaches the browser.

<Warning>
  Never put `FLASH_MD_API_KEY` in an Astro `PUBLIC_*` variable or reference it from a `.astro` component. Any variable prefixed with `PUBLIC_` is included in the client bundle. Save it as a normal server variable and only read it from code that runs in `src/pages/api/*` or Actions.
</Warning>

## Basic endpoint

```ts theme={null}
// src/pages/api/convert.ts
import type { APIRoute } from "astro";

const API_URL = import.meta.env.FLASH_MD_API_URL;
const API_KEY = import.meta.env.FLASH_MD_API_KEY;

async function pollJob(jobId: string): Promise<string> {
  while (true) {
    await new Promise((r) => setTimeout(r, 5000));
    const res = await fetch(`${API_URL}/jobs/${jobId}`, {
      headers: { "x-api-key": API_KEY },
    });
    const data = await res.json();
    if (data.status === "completed") return data.body;
    if (data.status === "failed") throw new Error(data.error);
  }
}

export const POST: APIRoute = async ({ request }) => {
  const form = await request.formData();
  const file = form.get("file") as File | null;

  if (!file) {
    return new Response(JSON.stringify({ error: "Missing file" }), { status: 400 });
  }

  const res = await fetch(
    `${API_URL}/convert/raw?filename=${encodeURIComponent(file.name)}&mode=fast`,
    {
      method: "POST",
      headers: {
        "x-api-key": API_KEY,
        "content-type": file.type || "application/octet-stream",
      },
      body: file,
    }
  );

  if (res.status === 200) {
    return new Response(await res.text(), {
      headers: { "content-type": "text/markdown; charset=utf-8" },
    });
  }

  if (res.status === 202) {
    const job = await res.json();
    const markdown = await pollJob(job.job_id);
    return new Response(markdown, {
      headers: { "content-type": "text/markdown; charset=utf-8" },
    });
  }

  return new Response(await res.text(), { status: res.status });
};
```

## Form on the client

```astro theme={null}
---
// src/pages/upload.astro
---
  <form id="form">
    <input type="file" name="file" accept=".pdf,.docx,.csv,.xlsx,.pptx,.txt" required />
    <button type="submit">Convertir</button>
  </form>
  <pre id="output"></pre>

  <script>
    const form = document.querySelector("form")!;
    const output = document.getElementById("output")!;

    form.addEventListener("submit", async (e) => {
      e.preventDefault();
      output.textContent = "Convirtiendo...";
      const data = new FormData(form);
      const res = await fetch("/api/convert", { method: "POST", body: data });
      output.textContent = await res.text();
    });
  </script>
```

<Tip>
  You need `output: "server"` or `output: "hybrid"` on `astro.config.mjs` for server endpoints to work.
</Tip>

## Astro Action

[Astro Actions](https://docs.astro.build/en/guides/actions/) are a typed alternative to API Routes: they are called from the client as normal functions (without manual `fetch`) and Astro takes care of serialization. They are a good option when you already have progressive forms (`<form>` with vavalidation that works without JS) or want vavalidation with Zod before touching the API.

```ts theme={null}
// src/actions/index.ts
import { defineAction } from "astro:actions";
import { ActionError } from "astro:actions";
import { z } from "astro:schema";

const API_URL = import.meta.env.FLASH_MD_API_URL;
const API_KEY = import.meta.env.FLASH_MD_API_KEY;

async function pollJob(jobId: string): Promise<string> {
  const deadline = Date.now() + 5 * 60_000; // 5 minutos de presupuesto total
  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, 5000));
    const res = await fetch(`${API_URL}/jobs/${jobId}`, {
      headers: { "x-api-key": API_KEY },
    });
    const data = await res.json();
    if (data.status === "completed") return data.body;
    if (data.status === "failed") {
      throw new ActionError({ code: "BAD_REQUEST", message: data.error ?? "Conversión falvalida" });
    }
  }
  throw new ActionError({ code: "TIMEOUT", message: "El job tardó demasiado en completarse" });
}

export const server = {
  convertDocument: defineAction({
    accept: "form",
    input: z.object({
      file: z.instanceof(File),
      mode: z.enum(["fast", "ultra_fast", "balanced", "quality", "auto"]).default("fast"),
    }),
    handler: async ({ file, mode }) => {
      if (file.size === 0) {
        throw new ActionError({ code: "BAD_REQUEST", message: "Empty file" });
      }

      const res = await fetch(
        `${API_URL}/convert/raw?filename=${encodeURIComponent(file.name)}&mode=${mode}`,
        {
          method: "POST",
          headers: {
            "x-api-key": API_KEY,
            "content-type": file.type || "application/octet-stream",
          },
          body: file,
        }
      );

      if (res.status === 200) {
        return { markdown: await res.text() };
      }

      if (res.status === 202) {
        const job = await res.json();
        return { markdown: await pollJob(job.job_id) };
      }

      const detail = await res.json().catch(() => ({ detail: res.statusText }));

      if (res.status === 401) {
        throw new ActionError({ code: "UNAUTHORIZED", message: "API key invávalida" });
      }
      if (res.status === 403) {
        throw new ActionError({ code: "FORBIDDEN", message: detail.detail });
      }
      if (res.status === 413) {
        throw new ActionError({ code: "PAYLOAD_TOO_LARGE", message: detail.detail });
      }
      if (res.status === 415) {
        throw new ActionError({ code: "UNSUPPORTED_MEDIA_TYPE", message: detail.detail });
      }
      if (res.status === 422) {
        throw new ActionError({ code: "BAD_REQUEST", message: detail.detail });
      }
      if (res.status === 429) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: detail.detail });
      }
      throw new ActionError({ code: "INTERNAL_SERVER_ERROR", message: detail.detail });
    },
  }),
};
```

Using from an Astro component (progressive form, works without JavaScript):

```astro theme={null}
---
// src/pages/convert.astro
import { actions } from "astro:actions";
---
<form method="POST" action={actions.convertDocument}>
  <input type="file" name="file" accept=".pdf,.docx,.csv,.xlsx,.pptx,.txt" required />
  <select name="mode">
    <option value="fast">Rápido</option>
    <option value="balanced">Balanceado</option>
    <option value="quality">Cavalidad</option>
  </select>
  <button type="submit">Convertir</button>
</form>
```

Or from a client script for a SPA experience:

```astro theme={null}
<script>
  import { actions, isInputError } from "astro:actions";

  const form = document.querySelector("form")!;

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const { data, error } = await actions.convertDocument(new FormData(form));

    if (error) {
      if (isInputError(error)) {
        console.error("Vavalidación:", error.fields);
      } else {
        console.error(`[${error.code}]`, error.message);
      }
      return;
    }

    console.log(data.markdown);
  });
</script>
```

## Ingest into a content collection

A common use case in Astro is converting a batch of PDFs (whitepapers, manuals, minutes) to Markdown during the build to feed a [Content Collection](https://docs.astro.build/en/guides/content-collections/). This script runs in Node before the build, not in a request:

```ts theme={null}
// scripts/ingest-pdfs.ts
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import { extname, basename, join } from "node:path";

const API_URL = process.env.FLASH_MD_API_URL!;
const API_KEY = process.env.FLASH_MD_API_KEY!;
const SOURCE_DIR = "raw-pdfs";
const OUTPUT_DIR = "src/content/docs";

async function convertOne(path: string, filename: string): Promise<string> {
  const buffer = await readFile(path);

  const res = await fetch(
    `${API_URL}/convert/raw?filename=${encodeURIComponent(filename)}&mode=quality&clean=true`,
    {
      method: "POST",
      headers: { "x-api-key": API_KEY, "content-type": "application/pdf" },
      body: buffer,
    }
  );

  if (res.status === 200) return res.text();

  if (res.status === 202) {
    const { job_id } = await res.json();
    while (true) {
      await new Promise((r) => setTimeout(r, 5000));
      const jobRes = await fetch(`${API_URL}/jobs/${job_id}`, {
        headers: { "x-api-key": API_KEY },
      });
      const job = await jobRes.json();
      if (job.status === "completed") return job.body;
      if (job.status === "failed") throw new Error(`Job ${job_id} falló: ${job.error}`);
    }
  }

  const detail = await res.json().catch(() => ({ detail: res.statusText }));
  throw new Error(`${filename}: HTTP ${res.status} - ${detail.detail}`);
}

async function main() {
  await mkdir(OUTPUT_DIR, { recursive: true });
  const files = (await readdir(SOURCE_DIR)).filter((f) => extname(f) === ".pdf");

  for (const file of files) {
    const slug = basename(file, ".pdf");
    console.log(`Convirtiendo ${file}...`);

    try {
      const markdown = await convertOne(join(SOURCE_DIR, file), file);
      const frontmatter = `---\ntitle: "${slug}"\n---\n\n`;
      await writeFile(join(OUTPUT_DIR, `${slug}.md`), frontmatter + markdown);
    } catch (err) {
      console.error(`Error en ${file}:`, err);
    }
  }
}

main();
```

Run it as a previous step to build:

```json theme={null}
// package.json
{
  "scripts": {
    "prebuild": "tsx scripts/ingest-pdfs.ts",
    "build": "astro build"
  }
}
```

## Language error handling

In either approach (API Route or Action), map each status code to a clear response instead of propagating a generic 500:

```ts theme={null}
// src/pages/api/convert.ts (fragment)
function mapUpstreamError(status: number, detail: string): Response {
  switch (status) {
    case 400:
    case 422:
      return new Response(JSON.stringify({ error: detail }), { status });
    case 401:
      return new Response(JSON.stringify({ error: "API key invávalida en el servidor" }), { status: 401 });
    case 403:
      return new Response(JSON.stringify({ error: "Origen no permitido" }), { status: 403 });
    case 413:
      return new Response(JSON.stringify({ error: "Document too large" }), { status: 413 });
    case 415:
      return new Response(JSON.stringify({ error: "Formato no soportado" }), { status: 415 });
    case 429:
      return new Response(JSON.stringify({ error: "Too many requests, retry in a few seconds" }), { status: 429 });
    default:
      return new Response(JSON.stringify({ error: "Could not convert the document" }), { status: 502 });
  }
}
```

## Production

<Note>
  In serverless deployments (Vercel, Netlify, Cloudflare adapters), check the lifetime limit of your function before polling long jobs: if the `job_id` can take several minutes, consider returning the `job_id` to the client and having it poll directly (with its own read-only key or through a proxy endpoint), instead of blocking the server function.
</Note>

### Timeout with `fetch`

```ts theme={null}
async function convertWithTimeout(file: File, timeoutMs = 30_000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    return await fetch(
      `${API_URL}/convert/raw?filename=${encodeURIComponent(file.name)}&mode=fast`,
      {
        method: "POST",
        headers: { "x-api-key": API_KEY, "content-type": file.type },
        body: file,
        signal: controller.signal,
      }
    );
  } finally {
    clearTimeout(timer);
  }
}
```

### Retries with exponential backoff

Only retry on `429` and `5xx`; other `4xx` errors are not fixed by retrying.

```ts theme={null}
async function fetchWithRetry(url: string, init: RequestInit, maxAttempts = 4): Promise<Response> {
  let attempt = 0;
  while (true) {
    attempt++;
    const res = await fetch(url, init);

    if (res.status !== 429 && res.status < 500) return res;
    if (attempt >= maxAttempts) return res;

    const backoffMs = Math.min(1000 * 2 ** attempt, 15_000);
    const jitter = Math.random() * 250;
    await new Promise((r) => setTimeout(r, backoffMs + jitter));
  }
}
```

### Large files with BYOS (`output_url`)

For large documents, avoid Markdown traveling through the response body: upload the result directly to your own storage with a pre-signed URL.

```ts theme={null}
// src/pages/api/convert-large.ts
export const POST: APIRoute = async ({ request }) => {
  const { url, putUrl, headUrl } = await request.json();

  const res = await fetch(
    `${API_URL}/convert/from-url`,
    {
      method: "POST",
      headers: { "x-api-key": API_KEY, "content-type": "application/json" },
      body: JSON.stringify({
        url,
        mode: "balanced",
        output_url: putUrl,       // pre-signed PUT to your bucket
        output_head_url: headUrl, // pre-signed HEAD, avoids reprocessing if it already exists
      }),
    }
  );

  // output_url auto-enable response_format=json, output_encoding=zstd, slim=true
  const body = await res.json();
  return new Response(JSON.stringify(body), { status: res.status });
};
```

<Tip>
  With `output_url`, the API responds with a small JSON (metadata + confirmation) instead of the full Markdown. This is ideal for documents of hundreds of pages where the normal body would be huge.
</Tip>
