AWS Builder Center
Building Reachy: A Serverless AI Outreach Platform with Next.js, Vercel, and Amazon Aurora (IAM Database Auth)

Building Reachy: A Serverless AI Outreach Platform with Next.js, Vercel, and Amazon Aurora (IAM Database Auth)

Reachy is an AI-powered outreach platform that transforms public profiles into personalized outreach messages. Built with Next.js, Amazon Aurora PostgreSQL Serverless v2, Inngest, and Google Gemini, it automates prospect research, generates human-like outreach, and maintains conversation context for intelligent follow-ups.

INTRODUCTION
Cold outreach is a numbers game, but the numbers are getting worse. Today, prospects are immune to generic templates populated by merge tags like {{first_name}} and {{company_name}}. To stand out, you need to do real research: read their LinkedIn profile, check their recent tweets, check their GitHub contributions, and understand their company’s core problems.
But doing that manually takes 10 to 15 minutes per prospect. If you want to reach out to 50 prospects a week, that’s almost two full workdays spent just doing research and drafting emails.
To solve this, I built Reachy: a full-stack, AI-powered outreach assistant. With Reachy, you can paste a LinkedIn URL or any public developer profile, and it will scrape the source, consolidate the context with Google Gemini, and draft a hyper-personalized outreach message that sounds human-written. More importantly, it manages ongoing conversation replies with full chronological memory.
In this post, I will break down how I built Reachy using Next.js, Vercel, and a secure serverless database architecture featuring Amazon Aurora PostgreSQL Serverless v2 with IAM Database Authentication.

Disclaimer: I created Reachy and this article for the purposes of entering the AWS & Vercel Hackathon. Let's dive in!
Hashtags: #H0Hackathon #AWS #Vercel #Nextjs #PostgreSQL #GenerativeAI #Serverless

THE TECH STACK

  • Frontend & API: Next.js 16 (App Router) deployed on Vercel.
  • Database: Amazon Aurora PostgreSQL (Serverless v2, 0.5–2 ACUs) with Drizzle ORM.
  • Authentication: Better Auth.
  • Background Workflows: Inngest for orchestrating scraping & consolidation.
  • Data Collection: Apify (LinkedIn scraping) & Firecrawl (Web & GitHub scraping).
  • Generative AI: Google Gemini 2.5 Flash via the Vercel AI SDK.
  1. THE DATABASE LAYER: ZERO-PASSWORD IAM DATABASE AUTHENTICATION
