<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sakurai Ts</title>
    <description>The latest articles on DEV Community by Sakurai Ts (@sakurai_ts_745cc85d7d4294).</description>
    <link>https://dev.to/sakurai_ts_745cc85d7d4294</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3358286%2Fbf7b02db-513e-41c8-9402-203868fc08ce.gif</url>
      <title>DEV Community: Sakurai Ts</title>
      <link>https://dev.to/sakurai_ts_745cc85d7d4294</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sakurai_ts_745cc85d7d4294"/>
    <language>en</language>
    <item>
      <title>Separating Prompt Discovery from AI Generation in Next.js</title>
      <dc:creator>Sakurai Ts</dc:creator>
      <pubDate>Mon, 14 Sep 2026 17:52:46 +0000</pubDate>
      <link>https://dev.to/sakurai_ts_745cc85d7d4294/separating-prompt-discovery-from-ai-generation-in-nextjs-28am</link>
      <guid>https://dev.to/sakurai_ts_745cc85d7d4294/separating-prompt-discovery-from-ai-generation-in-nextjs-28am</guid>
      <description>&lt;p&gt;I'm sharing a look at my own project, &lt;a href="https://journalprompts.org/" rel="noopener noreferrer"&gt;Journal Prompts&lt;/a&gt;, a web app that helps someone pick a writing question and start a journal entry. The implementation uses Next.js, React, Cloudflare Workers, and Drizzle with Neon PostgreSQL.&lt;/p&gt;

&lt;p&gt;The useful design question is where a model call belongs. A person opening a journal may only need one existing question and somewhere to write. That gives the application two distinct paths: selecting a curated prompt and generating a new one.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Keep prompt selection separate from generation
&lt;/h2&gt;

&lt;p&gt;The curated library stores questions with fields such as mood, writing direction, and topic. The matcher uses those fields to select existing questions. A request for a gratitude prompt can stay within that collection, while a general request can use the broader library.&lt;/p&gt;

&lt;p&gt;This path does not need an AI provider request. Its tradeoff is editorial work: someone still needs to review the questions and how they are categorized. Matching existing text also has a smaller range of responses than generating new text.&lt;/p&gt;

&lt;p&gt;AI generation is a separate server action. It validates the input, uses the authenticated session, checks the account's allowance, and calls OpenRouter. The response includes the generated question and remaining usage information. Keeping this separate makes the dependency visible: provider failures affect the generation action rather than turning the prompt collections into empty pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Treat writing storage as a product boundary
&lt;/h2&gt;

&lt;p&gt;The editor supports local guest writing and account-based saving. These serve different expectations.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Writing mode&lt;/th&gt;
&lt;th&gt;Storage boundary&lt;/th&gt;
&lt;th&gt;What the interface needs to explain&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Guest writing&lt;/td&gt;
&lt;td&gt;Browser localStorage&lt;/td&gt;
&lt;td&gt;Entries stay on that browser and can be removed when browser data is cleared.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account-based saving&lt;/td&gt;
&lt;td&gt;Server persistence through Drizzle and PostgreSQL&lt;/td&gt;
&lt;td&gt;Saving depends on authentication and a successful server response.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A writing field containing text is not, by itself, evidence that the text reached the server. Likewise, being signed in does not make a local draft a completed cloud save. The interface needs to keep those states understandable.&lt;/p&gt;

&lt;p&gt;This is a useful distinction for other small web tools too: a local-first starting experience and an account-backed service can coexist, but the storage boundary should be explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Put generation checks on the server
&lt;/h2&gt;

&lt;p&gt;The browser can display a remaining allowance, but it is not the authority for that allowance. The generation action checks the current account and recorded usage before calling the provider. Input validation also happens at that boundary.&lt;/p&gt;

&lt;p&gt;The practical tradeoff is another database-dependent step in the generated-prompt path. In exchange, the client does not need to carry provider credentials or decide which account benefits apply. Curated selection remains a separate operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Let topic pages work as ordinary web pages
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://journalprompts.org/journal-prompt-generator" rel="noopener noreferrer"&gt;journal prompt generator page&lt;/a&gt; combines explanatory content with the interactive finder. Other pages focus on daily reflection, gratitude, morning writing, and related topics. They contain ordinary links to related collections and the finder.&lt;/p&gt;

&lt;p&gt;These pages give someone a useful starting point before they interact with the application. The same links also provide a crawlable structure: a topic page can lead to a related topic, a technique, or the main tool.&lt;/p&gt;

&lt;p&gt;OpenNext adapts the Next.js application for Cloudflare Workers. Database access and generation remain server-side concerns, while React handles the interactive selection and writing experience.&lt;/p&gt;

&lt;p&gt;For a small AI-assisted product, it helps to draw these boundaries before adding more features: what can work from reviewed content, what requires a model, and when a local interaction becomes a server-backed promise. Those decisions shape both the interface and the failure cases it needs to handle.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A Government Liquor Board Lab-Tests Every Bottle It Sells. Almost Nobody Uses the Data.</title>
      <dc:creator>Sakurai Ts</dc:creator>
      <pubDate>Sat, 08 Aug 2026 12:43:47 +0000</pubDate>
      <link>https://dev.to/sakurai_ts_745cc85d7d4294/a-government-liquor-board-lab-tests-every-bottle-it-sells-almost-nobody-uses-the-data-1j4d</link>
      <guid>https://dev.to/sakurai_ts_745cc85d7d4294/a-government-liquor-board-lab-tests-every-bottle-it-sells-almost-nobody-uses-the-data-1j4d</guid>
      <description>&lt;p&gt;The best datasets aren't hidden. They're published in plain sight by bureaucracies that have no idea anyone might want them — collected for compliance, exposed out of habit, and abandoned one page at a time.&lt;/p&gt;

