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.
discord webhook
for indie projects and small teams. one fetch, no sdk.
// app/api/feedback/route.ts
import { parseFeedback } from "remediate/server";
export async function POST(req: Request) {
const { submission } = await parseFeedback(req);
await fetch(process.env.DISCORD_WEBHOOK_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: `feedback on \`${submission.url}\` - ${submission.items.length} items`,
embeds: submission.items.slice(0, 5).map((item) => ({
description: summarize(item),
})),
}),
});
return Response.json({ ok: true });
}summarize is yours. see schema for item shapes.
email me
for solo devs. uses resend.
import { parseFeedback } from "remediate/server";
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: Request) {
const { submission } = await parseFeedback(req);
await resend.emails.send({
from: "feedback@yourdomain.com",
to: "you@yourdomain.com",
subject: `feedback on ${submission.url}`,
text: JSON.stringify(submission, null, 2),
});
return Response.json({ ok: true });
}attachments are optional. add them from files if you want screenshots inline.
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 } = await parseFeedback(req);
const lines = submission.items.map((item) => {
const tag = item.priority !== "none" ? ` *[${item.priority}]*` : "";
return `• ${item.type}${tag} - ${item.additionalText || item.note || item.text || ""}`;
});
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 });
}want item-type-specific formatting (cameras for screenshots, mics for voice notes)? narrow on item.type. see payload.
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 } 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}/${name}`, file, { access: "public" });
urls[name] = blob.url;
}
const title = pickTitle(submission) ?? `feedback from ${submission.url}`;
const body = renderMarkdown(submission, urls);
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, body, labels: ["feedback"] }),
},
);
return Response.json({ ok: true });
}pickTitle and renderMarkdown are yours.
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 } from "remediate/server";
export async function POST(req: Request) {
const { submission } = await parseFeedback(req);
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 ${submission.url}`,
description: "```json\n" + JSON.stringify(submission, null, 2) + "\n```",
},
},
}),
});
return Response.json({ ok: true });
}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}/${name}`, file, { access: "public" });
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 [name, file] of files) {
await writeFile(join(dir, name), Buffer.from(await file.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 [name, file] of files) {
await writeFile(join(dir, name), Buffer.from(await file.arrayBuffer()));
}
return Response.json({ ok: true });
}missing something? open an issue.