Skip to content

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.

Terminal window
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" }
next.config.ts
const nextConfig = {
serverExternalPackages: ["@porulle/core", "@porulle/adapter-postgres", "@porulle/adapter-local-storage",
"@samesake/core", "@samesake/server", "drizzle-orm", "postgres", "better-auth"],
};
commerce.config.ts
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:

drizzle.config.ts
export default { dialect: "postgresql",
schema: ["./node_modules/@porulle/core/dist/kernel/database/schema.js"],
dbCredentials: { url: process.env.DATABASE_URL! } };
Terminal window
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 push

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.)

scripts/seed.ts
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);

samesake reads the Porulle catalog and indexes it. The collection mirrors the synced fields:

lib/samesake.ts
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:

scripts/sync-to-samesake.ts
await matcher.migrate(); // create samesake_* tables if absent
await matcher.apply("playground", { entities: [], collections: [products] });
await matcher.pushDocuments("playground", "products", docs);
await matcher.index("playground", "products");
app/api/[[...route]]/route.ts — Porulle, embedded
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 */
app/api/search/route.ts — samesake (specific route wins over the catch-all)
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.

Terminal window
bun --env-file=.env scripts/seed.ts # 30 products into Porulle
bun --env-file=.env scripts/sync-to-samesake.ts # index into samesake
bun run dev # http://localhost:3000

Verified end-to-end:

Terminal window
curl -s localhost:3000/api/health # {"status":"ok"} (Porulle)
curl -s -XPOST localhost:3000/api/search -d '{"q":"linen shirt"}' # 3 linen shirts
curl -s -XPOST localhost:3000/api/search -d '{"q":"denim jacket"}' # denim outerwear

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 -pooler host) — 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.
Terminal window
cd apps/playground
vercel link --yes --scope <your-scope>
# production env (pooler host!)
printf '%s' "$NEON_POOLER_URL" | vercel env add DATABASE_URL production
printf '%s' "$GEMINI_API_KEY" | vercel env add GEMINI_API_KEY production
printf '%s' "$API_KEY" | vercel env add API_KEY production
printf '%s' "$BETTER_AUTH_SECRET" | vercel env add BETTER_AUTH_SECRET production
vercel 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/* as workspace:* 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.

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 color as soft: true so an NLQ-extracted color = black relaxes 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.

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.