Remediate

recipes

short, copy-pasteable backends for /api/feedback. each one assumes the getting started route shape. parseFeedback(req) returns { submission, files }.

pick whichever matches your team.

formatting with toMarkdown()

all the text-based recipes below (discord, email, slack, github, linear) need to format the submission into something readable. instead of hand-building it, use toMarkdown():

import { parseFeedback, toMarkdown } from "remediate/server";

const { submission, files } = await parseFeedback(req);
const body = toMarkdown(submission);

output is structured markdown — items grouped by type, numbered to match the widget, priority badges, environment line. works directly for github issues, linear, discord embeds, and email.

if you uploaded files to a public url first, pass them in to get inline images and download links:

const body = toMarkdown(submission, {
  fileUrls: {
    "screenshot-cap_abc123": "https://storage.example.com/screenshot-cap_abc123.png",
    "voice-voc_def456": "https://storage.example.com/voice-voc_def456.webm",
  },
});

screenshots render as ![](url) inline images. videos and voice notes render as [filename](url) links. items without a url fall back to the filename.

options: fileUrls (map of file key → public url), environment (include browser/os line, default true), metadata (include metadata as json block, default false).

discord webhook

for indie projects and small teams. one fetch, no sdk. screenshots and recordings attach inline.

// app/api/feedback/route.ts
import { parseFeedback } from "remediate/server";

export async function POST(req: Request) {
  const { submission, files } = await parseFeedback(req);

  const body = submission.items
    .map((item) => {
      if (item.type === "textNote") return item.text;
      if (item.type === "annotation") return `${item.note} (${item.element.selector})`;
      return item.additionalText;
    })
    .filter(Boolean)
    .join("\n");

  const form = new FormData();
  form.append(
    "payload_json",
    JSON.stringify({
      content: `**feedback on ${submission.url}** — ${submission.items.length} items`,
      embeds: body ? [{ description: body }] : [],
      allowed_mentions: { parse: [] },
    }),
  );

  let i = 0;
  for (const [, file] of files) {
    form.append(`files[${i}]`, file.blob, file.filename);
    i++;
  }

  await fetch(process.env.DISCORD_WEBHOOK_URL!, {
    method: "POST",
    body: form,
  });

  return Response.json({ ok: true });
}

screenshots, recordings, and voice notes show up as attachments in the discord message. discord allows up to 10 files per message (8mb each on free, 50mb with nitro).

email me

for solo devs. uses resend.

import { parseFeedback, toMarkdown } from "remediate/server";
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(req: Request) {
  const { submission, files } = await parseFeedback(req);

  const attachments = [];
  for (const [, file] of files) {
    const buffer = Buffer.from(await file.blob.arrayBuffer());
    attachments.push({ filename: file.filename, content: buffer });
  }

  try {
    await resend.emails.send({
      from: "feedback@yourdomain.com",
      to: "you@yourdomain.com",
      subject: `feedback on ${new URL(submission.url).pathname}`,
      text: toMarkdown(submission),
      attachments,
    });
  } catch (err) {
    console.error("[feedback] email delivery failed:", err);
  }

  return Response.json({ ok: true });
}

screenshots, recordings, and voice notes arrive as email attachments. drop the attachments array if you only want the json.

slack channel

for teams already living in slack. paste in a webhook url, pick a channel.

import { parseFeedback } from "remediate/server";

export async function POST(req: Request) {
  const { submission, files } = await parseFeedback(req);

  const lines = submission.items.map((item) => {
    const tag = item.priority !== "none" ? ` *[${item.priority}]*` : "";
    const text =
      item.type === "textNote" ? item.text :
      item.type === "annotation" ? item.note :
      item.additionalText || "";
    return `${item.type}${tag}${text}`;
  });

  if (files.size > 0) {
    const names = [...files.values()].map((f) => f.filename);
    lines.push("", `_${names.length} file(s): ${names.join(", ")}_`);
  }

  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      text: [`*feedback on ${submission.url}*`, "", ...lines].join("\n"),
    }),
  });

  return Response.json({ ok: true });
}

slack incoming webhooks don't support file uploads — screenshots, recordings, and voice notes can't attach inline. the recipe lists filenames so you know files were submitted. to include the actual files, pair this with a blob storage recipe and link the urls in the message.

github issues

for internal qa. uploads blobs to vercel blob, links them in the issue body.

// .env: GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO, BLOB_READ_WRITE_TOKEN
import { parseFeedback, toMarkdown } from "remediate/server";
import { put } from "@vercel/blob";

export async function POST(req: Request) {
  const { submission, files } = await parseFeedback(req);

  const fileUrls: Record<string, string> = {};
  for (const [name, file] of files) {
    const blob = await put(
      `feedback/${submission.id}/${file.filename}`,
      file.blob,
      { access: "public", contentType: file.type },
    );
    fileUrls[name] = blob.url;
  }

  const body = toMarkdown(submission, { fileUrls });

  try {
    await fetch(
      `https://api.github.com/repos/${process.env.GITHUB_OWNER}/${process.env.GITHUB_REPO}/issues`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          title: `feedback on ${new URL(submission.url).pathname}`,
          body,
          labels: ["feedback"],
        }),
      },
    );
  } catch (err) {
    console.error("[feedback] github issue creation failed:", err);
  }

  return Response.json({ ok: true });
}

screenshots render as inline images in the issue body. videos and voice notes render as download links.

mount conditionally so it only shows on staging:

{process.env.NEXT_PUBLIC_ENV === "staging" && (
  <Remediate endpoint="/api/feedback" metadata={{ tester: user.name }} />
)}

linear ticket

import { parseFeedback, toMarkdown } from "remediate/server";

