Fashion app with Porulle + Next.js
This builds the apps/playground example: one Next.js app that runs Porulle (headless commerce) and samesake (search) together, over a real 30-product Sri-Lankan fashion catalog, in a single process against a single Postgres database.
Next.js (one process)├── /api/[[...route]] → Porulle Hono app (catalog, pricing, inventory, auth)├── /api/search → samesake.search (NLQ-free hybrid: FTS + cosine ANN)└── / → minimalist search UI │ ▼ one Postgres database ├── public.* Porulle commerce tables └── samesake_*, project_playground.* samesake (namespaced)1. One database, or two? (the design answer)
Section titled “1. One database, or two? (the design answer)”You can run samesake and your commerce backend in one Postgres database. We verified it: in the playground DB, Porulle owns the public schema (sellable_entities, prices, …) while samesake namespaces itself — six samesake_* system tables plus a project_<slug> schema for each project’s search tables. No name collisions. Separate databases also work and give stronger blast-radius isolation; pick based on ops preference, not necessity. samesake never writes to your commerce tables — it only reads the catalog and maintains its own.
The playground uses a dedicated database (createdb-style isolation) so the demo is reproducible and droppable.
2. Scaffold + dependencies
Section titled “2. Scaffold + dependencies”bunx create-next-app@latest apps/playground --ts --app --no-tailwind --no-src-dir --skip-install --yes// apps/playground/package.json (deps)"@porulle/core": "^0.1.0","@porulle/adapter-postgres": "^0.1.0","@porulle/adapter-local-storage": "^0.1.0","@samesake/core": "...","@samesake/server": "...","better-auth": "1.6.18","@better-auth/api-key": "1.6.18", // workaround for porulle#25"hono": "^4.12.0", "drizzle-orm": "^0.45.1", "postgres": "^3.4.0", "zod": "^4.0.0"// root package.json — workaround for porulle#24"overrides": { "@porulle/core": "0.1.0" }const nextConfig = { serverExternalPackages: ["@porulle/core", "@porulle/adapter-postgres", "@porulle/adapter-local-storage", "@samesake/core", "@samesake/server", "drizzle-orm", "postgres", "better-auth"],};3. Porulle commerce config + schema
Section titled “3. Porulle commerce config + schema”import { consoleEmailAdapter, defineConfig } from "@porulle/core";import { postgresAdapter } from "@porulle/adapter-postgres";import { localStorageAdapter } from "@porulle/adapter-local-storage";
export default defineConfig({ storeName: "Samesake Fashion Playground", database: { provider: "postgresql" }, databaseAdapter: postgresAdapter({ connectionString: process.env.DATABASE_URL! }), storage: localStorageAdapter({ basePath: "./.data/media", baseUrl: "http://localhost:3000/assets" }), email: consoleEmailAdapter(), auth: { requireEmailVerification: false, apiKeys: { enabled: true }, trustedOrigins: ["http://localhost:3000"], roles: { owner: { permissions: ["*:*"] }, admin: { permissions: ["*:*"] } } }, entities: { product: { fields: [ { name: "material", type: "text" }, { name: "color", type: "text" }, { name: "category", type: "text" }, { name: "imageUrl", type: "text" }, ], variants: { enabled: true, optionTypes: ["size", "color"] }, fulfillment: "physical", }, },});Porulle ships its Drizzle schema as a re-export barrel, so one entry covers every table:
export default { dialect: "postgresql", schema: ["./node_modules/@porulle/core/dist/kernel/database/schema.js"], dbCredentials: { url: process.env.DATABASE_URL! } };psql "$DATABASE_URL" -c "CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS unaccent; CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;"bun run db:push # bunx drizzle-kit push4. Seed the catalog (in-process kernel)
Section titled “4. Seed the catalog (in-process kernel)”Porulle’s services are callable in-process via createKernel. Each call returns a Result — check .ok. (The README’s /api/admin/entities SDK example is wrong; the real surface is /api/catalog/* — porulle#29.)
import { createKernel, ensureDefaultOrg, DEFAULT_ORG_ID, type Actor } from "@porulle/core";import config from "../commerce.config.ts";
const kernel = createKernel(await config);await ensureDefaultOrg(kernel.database.db, "Samesake Fashion Playground");const staff: Actor = { type: "user", userId: "seed", email: "seed@x.dev", name: "Seed", vendorId: null, organizationId: DEFAULT_ORG_ID, role: "owner", permissions: ["*:*"] };
const res = await kernel.services.catalog.create({ type: "product", slug: "q3-1", attributes: { title: "Heritage Linen Stripe Short Sleeve Shirt" }, metadata: { brand: "AnationZ" }, customFields: { material: "linen", color: "blue", category: "shirt", imageUrl: "https://…" },}, staff);if (!res.ok) throw res.error;await kernel.services.pricing.setBasePrice({ entityId: res.value.id, currency: "LKR", amount: 5490 }, staff);await kernel.services.catalog.publish(res.value.id, staff);5. samesake collection + sync
Section titled “5. samesake collection + sync”samesake reads the Porulle catalog and indexes it. The collection mirrors the synced fields:
export const products = collection("products", { fields: { title: f.text({ searchable: true }), brand: f.text({ filterable: true }), category: f.text({ filterable: true }), color: f.text({ filterable: true }), material: f.text({ filterable: true }), price: f.number({ filterable: true, budget: true }), available: f.boolean({ filterable: true }), image_url: f.text(), }, embeddings: { doc: { source: "$title $brand $category $color $material", model: "gemini-embedding-2", dim: 1536 } }, search: { channels: [Channels.fts({ fields: ["title","brand","category","color","material"], weight: 1 }), Channels.cosine({ embedding: "doc", weight: 1 })], combiner: "rrf" },});The sync reads active products (here straight from Porulle’s tables — GET /api/catalog/entities?status=active&include=attributes,pricing is the API equivalent), maps them, and indexes:
await matcher.migrate(); // create samesake_* tables if absentawait matcher.apply("playground", { entities: [], collections: [products] });await matcher.pushDocuments("playground", "products", docs);await matcher.index("playground", "products");6. Routes + UI
Section titled “6. Routes + UI”import { handle } from "hono/vercel";import { createServer } from "@porulle/core";import config from "@/commerce.config";export const runtime = "nodejs";const { app } = await createServer(await config);export const GET = handle(app); export const POST = handle(app); /* PUT/PATCH/DELETE */export const runtime = "nodejs";export async function POST(req: Request) { const { q, category } = await req.json(); // No explicit price filter: NLQ parses budgets ("under 3000") from q into a hard // price <= cap. category stays an explicit facet from the storefront chips. const result = await getMatcher().search("playground", "products", { q, limit: 24, filters: { available: true, ...(category ? { category } : {}) }, }); return Response.json({ hits: result.hits });}A specific route (/api/search) takes precedence over the optional catch-all (/api/[[...route]]), so samesake owns search while Porulle owns everything else under /api.
Budgets are understood because the collection enables NLQ and price is budget: true:
// lib/samesake.ts — createMatcher({ embed: geminiEmbed, generate: geminiGenerate, ... })search: { channels: [/* fts + cosine */], combiner: "rrf", nlq: { enable: true, semanticRewrite: true } }So “party wear under 3000” returns only in-budget results — no separate max-price box needed.
7. Run it
Section titled “7. Run it”bun --env-file=.env scripts/seed.ts # 30 products into Porullebun --env-file=.env scripts/sync-to-samesake.ts # index into samesakebun run dev # http://localhost:3000Verified end-to-end:
curl -s localhost:3000/api/health # {"status":"ok"} (Porulle)curl -s -XPOST localhost:3000/api/search -d '{"q":"linen shirt"}' # 3 linen shirtscurl -s -XPOST localhost:3000/api/search -d '{"q":"denim jacket"}' # denim outerwearDeploy to Vercel
Section titled “Deploy to Vercel”The app is a standard Next.js app, so vercel deploys it as-is. Two things matter for serverless:
- Use Neon’s pooled connection string for
DATABASE_URL(the-poolerhost) — serverless functions open many short-lived connections. - The routes are
runtime = "nodejs"and build lazily (no DB at build), so the build never needs the database.
cd apps/playgroundvercel link --yes --scope <your-scope># production env (pooler host!)printf '%s' "$NEON_POOLER_URL" | vercel env add DATABASE_URL productionprintf '%s' "$GEMINI_API_KEY" | vercel env add GEMINI_API_KEY productionprintf '%s' "$API_KEY" | vercel env add API_KEY productionprintf '%s' "$BETTER_AUTH_SECRET" | vercel env add BETTER_AUTH_SECRET productionvercel deploy --prod --yes --scope <your-scope>Three gotchas worth knowing:
- Co-locate functions with the database. A fresh Vercel project runs in
iad1(US-East); if your Neon DB is in, say,ap-southeast-1(Singapore), every query crosses the Pacific. Pin the function region to match —vercel.json{ "regions": ["sin1"] }— so Porulle and samesake both talk to Postgres locally. @samesake/*asworkspace:*won’t resolve in a standalone Vercel build. Reference the published versions ("@samesake/core": "^1.1.0") — in a Bun workspace this still links the local package locally while Vercel pulls from npm.- New Vercel projects ship with Deployment Protection (Vercel Authentication) on, so the demo returns “Authentication Required”. Turn it off for a public demo (Project → Settings → Deployment Protection, or
PATCH /v9/projects/<id>with{"ssoProtection": null}).
Enrich attributes from the image (samesake enrich pipeline)
Section titled “Enrich attributes from the image (samesake enrich pipeline)”Heuristic “colour-from-title” left most products with no colour at all, so colour queries
returned whatever was vaguely “a dress” — including red ones for a black query. samesake’s
enrich pipeline fixes this at the source: a multimodal stage reads each product image
and writes structured attributes into the row’s enriched jsonb.
import { z } from "zod";
enrich: pipeline( stage("vision", { model: "gemini-3.1-flash-lite", images: (ctx) => (ctx.data.image_url ? [String(ctx.data.image_url)] : []), prompt: (ctx) => `Describe this product's colours and pattern as JSON.`, // a zod schema (or a plain JSON Schema object) — samesake converts zod to // JSON Schema and hands it to your generate. schema: () => z.object({ color_text: z.string(), // "light blue and white striped" colors: z.array(z.string()).optional(), pattern: z.string().optional(), }), })),embeddings: { // pull the enriched attributes into the doc text the embedding is built from doc: { source: "$title $brand $category $enriched.color_text $enriched.pattern", model: "gemini-embedding-2", dim: 1536 },},matcher.enrich(project, collection) runs the pipeline — it fetches each image through the
SSRF-hardened fetcher and calls your generate with the image bytes + schema, then stores the
JSON in enriched. The order in the sync is push → enrich → index. Now the colours are real
(“RED PUFF SLEEVE MAXI DRESS” → solid red, “Princess Line Dress” → solid black), and because
the doc embedding source includes $enriched.color_text, “black dress with white” cosine-favours
genuinely black products.
Pair it with a constrained NLQ schema so a sparse colour tag can’t become a hard filter that dead-ends the query — let intent + budget through, and leave colour to the embedding + visual signals:
nlq: { enable: true, semanticRewrite: true, schema: z.object({ semantic_query: z.string(), max_price: z.number().optional() }) }Honest result. With real colours the black dress ranks #1 (it was buried before), but at a 27-product corpus colour is a weak discriminator, so a couple of other-coloured dresses still appear in the top few. A larger catalog and a colour soft-boost would sharpen it further — the enrichment is the foundation that makes either possible.
Visual & cross-modal search
Section titled “Visual & cross-modal search”gemini-embedding-2 is multimodal, so adding a visual space embeds both product images and
text queries into the same vector space:
spaces: { visual: s.image({ source: "$image_url", model: "gemini-embedding-2", dim: 768 }) },search: { channels: [/* fts, cosine */, Channels.spaces({ weight: 1 })], defaultSpaceWeights: { visual: 1 } }That unlocks three things over the same index:
- Search images by text — “black dress with white” embeds the query text into the visual space and matches it against product images, recovering colour/pattern combinations the text fields don’t store.
- Search by image — paste an image URL (or “find similar” on a card) and the engine embeds that image and ranks by visual similarity.
- Soft attributes matter. Mark sparse tags like
colorassoft: trueso an NLQ-extractedcolor = blackrelaxes instead of dead-ending — letting the visual + text signals rank a “black dress with white” result set rather than returning only the two products literally tagged black.
The embed fn just handles an image part when samesake passes one; samesake fetches the image through its SSRF-hardened fetcher and embeds the bytes.
What this proves
Section titled “What this proves”A commerce backend you own (Porulle) and a search layer you own (samesake) compose in one app, one database, with the search reading the catalog and never touching the commerce tables. The same pattern applies to Medusa, Shopify, and WooCommerce — only the catalog-read + sync step changes.