There are two people on this project. My friend owns the backend on .NET. I do everything else: design, product, and the entire frontend on SvelteKit. We built rsale.net, a classifieds marketplace for Serbia, with AI-assisted listings, semantic search, in-platform chat, and five languages.
I am not going to sell you the product here. This is dev.to, so let me sell you the constraints instead, because the constraints are the interesting part. When you are two people, you cannot afford architecture cosplay. Every fancy decision is a tax that one of two humans has to pay forever. That single fact shaped almost everything below.
Here is the honest engineering version.
We rewrote Next.js 15 into SvelteKit on purpose
The first frontend was Next.js 15. It worked. It was also slowly turning into a second job. The server and client boundary, the RSC mental model, the constant question of "does this run here or there," all of it is fine when you have a frontend team. We do not. We have me.
So I moved the frontend to SvelteKit 2 with Svelte 5 runes, on Vite 7 and adapter-node. The reactivity model is just JavaScript you can read out loud:
<script lang="ts">
let { listing } = $props();
let views = $state(0);
let priceLabel = $derived(formatRSD(listing.price));
</script>
No dependency arrays, no "why did this re-render," no provider towers. The bundle got smaller and builds got faster. (Drop your real before/after numbers here, they are more honest than anything I could write for you.)
The point is not that SvelteKit is objectively superior. The point is that it lowered the coordination cost of having one person own a whole frontend. For a two-person team, "fewer concepts to hold in your head" is a performance metric.
The backend is boring, and that is the compliment
The backend is ASP.NET Core. Not because it is trendy on dev.to, but because it is reliable, my friend knows it cold, and one person can own it without it falling over at 3am.
We have a few services split along real boundaries: listings, chat, and a separate AI Translation Service. We did not split by resume. The translation service is its own thing because it has a completely different latency and scaling profile from serving a listing page, not because microservices are a personality.
If you are a small team, resist the urge to draw the architecture you would draw at a company with 200 engineers. You will spend your whole life maintaining the seams.
Multilingual is an architecture decision, not a toggle
Serbia is not monolingual, so the platform speaks Serbian, Russian, English, German and Chinese. The mistake most people make is bolting i18n on as a client-side switcher that flickers and gives Google nothing to index.
We did the opposite. Language lives in the URL, content is translated server-side, and every page declares its alternates:
<link rel="alternate" hreflang="sr" href="https://rsale.net/sr/listing/..." />
<link rel="alternate" hreflang="en" href="https://rsale.net/en/listing/..." />
<link rel="alternate" hreflang="de" href="https://rsale.net/de/listing/..." />
<link rel="alternate" hreflang="x-default" href="https://rsale.net/sr/listing/..." />
Messages load in the SSR pass, so the first paint is already in the right language:
// +layout.ts
export const load = async ({ params, fetch }) => {
const lang = params.lang ?? 'sr';
const messages = await fetch(`/i18n/${lang}.json`).then((r) => r.json());
return { lang, messages };
};
Listing content itself gets translated by the AI Translation Service and cached, not re-translated on every request. Translation is expensive and mostly static per listing, so caching it is the difference between a feature and a bill.
The AI listing builder, minus the hallucination circus
Posting a listing is work, and people abandon work. So the AI writes the first draft: you drop a few photos and a couple of words, it produces a title, a description, a category, and a price.
The trick is not "call an LLM." The trick is refusing to trust it. We constrain the model to structured output and validate everything against our own taxonomy:
{
"title": "string, max 70 chars",
"category_id": "must match an existing taxonomy node",
"description": "string",
"attributes": { "brand": "string|null", "condition": "new|used" },
"suggested_price_rsd": "number"
}
Rules that keep it sane:
- The model may not invent a category. If
category_idis not in our taxonomy, we reject and re-ask. The LLM proposes, the taxonomy disposes. - The price suggestion is anchored to comparable sold listings, not to the model's vibes. The LLM can phrase it, it does not get to imagine it.
- There is always a human review step before publish. The AI removes the blank-page problem, it does not remove the human.
A decent draft you edit beats an empty form you abandon. That is the whole value, and it is enough.
Search that forgives a typo
Keyword search gets personally offended by typos. We run a hybrid: lexical search for exact-ish matches, plus vector search over embeddings for meaning.
-- pgvector: nearest listings by meaning
SELECT id, title
FROM listings
ORDER BY embedding <=> $1 -- cosine distance to the query embedding
LIMIT 40;
We blend the vector ranking with full-text rank so "samsung tv" and "telvizor samsung" and a half-typed mess all land somewhere reasonable. Intent detection then sets the obvious filters (category, price band) so the user does not have to. Pure semantic search alone is mushy, pure lexical is brittle, the boring answer is both.
Real-time chat that keeps phone numbers off the listing
Your phone number is not a feature, it is a PII leak waiting to happen. So buyers and sellers talk in-platform over a WebSocket layer (on .NET that is SignalR territory; confirm what you actually shipped before you publish this line). The win is not technical novelty, it is that nobody has to paste their number into a public field and then live with the consequences.
SEO is part of the build, not a plugin you install later
Because the frontend is SSR by default on adapter-node, pages arrive rendered, fast, with clean per-language URLs and a sitemap per language. Structured data is in the markup, not injected by a third-party script after load.
Most teams treat SEO like flossing: everyone knows they should, almost nobody does until something hurts. Building it into the rendering layer from day one costs almost nothing and compounds quietly. Retrofitting it later costs a rewrite.
How two people ship like ten
- Same-day deploys. I change how a listing looks, it is in production that afternoon. No release train, no change-advisory ritual.
- Shared types across the boundary so the frontend and backend stop lying to each other.
- No meetings about meetings. The entire coordination overhead is two people who talk when something is actually unclear.
Small teams are not a limitation you apologize for. They are a forcing function for picking tools that do not need a committee to operate.
What still hurts (the engineering edition)
The honest debt, because dev.to deserves the real list:
- The AI listing builder fumbles unusual or niche items, where comparables are thin and the taxonomy fit is fuzzy.
- Vector index tuning is an ongoing fight between recall and latency.
- Translation cache invalidation is exactly as fun as the famous quote promises.
- Test coverage is uneven. Some paths are solid, some work because I know where not to click.
- Mobile is still being decided (PWA versus a real native shell). Opinions welcome.
It is not finished. Nothing good is. Anyone who tells you their architecture is done is either lying or about to be rewritten.
Roast it
If you have built marketplaces, search, multilingual SSR, or LLM pipelines that have to behave in production, I want the honest version. Where does this break at scale? What would you have done differently with two people and no patience for ceremony?
Rip it apart in the comments. That is worth more to me than stars.
rsale.net
Top comments (0)