export async function POST(req: Request) {
  const { submission } = await parseFeedback(req);
  const description = toMarkdown(submission);

  try {
    await fetch("https://api.linear.app/graphql", {
      method: "POST",
      headers: {
        Authorization: process.env.LINEAR_API_KEY!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        query: `mutation($input: IssueCreateInput!) { issueCreate(input: $input) { issue { id } } }`,
        variables: {
          input: {
            teamId: process.env.LINEAR_TEAM_ID,
            title: `feedback on ${new URL(submission.url).pathname}`,
            description,
          },
        },
      }),
    });
  } catch (err) {
    console.error("[feedback] linear ticket creation failed:", err);
  }

  return Response.json({ ok: true });
}

convex

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  feedback: defineTable({
    submissionId: v.string(),
    url: v.string(),
    timestamp: v.string(),
    itemCount: v.number(),
    metadata: v.any(),
    payload: v.any(),
    files: v.array(
      v.object({
        filename: v.string(),
        storageId: v.id("_storage"),
        contentType: v.string(),
        size: v.number(),
      }),
    ),
  })
    .index("by_submissionId", ["submissionId"])
    .index("by_url", ["url"]),
});
// convex/feedback.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const generateUploadUrl = mutation({
  args: {},
  returns: v.string(),
  handler: async (ctx) => ctx.storage.generateUploadUrl(),
});

export const insert = mutation({
  args: {
    submissionId: v.string(),
    url: v.string(),
    timestamp: v.string(),
    itemCount: v.number(),
    metadata: v.any(),
    payload: v.any(),
    files: v.array(
      v.object({
        filename: v.string(),
        storageId: v.id("_storage"),
        contentType: v.string(),
        size: v.number(),
      }),
    ),
  },
  returns: v.id("feedback"),
  handler: async (ctx, args) => ctx.db.insert("feedback", args),
});
// app/api/feedback/route.ts
// .env: NEXT_PUBLIC_CONVEX_URL
import { parseFeedback } from "remediate/server";
import { ConvexHttpClient } from "convex/browser";
import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel";

const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export const runtime = "nodejs";

export async function POST(req: Request) {
  const { submission, files } = await parseFeedback(req);

  const uploaded: Array<{
    filename: string;
    storageId: Id<"_storage">;
    contentType: string;
    size: number;
  }> = [];

  for (const [, file] of files) {
    const uploadUrl = await convex.mutation(api.feedback.generateUploadUrl, {});

    const res = await fetch(uploadUrl, {
      method: "POST",
      headers: { "Content-Type": file.type },
      body: file.blob,
    });
    if (!res.ok) throw new Error(`convex upload failed: ${res.status}`);

    const { storageId } = (await res.json()) as { storageId: Id<"_storage"> };

    uploaded.push({
      filename: file.filename,
      storageId,
      contentType: file.type,
      size: file.blob.size,
    });
  }

  const id = await convex.mutation(api.feedback.insert, {
    submissionId: submission.id,
    url: submission.url,
    timestamp: submission.timestamp,
    itemCount: submission.items.length,
    metadata: submission.metadata,
    payload: submission,
    files: uploaded,
  });

  return Response.json({ ok: true, id });
}

run npx convex dev in another terminal to keep the codegen current. files render in the convex dashboard via storage.getUrl(storageId) from any query or mutation.

vercel blob + database

for beta programs. store the payload, store the files, query later.

{user.isBetaTester && (
  <Remediate
    endpoint="/api/feedback"
    metadata={{ userId: user.id, email: user.email }}
  />
)}
// .env: BLOB_READ_WRITE_TOKEN
import { parseFeedback } from "remediate/server";
import { put } from "@vercel/blob";

export async function POST(req: Request) {
  const { submission, files } = await parseFeedback(req);

  const urls: Record<string, string> = {};
  for (const [name, file] of files) {
    const blob = await put(
      `feedback/${submission.id}/${file.filename}`,
      file.blob,
      { access: "public", contentType: file.type },
    );
    urls[name] = blob.url;
  }

  await db.feedback.create({
    data: {
      id: submission.id,
      url: submission.url,
      userId: String(submission.metadata.userId),
      payload: submission,
      fileUrls: urls,
    },
  });

  return Response.json({ ok: true });
}

watch the 4.5mb body limit on serverless if recordings run long.

postgres + drizzle

for self-hosters. nothing leaves your stack.

import { parseFeedback } from "remediate/server";
import { db } from "./db";
import { feedback } from "./schema";
import { writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";

export async function POST(req: Request) {
  const { submission, files } = await parseFeedback(req);

  const dir = join(process.cwd(), "uploads", submission.id);
  await mkdir(dir, { recursive: true });
  for (const [, file] of files) {
    await writeFile(join(dir, file.filename), Buffer.from(await file.blob.arrayBuffer()));
  }

  await db.insert(feedback).values({
    id: submission.id,
    url: submission.url,
    payload: submission,
    createdAt: new Date(),
  });

  return Response.json({ ok: true });
}

local dev: write to disk

simplest possible backend. useful when evaluating the library.

import { parseFeedback } from "remediate/server";
import { writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";

export async function POST(req: Request) {
  const { submission, files } = await parseFeedback(req);
  const dir = join(process.cwd(), ".feedback", submission.id);
  await mkdir(dir, { recursive: true });
  await writeFile(join(dir, "submission.json"), JSON.stringify(submission, null, 2));
  for (const [, file] of files) {
    await writeFile(join(dir, file.filename), Buffer.from(await file.blob.arrayBuffer()));
  }
  return Response.json({ ok: true });
}

missing something? open an issue.