&lt;p&gt;Here's my favorite example. The LCBO — Ontario's government liquor monopoly, and one of the largest single buyers of beverage alcohol in the world — runs a quality-assurance lab. Products it sells get tested, and each product page on lcbo.com quietly lists a field most shoppers scroll past: &lt;strong&gt;Sugar Content&lt;/strong&gt;, in grams per litre. Lab-measured residual sugar, for essentially the entire catalogue. Roughly thirty thousand SKUs.&lt;/p&gt;

&lt;p&gt;Now the punchline: the site gives you &lt;em&gt;no way to use it&lt;/em&gt;. You can sort the catalogue by price, rating, novelty, name — everything except sugar. The single field that matters to diabetics, keto dieters, and every person who's ever asked "is this wine actually sweet?" is right there on every page and completely inert. Meanwhile, wine bottles themselves carry no nutrition labels at all in North America.&lt;/p&gt;

&lt;p&gt;Public data, real audience, zero interface. That's not a gap in the market; that's an open door with a welcome mat.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://winesugarcontent.com" rel="noopener noreferrer"&gt;winesugarcontent.com&lt;/a&gt; — the missing interface: 12,635 in-stock products, each with its lab-verified g/L, searchable and sortable by sugar, price, category, country, grape, and a five-tier sweetness scale (bone dry &amp;lt; 1 g/L → sweet &amp;gt; 120 g/L).&lt;/p&gt;

&lt;p&gt;The pipeline, for the technically curious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ingestion.&lt;/strong&gt; The LCBO frontend is driven by Coveo (search-as-a-service). Rather than scraping HTML, you can speak to the same search API the site itself uses — the field is called &lt;code&gt;lcbo_sugar_gm_per_ltr&lt;/code&gt;. A headless browser grabs a short-lived API token; after that it's structured JSON with rate limiting and checkpointed batches. ~30k raw records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleaning.&lt;/strong&gt; Dedupe, normalize, drop delisted items, derive a sweetness tier from measured g/L (never from the marketing "style" field — more on that below). Result: one &lt;code&gt;wines.json&lt;/code&gt; as the single source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Site.&lt;/strong&gt; Astro static generation: one build fans out to ~12,900 pages (a page per product, paginated category lists, a ranked red-wine chart). Tables are server-rendered HTML — crawlable, fast, no client-side data fetching. Postgres (Supabase) holds canonical data; images are re-hosted as WebP on Cloudflare R2; deploys to Cloudflare Pages via GitHub Actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh.&lt;/strong&gt; The scrape → clean → rebuild loop is scripted end to end, so the database tracks the live catalogue instead of decaying into a snapshot.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three rules that shaped the build
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Never let a language model invent a number.&lt;/strong&gt; This project has an audience of diabetics making dosing-adjacent decisions. Every g/L on the site traces to an LCBO lab reading, and every product page links back to the original listing for verification. During development, placeholder rows rendered with a banner reading &lt;code&gt;SAMPLE DATA — DO NOT SHIP&lt;/code&gt;. Data provenance isn't a nice-to-have here; it's the entire product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Trust measurements, not descriptors.&lt;/strong&gt; The LCBO data includes a human-facing style field ("Off-dry &amp;amp; Fruity"). It disagrees with the lab numbers constantly — same descriptor, 3× sugar difference. The tier system uses g/L thresholds only. When measurement and marketing conflict, measurement wins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The interface is the product.&lt;/strong&gt; I added nothing to the data. No scores, no AI summaries, no editorial. Sort-by-sugar — the one feature the source refuses to offer — turns out to be the whole value proposition. My favorite artifact: of 6,172 red wines, exactly &lt;strong&gt;3&lt;/strong&gt; measure above 120 g/L. Every sweet-red seeker in Ontario has been searching for three bottles their whole lives without knowing it. (They're all visible in the &lt;a href="https://winesugarcontent.com/red-wine-sweetness-chart/" rel="noopener noreferrer"&gt;red wine sweetness chart&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The replicable part
&lt;/h2&gt;

&lt;p&gt;This pattern generalizes further than wine. Government purchasing monopolies are accidental data publishers: Sweden's Systembolaget, Norway's Vinmonopolet, Finland's Alko and Quebec's SAQ all publish comparable sugar figures. Same schema, different flag — each one a weekend project waiting for someone whose audience needs it.&lt;/p&gt;

&lt;p&gt;The formula: find a field a bureaucracy measures but doesn't surface, verify it's genuinely public, respect the source (rate limits, attribution, link back), and build the missing index. You're not creating information. You're creating &lt;em&gt;access&lt;/em&gt; — and access, it turns out, is most of what people were missing.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>datascience</category>
      <category>astro</category>
    </item>
  </channel>
</rss>