Typically, connecting a Next.js application to a relational database involves storing a connection string (containing a database password) in an environment variable. If that password leaks or gets committed to source control, your entire database is exposed.
For Reachy, I decided to go with a production-grade, zero-password authentication system using AWS IAM Database Authentication with Amazon Aurora Serverless v2.
[
Instead of static passwords, every database connection generated by our serverless functions uses a short-lived IAM authentication token. This token is cryptographically signed using AWS access keys and is valid for only 15 minutes.
To implement this, we grant the rds-db:connect permission to our Next.js deployment's IAM role or user:
Then, in our database client configuration (src/db/index.ts), we dynamically generate the token on connection using @aws-sdk/rds-signer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { Signer } from "@aws-sdk/rds-signer";
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
import { drizzle } from "drizzle-orm/node-postgres";
import { Client } from "pg";

const getDbCredentials = async () => {
// If we are running locally and have a local connection string, use it
if (process.env.NODE_ENV === "development" && process.env.LOCAL_DATABASE_URL) {
return { connectionString: process.env.LOCAL_DATABASE_URL };
}

// In production, we generate a 15-minute token using IAM
const signer = new Signer({
hostname: process.env.AURORA_ENDPOINT!,
port: 5432,
username: "reachy_vercel",
region: process.env.AWS_REGION || "us-east-1",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});

const token = await signer.getAuthToken();

return {
host: process.env.AURORA_ENDPOINT!,
port: 5432,
user: "reachy_vercel",
password: token,
database: "reachy",
ssl: {
rejectUnauthorized: true, // RDS CA certificate validation
},
};
};
This ensures that even if our environment variables are intercepted, there are no database passwords to steal. Connections are securely negotiated using short-lived AWS IAM credentials.

2. ORCHESTRATING LONG-RUNNING BACKGROUND JOBS WITH INNGEST

Extracting a prospect profile is a multi-step, slow, and fragile operation:
  1. Apify scrapes the LinkedIn URL (takes 5–10 seconds).
  2. Firecrawl scrapes GitHub or personal websites (takes 3–8 seconds).
  3. Gemini processes and consolidates the raw markup into a structured bio, company profile, and set of pain points (takes 2–4 seconds).
Total time: 10 to 25 seconds. If you run this inside a standard Next.js API route or Server Action on Vercel, you run into two major issues:
  • API timeouts (Vercel hobby plan limits functions to 10 seconds; pro plans limit to 60 seconds).
  • A poor user experience, as the user is stuck watching a spinner on a frozen screen.
To solve this, I decoupled the scraping workflow using Inngest. When a user adds a prospect, the Server Action immediately inserts a database entry with status: "pending" and triggers an Inngest event. The HTTP response completes instantly, releasing the user's UI.
Inngest then picks up the event in the background and runs the workflow as a durable, multi-step transaction:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
export const extractProspectJob = inngest.createFunction(
{ id: "extract-prospect" },
{ event: "prospect.added" },
async ({ event, step }) => {
const { prospectId, linkedinUrl, githubUrl } = event.data;

// Step 1: Scrape LinkedIn
const rawLinkedIn = await step.run("scrape-linkedin", async () => {
return await scrapeLinkedInProfile(linkedinUrl);
});

// Step 2: Scrape GitHub/Website
const rawGitHub = await step.run("scrape-github", async () => {
return await scrapeGitHubProfile(githubUrl);
});

// Step 3: Consolidate profiles using Gemini and step.ai.wrap
const result = await step.ai.wrap("generate-profile", async () => {
return await generateStructuredProfile(rawLinkedIn, rawGitHub);
});

// Handle serialization workaround (explained below)
const profile = result.output ?? (result as any)._output;

// Step 4: Save to Aurora PostgreSQL database
await step.run("save-profile-to-db", async () => {
await db.update(prospects)
.set({
bio: profile.bio,
painPoints: profile.painPoints,
status: "completed",
})
.where(eq(prospects.id, prospectId));
});
}
);
Using step.run() guarantees that if Step 3 (AI consolidation) fails, Inngest will retry ONLY Step 3. It will not re-run the expensive web scraping steps, saving us API credits and network latency.

TECHNICAL GOTCHA: step.ai.wrap() and JSON Serialization

While wrapping our Gemini call with Inngest's step.ai.wrap() provides fantastic observability (showing token usage, latency, and inputs right in the Inngest dashboard), it introduced an interesting bug.
The Vercel AI SDK return object from step.ai.wrap() wraps the generated output in a JavaScript getter (result.output). However, Inngest serializes step data as JSON between executions to ensure durability. Since JSON does not serialize getters, result.output became undefined upon step resumption.
I fixed this by accessing the raw underlying private property result._output if result.output was missing:
1
const profile = result.output ?? (result as any)._output;

3. CORE SYSTEM UX: PERSONALIZED OUTREACH & CONVERSATION FOLLOW-UPS

Once a prospect's profile is compiled, Reachy helps you generate personalized outreach. You simply select an "Offering" (your product context) and set a custom "Prompt" template (controls tone, length, boundaries, etc.).
Unlike the long-running scraping pipeline, outreach generation is a single LLM request. Because the user is actively waiting for the text, we bypassed background queues and executed this as a direct, synchronous Next.js Server Action (generateOutreach()).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export async function generateOutreach(prospectId: string, offeringId: string, customPrompt: string) {
const session = await requireAuth();

const prospect = await db.query.prospects.findFirst({ where: eq(prospects.id, prospectId) });
const offering = await db.query.offerings.findFirst({ where: eq(offerings.id, offeringId) });

if (!prospect || !offering) throw new Error("Not found");

const systemPrompt = fillTemplate(customPrompt, {
offeringName: offering.name,
offeringSummary: offering.summary,
offeringIcp: offering.icp,
offeringDifferentiators: offering.differentiators,
});

const response = await generateText({
model: google("gemini-2.5-flash"),
system: systemPrompt,
prompt: `Generate an outreach email for ${prospect.name}. Bio: ${prospect.bio}. Pain points: ${prospect.painPoints.join(", ")}`,
});

return response.text;
}

No RAG? Why a Rolling Summary Wins for Conversation History

When the prospect replies, Reachy handles the conversation thread. I initially considered using Retrieval-Augmented Generation (RAG) — embedding each message in a vector database and querying it.
However, RAG is designed for searching large, disjointed databases. Sales outreach threads are linear, short (rarely exceeding 10 messages), and highly dependent on chronological context. Vector similarity searches retrieve isolated message shards out of order, destroying conversational flow.
Instead, we store messages in sequential order within Aurora PostgreSQL and feed the complete conversation transcript directly to Gemini's extensive context window. It's fast, has zero vector-db overhead, and preserves the perfect conversational context.

CONCLUSION

Building Reachy showed me how powerful a serverless stack can be when paired with a secure, production-ready relational database like Amazon Aurora Serverless v2. By shifting our credentials from static passwords to IAM-based authentication tokens and utilizing Inngest for background scraping workflows, we created an app that is not only highly functional but secure by design.
If you are building full-stack AI applications, deploy them on Vercel, keep your database passwords out of your code via AWS IAM, and isolate slow workflows to background jobs.
You can check out the live version of Reachy and the full source code below:
  • GitHub: https://github.com/anuj846k/reachy-outreach
  • Live App: reachy-outreach.vercel.app 
  • Demo Video: https://youtu.be/QvuJdwTnzcM?si=BHZFVMhzCDgXBnHF
Thank you for reading! Don't forget to share your feedback and join the conversation using #H0Hackathon.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article