This project is the site you are reading. It has a library of projects and articles, a search experience, and a contact form. I wanted to be able to publish without rewriting page components, keep the reading experience fast, and make sure a contact message could not quietly disappear when another service was unavailable.
Those requirements produced two separate flows. The first turns an edit into a page. The second turns a form submission into a stored message. This walkthrough follows both in that order.
From an edit to a published page#
Each project and article begins as a file in the repository. The top of a project file contains fields such as its title, description, date, status, technologies, and links. The rest of the file is the case study itself. Site-wide copy—navigation labels, page introductions, biography, and footer text—lives in structured data files beside it.
I can edit those files directly or use Keystatic, which provides a form-based editing screen. Keystatic does not serve content to readers. It writes the same files that are reviewed in Git and used by the site.
When I deploy a change, the build reads every content file. Zod schemas describe which fields are required and what shape each value must have. Additional checks confirm that references are valid: a project cannot name a technology that does not exist, and a published article cannot point to an unpublished topic.
Only after those checks pass does Next.js turn the content into HTML pages. This is static generation: preparing the finished page during deployment rather than assembling it again for every visitor.
View diagram source
flowchart TB
E["Edit a project, article, or site setting"] --> F["Save a versioned content file"]
F --> V["Check required fields and relationships"]
V -->|"invalid"| X["Stop the deployment with a useful error"]
V -->|"valid"| B["Build the pages"]
B --> D["Deploy finished HTML"]
D --> R["Reader opens the page"]Readers therefore do not wait for the editing system or a content database. The work already happened before the page was deployed.
Keeping every public surface in agreement#
Publishing an article affects more than its detail page. The same article may also appear in the Writing library, search results, a topic page, an RSS feed, the sitemap, and social-sharing metadata.
Earlier versions of the portfolio made some of those decisions in separate places. Public copy existed partly in components and partly in files, while the editor described its own set of fields. A change could be valid in one place and incomplete in another.
The current version reads all of those surfaces through the same content loader. One published value decides whether an entry is public, and the same validated record is reused wherever that entry appears. Together, the fields and relationships for projects, writing, topics, and site copy form the site's content model. The current version defines that model once and reuses it across the editing and publishing paths.
Tests compare the editing fields with the runtime schemas so they cannot drift unnoticed. That turns a missing field or stale relationship into a failed build, where it is cheap to correct, instead of a broken public page.
What happens when someone presses Send#
The contact form begins preparing before a message is submitted. The browser requests a signed timestamp from a small edge function. An edge function is a short server-side handler that can run close to the visitor. Here it returns a timestamp with a signature that only the site can create. On submission, the server verifies the signature and rejects a missing, forged, future-dated, or expired token. This adds friction for automated requests, but it does not claim to prove that a visitor is human.
When the visitor presses Send, the request first goes through inexpensive checks. The site rejects requests from an unexpected origin, bodies that are too large, a filled hidden field that normal visitors never see, invalid timestamps, and addresses submitting too frequently.
The frequency check is a rate limit. This portfolio permits one attempt in ten seconds and five attempts in an hour from the same detected address. Redis holds the small counters needed to make that decision across requests.
Requests that pass those checks reach the main form handler. It checks the name, email, subject, and message again, removes unsafe or unwanted input, assigns the submission a unique identifier, and gives the cleaned message to a delivery service.
View diagram source
sequenceDiagram
participant B as Browser
participant G as Request checks
participant H as Form handler
participant Q as Delivery service
participant P as Message processor
B->>G: Send name, email, subject, and message
G->>G: Check origin, size, token, bot field, and frequency
G->>H: Forward an allowed request
H->>H: Validate, clean, and assign an ID
H->>Q: Hand off the message
Q-->>H: Message accepted
H->>H: Sign a short-lived receipt
H-->>B: Show accepted and offer an optional dispatch
Q->>P: Deliver the message for processing
B->>H: Check the receipt for a safe public resultThe browser sees success only after the delivery service has accepted responsibility for the message. It does not yet mean that every later step has finished. When receipt signing succeeds, the page also opens a portfolio-assistant panel with a visible composing state. It tells the visitor the message is already accepted for delivery, the optional field note will come from one of six assistants rather than me, and leaving is fine. The page checks without moving keyboard focus for up to 90 seconds. Each lookup has a five-second timeout so a stalled request does not block the remaining checks; only after the automatic window ends does the panel offer a manual result check.
Finishing the work after the visitor leaves#
The hand-off above is a queue: a service that holds accepted work and delivers it to another endpoint. QStash provides that service here. It lets the visitor-facing request finish without waiting for tasks that can take longer or need another attempt.
QStash sends the cleaned message to a processing route and signs the request. The route verifies that signature before trusting the body. It then performs the work in a deliberate order.
First, it turns the cleaned submission into structured inbox context and an optional field note from one of the portfolio assistants. Gemini 3.6 Flash normally handles one concise structured request with a longer generation window. Full Gemini 3.5 Flash gets one shorter fallback attempt only when the primary times out, has a retryable failure, produces no usable response, or cannot produce a safe visitor note.
The response is checked in sections rather than accepted or discarded as one block. Serious inbox triage is the minimum useful result. If an optional recommendation is invalid, the processor removes that recommendation without losing a safe assistant note. If the public note itself is unsafe, it stays private and unavailable while valid inbox context survives. Style targets such as length and direct address guide generation but do not turn harmless variation into a system failure. A deterministic full fallback is needed only when neither model returns usable triage.
The result serves two audiences. The owner view adds intent, sentiment, urgency, a summary, and concise message-specific context to the Sheet and an inbox-first Telegram alert. The visitor view selects one of six original portfolio assistants and prompts it to speak directly to the sender in a short, vivid persona note, optionally using the submitted name. Ordinary networking and coffee invitations favor the warm opportunity scout rather than being criticized for lacking a formal project brief. If the human recipient needs to be mentioned, the assistant says Abdul Ahad rather than using an impersonal role. Each persona has a distinct voice, but none can accept or decline an invitation, schedule anything, promise a response, or speak as me. The note also cannot repeat private contact details, expose internal generation instructions, contain markup, or introduce arbitrary links. It may recommend one published project or article only by a closed identifier that the server converts to a verified local path. Suspicious or prompt-injection-shaped messages get a generic response and no recommendation.
Second, the processor writes the original submission, owner-only context, and any public dispatch to a Google Sheet. That sheet is the durable record for this portfolio-sized workload.
Only after that row is safe does the processor store the validated public dispatch in Redis for ten minutes and send the Telegram alert. Redis receives only the visitor-facing result; the durable submission and owner-only context remain with the stored record. If Redis or Telegram fails, the stored message remains available and the queue does not repeat completed durable work. Reversing that order could produce a notification or public result for a message that was never recorded.
Saving one message when delivery repeats#
A delivery service may send the same work again when a response is lost or an earlier attempt times out. That is normal retry behaviour, not necessarily a second form submission.
The unique submission identifier assigned by the form handler travels with every delivery attempt. Before the storage script appends a row, it checks whether that identifier already exists. A repeated attempt therefore returns the existing result instead of creating a second row.
This is idempotency: repeating the same operation leaves the system in the same final state as performing it once. In this project, it means retries can improve delivery without duplicating the message.
What happens when processing fails#
Not every failure deserves the same response. If the queued body is invalid, the processing route marks it as non-retryable because sending the same invalid data again cannot repair it. If storage is temporarily unavailable, QStash retries the same submission with increasing delays.
After the final unsuccessful attempt, QStash keeps the message in its managed failed-message store and calls a separate failure route. That route decodes the original submission and sends a [DLQ] Telegram alert. “Dead-letter queue” is the common term; in this case it simply means a place where exhausted work remains visible instead of being discarded.
There are clear limits to this design. An address-based rate limit can inconvenience people on a shared network and cannot stop every determined attacker. A spreadsheet is appropriate for the current contact volume, not a general-purpose transactional database. The generated inbox context and public dispatch are useful additions, not conditions of successful delivery.
The resulting order is deliberate. Edits are checked before they become pages, and contact messages are checked before they are accepted. Once a message is accepted, saving the original is required; the optional dispatch and notification make the experience richer without being allowed to weaken delivery.