<?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: Rusu Ionut</title>
    <description>The latest articles on DEV Community by Rusu Ionut (@sir-john).</description>
    <link>https://dev.to/sir-john</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F144753%2Ffed5f112-6526-409a-83c9-24b4234d3f54.jpg</url>
      <title>DEV Community: Rusu Ionut</title>
      <link>https://dev.to/sir-john</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sir-john"/>
    <language>en</language>
    <item>
      <title>How I Built rusu-ionut.ro: A Personal Site With Zero Hardcoded Pages</title>
      <dc:creator>Rusu Ionut</dc:creator>
      <pubDate>Mon, 19 Oct 2026 14:48:49 +0000</pubDate>
      <link>https://dev.to/sir-john/how-i-built-rusu-ionutro-a-personal-site-with-zero-hardcoded-pages-43ca</link>
      <guid>https://dev.to/sir-john/how-i-built-rusu-ionutro-a-personal-site-with-zero-hardcoded-pages-43ca</guid>
      <description>&lt;p&gt;Most personal sites are a handful of pages that you edit in code, rebuild and upload. Mine has no pages in the code at all. &lt;a href="https://rusu-ionut.ro" rel="noopener noreferrer"&gt;rusu-ionut.ro&lt;/a&gt; is a small content management system that I built for myself: the pages, menus, blog articles and portfolio projects live in a database, and the site draws whatever the API returns for the address you opened.&lt;/p&gt;

&lt;p&gt;This article is the tour I would have wanted before starting: how the site is put together, what is in it, how I keep it from breaking, and the handful of lessons that cost me the most time. The first commit of this rebuild is dated 25 September 2026, and at the time of writing the site is already on version 1.29.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: a site with no hardcoded pages
&lt;/h2&gt;

&lt;p&gt;The rule I started from is simple: a new public URL should be a piece of content, not a code change. If I want a new page I open the backoffice, give it a title, a place in the page tree and a layout, and drop content blocks onto it. No build, no deploy, no pull request.&lt;/p&gt;

&lt;p&gt;That rule shaped everything else. Visitors never sign in, because the public side has no user accounts. Only the owner and editors sign in, and only to a separate backoffice. The public site is a read-only reflection of the content.&lt;/p&gt;

&lt;p&gt;It is more moving parts than a static site generator, and I will not pretend otherwise. What I get in return is editing from any browser, a real media library, drafts, ordering by drag and drop, and a clean split between content (data) and presentation (components).&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack at a glance
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Front end:&lt;/strong&gt; Vue 3 with TypeScript and Vite, Vuetify 4 for components, Tailwind CSS 4 for layout and utility styling, Pinia for state, Vue Router, Axios for HTTP, TipTap for rich-text editing and ApexCharts for the dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back end:&lt;/strong&gt; Node.js with Express 5 and TypeScript, Mongoose for the models, Zod for validating every write, Helmet for secure headers and Multer for uploads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; MongoDB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tooling:&lt;/strong&gt; ESLint, Prettier, Husky, Vitest and Playwright.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two apps, one repository. The layout is deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client/    Vue single-page app (public site and backoffice)
server/    Express API: routes, controllers, services, models
scripts/   Repository tooling
e2e/       Playwright specs that drive the real site
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server the flow is always the same: a router handles the path, a Zod schema validates the body, a thin controller delegates, and a service holds the business logic. On the client, a view only composes components, composables fetch and shape data, and constants hold every label. I come back to why that matters in the section on rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a page is built
&lt;/h2&gt;

&lt;p&gt;A page is a node in a tree with a path, a layout and a list of blocks. Each block sits in one of four regions: banner, main, sidebar or footer. When you open any address that is not an article or a project, a single catch-all view asks the API for the page at that path and hands its blocks to a renderer. The home page is simply the page with an empty path.&lt;/p&gt;

&lt;p&gt;The renderer is a lookup table from block type to Vue component. There are eleven types: free rich text, plus ten widgets (fact cards, skills, timeline, logo grid, certifications, profile card, contact form, blog feed, portfolio list and promo banner).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// simplified: one entry per block type&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;componentMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HtmlBlock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;widget_skills&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WidgetSkills&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;widget_timeline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WidgetTimeline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;widget_blog_feed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WidgetBlogFeed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a new kind of block means touching exactly two places: the editor's registry, which tells the backoffice which fields to offer, and this renderer. Keeping the two in sync is a rule, so the editor never offers a block the site cannot draw.&lt;/p&gt;

&lt;p&gt;Blocks below the fold are not rendered until they are about to scroll into view, and heavier widgets load on demand behind a small loading bar, so the first screen stays light.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4y9zmibtefnko98u6kj3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4y9zmibtefnko98u6kj3.png" alt="Backoffice page editor with the four regions (banner, main, sidebar, footer) and a few blocks placed." width="800" height="1156"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Backoffice page editor with the four regions (banner, main, sidebar, footer) and a few blocks placed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5hmpux31em5glsbip9pi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5hmpux31em5glsbip9pi.png" alt="The add-block picker showing the block types." width="800" height="595"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The add-block picker showing the block types.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The backoffice
&lt;/h2&gt;

&lt;p&gt;The backoffice is a separate part of the same app, with its own shell and its own light and dark switch, independent of the public theme. A short tour:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard:&lt;/strong&gt; a chart of how much content there is, a gauge of the average site rating, the latest inquiries, recent sign-ins and a visitors overview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search:&lt;/strong&gt; one box that searches blocks, pages, articles, projects and menu links. It shows the text that matched and, for a block, which pages use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pages, blocks and menus:&lt;/strong&gt; a page tree, a reusable block library, and header, footer and social menus that reorder by drag and drop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Media library:&lt;/strong&gt; uploads with type and size limits, previews, file details and a picker that every editor reuses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Articles and projects:&lt;/strong&gt; full editors with cover images, categories, tags, publish dates and a draft switch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audience:&lt;/strong&gt; an inbox for contact messages, newsletter subscribers, ratings and a log of sign-ins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings:&lt;/strong&gt; owner-only switches, such as taking the site offline for maintenance or showing the ratings widget.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are two roles. Editors create and update content; owners can also delete things and change settings.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4y9zmibtefnko98u6kj3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4y9zmibtefnko98u6kj3.png" alt="The dashboard with the visitors section visible." width="800" height="1156"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The dashboard with the visitors section visible.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The rich-text editor, and not rewriting your content
&lt;/h3&gt;

&lt;p&gt;Articles and text blocks are written in an editor built on TipTap, with three views: Visual, HTML and Preview. The preview shows the content with the site's own styling, in light or dark.&lt;/p&gt;

&lt;p&gt;The detail I am most pleased with is small. A visual editor only understands the markup it was built for; hand it anything else and it quietly drops it on the next edit. So before opening content in Visual mode, the editor checks whether it contains anything it would lose: unsupported tags, attributes or inline styles. If it does, the content opens in HTML mode with a warning, instead of being rewritten behind my back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9riea67j9syzc1rr0zv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9riea67j9syzc1rr0zv.png" alt="The article editor showing the Visual, HTML and Preview switch and the toolbar." width="800" height="595"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The article editor showing the Visual, HTML and Preview switch and the toolbar.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The blog
&lt;/h2&gt;

&lt;p&gt;The blog you are reading is part of the same system. Articles have a cover image, a category, tags, a publish date and a summary. When I leave the summary empty, the site cuts an excerpt from the first lines of the article for the cards.&lt;/p&gt;

&lt;p&gt;The list supports search, category and tag filters, and pagination, and every article shows its reading time. On an article page you can change the text size (it only affects the open article and resets when you leave), copy the link, or open a QR code for the page. The article ends with an author card with ways to get in touch.&lt;/p&gt;

&lt;p&gt;Article HTML is cleaned before it is drawn, so even if something odd ends up in a body it cannot run a script on a visitor's page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9riea67j9syzc1rr0zv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9riea67j9syzc1rr0zv.png" alt="An article page on desktop, showing the text-size control and the share panel." width="800" height="595"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;An article page on desktop, showing the text-size control and the share panel.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The portfolio
&lt;/h2&gt;

&lt;p&gt;Projects have their own cards, which you can view as a grid or a list, and their own pages with the story, the technologies and links to the live site and the source. You can search by name or technology and narrow the list with technology chips. The count updates as you filter, and an empty result offers a one-click way back to everything.&lt;/p&gt;

&lt;p&gt;A promo banner block can also spotlight one project on any page: a headline, a short pitch, up to two buttons and a screenshot with a gentle float or tilt. A thin light travels around its border. All of that motion switches off for visitors who prefer reduced motion.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4rx70fcdaa4jhp8v6t4u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4rx70fcdaa4jhp8v6t4u.png" alt="The portfolio list with the search field and technology chips." width="800" height="911"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The portfolio list with the search field and technology chips.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  A site that feels alive
&lt;/h2&gt;

&lt;p&gt;Visitors get dark by default, with a light switch. Both themes come from one set of colour tokens shared by Tailwind and Vuetify, so a button, a card and a chart never disagree about what "primary" means.&lt;/p&gt;

&lt;p&gt;On the first visit a startup splash holds until the page's content has actually arrived, then fades. You see one welcome instead of a welcome followed by a second wait. The home page hero is an illustrated landscape: drifting clouds and a sun by day; stars, shooting stars and fireflies at night; and a cyclist riding along the crest of the hills. Inner pages have a calmer mountain version. Everything animated respects reduced motion.&lt;/p&gt;

&lt;p&gt;The unglamorous states are designed too: a friendly 404, a retry page for when something fails to load, a maintenance page, an unexpected-error screen, and a contact form that guides you through a message and confirms that it was sent. A small feedback invitation and a rating dialog collect opinions, and on phones the invitation starts minimised so it never covers the content.&lt;/p&gt;

&lt;p&gt;There is also a standalone résumé page that prints cleanly, and an in-app changelog: click the version number in the footer to see what changed and when.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Frusu-ionut.ro%2Fuploads%2F2026%2F10%2Fscreenshot-2026-10-19-at-17-32-32-ionu-rusu-fullstack-develo-f35d89ea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Frusu-ionut.ro%2Fuploads%2F2026%2F10%2Fscreenshot-2026-10-19-at-17-32-32-ionu-rusu-fullstack-develo-f35d89ea.png" alt="The same home hero in dark and light theme, side by side." width="800" height="2789"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same home hero in dark and light theme, side by side.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa0nqoolp2524sbao8xtr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa0nqoolp2524sbao8xtr.png" alt="One page at phone, tablet and desktop width, side by side." width="431" height="950"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One page at phone, tablet and desktop width, side by side.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw7h7v4s1nvi918xil2xv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw7h7v4s1nvi918xil2xv.png" alt="Friendly states such as the 404 or maintenance page." width="756" height="1145"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Friendly states such as the 404 or the maintenance page.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Findable and shareable
&lt;/h2&gt;

&lt;p&gt;Each page and article has its own title and description, a canonical address, Open Graph and Twitter metadata for link previews, and structured data. The sitemap is generated from the published content, so it never goes stale. On the page itself, sharing is one click away, with a copy-link control and a QR code for moving from a laptop to a phone.&lt;/p&gt;
&lt;h2&gt;
  
  
  Visitor statistics without the creepy parts
&lt;/h2&gt;

&lt;p&gt;I wanted to know which pages and countries bring people in, without handing visitors to a third-party tracker. The result is a small, built-in counter with a few firm rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No analytics cookies, and nothing is written to the visitor's browser.&lt;/li&gt;
&lt;li&gt;Only the page path is recorded, with query strings and fragments thrown away. Admin screens, previews and obvious bots are excluded, and Do Not Track and Global Privacy Control are honoured.&lt;/li&gt;
&lt;li&gt;Unique visitors are estimated without storing an IP address or a browser signature, and the records expire automatically after a few months.&lt;/li&gt;
&lt;li&gt;Countries are worked out locally from a bundled public-domain dataset, so no visitor information is sent to a lookup service.&lt;/li&gt;
&lt;li&gt;If collecting a visit fails, nothing the visitor sees is affected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade-off is honesty about accuracy. It is an estimate, shared connections and VPNs blur it, and it only counts from the day it was switched on.&lt;/p&gt;
&lt;h2&gt;
  
  
  Security, in plain words
&lt;/h2&gt;

&lt;p&gt;I will keep this section deliberately general, because how protection works is not something to publish. The principles are ordinary ones, applied everywhere. Every write is validated against a schema before it touches the database. Anything that renders rich content is sanitised first. Roles limit who can delete or change settings, and the API is throttled. Secrets stay in configuration and never in code, and signing in to the backoffice takes more than a password. Errors leave the API as short codes rather than stack traces, and the backoffice turns them into plain sentences.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rules, tests and a changelog
&lt;/h2&gt;

&lt;p&gt;A project that grows by a feature a day will rot quickly unless something pushes back. In this repository the pushback is a rulebook, &lt;code&gt;AGENTS.md&lt;/code&gt;, that binds every contributor, human or AI assistant. It is long, but the ideas are few:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One source of truth.&lt;/strong&gt; Colours come from theme tokens, user-facing text from one dictionary, and option values and icon names from a registry of literals. Writing &lt;code&gt;"flat"&lt;/code&gt; or &lt;code&gt;"mdi-plus"&lt;/code&gt; inline in a template is a violation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screens are orchestrators.&lt;/strong&gt; A new screen only composes smaller components and composables. It never owns the markup of every part.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fixed file order.&lt;/strong&gt; Template first, then the script with its sections in a set order, then styles only when Tailwind and Vuetify cannot do the job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave it cleaner.&lt;/strong&gt; Every change ends with a cleanup pass: dead styles, unused imports, orphaned logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preserve the design.&lt;/strong&gt; A layout that is already on screen is the design. Fixing one thing must not move another.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is what the literals rule looks like in practice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- before --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;v-btn&lt;/span&gt; &lt;span class="na"&gt;color=&lt;/span&gt;&lt;span class="s"&gt;"primary"&lt;/span&gt; &lt;span class="na"&gt;variant=&lt;/span&gt;&lt;span class="s"&gt;"flat"&lt;/span&gt; &lt;span class="na"&gt;prepend-icon=&lt;/span&gt;&lt;span class="s"&gt;"mdi-plus"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Save
&lt;span class="nt"&gt;&amp;lt;/v-btn&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- after: same pixels, every value sourced --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;v-btn&lt;/span&gt;
  &lt;span class="na"&gt;:color=&lt;/span&gt;&lt;span class="s"&gt;"UI_COLORS.PRIMARY"&lt;/span&gt;
  &lt;span class="na"&gt;:variant=&lt;/span&gt;&lt;span class="s"&gt;"UI_OPTIONS.FLAT"&lt;/span&gt;
  &lt;span class="na"&gt;:prepend-icon=&lt;/span&gt;&lt;span class="s"&gt;"UI_ICONS.PLUS"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  {{ LABELS.SAVE }}
&lt;span class="nt"&gt;&amp;lt;/v-btn&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks like ceremony until the day a label changes or a button style needs to move, and it is a one-line edit instead of a search through every file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tests that look at the real site
&lt;/h3&gt;

&lt;p&gt;Unit tests (Vitest, twenty files today) cover pure logic: text helpers, validation schemas, the visitor-counting rules, the splash screen's timing. The part I care about more is the browser suite: 19 Playwright specs that drive the running site at phone, tablet and desktop widths. The rules for those specs are all there because something went wrong without them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every user-visible feature ships with a spec that covers all of its states: loading, loaded, failed and empty. The states are produced by holding, failing or faking the network request, not by hoping the database happens to hold the right data.&lt;/li&gt;
&lt;li&gt;Assert on what is painted, not on its wrapper. A box can have the right size while the image inside it is blank.&lt;/li&gt;
&lt;li&gt;Wait out the startup splash, because it covers the page while the tests still count the content as visible.&lt;/li&gt;
&lt;li&gt;Run each spec at least twice. A flaky test is fixed at its cause, never with a retry or a longer timeout.&lt;/li&gt;
&lt;li&gt;Look at the screenshots. A passing assertion behind an overlay proves nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A Husky hook runs lint, formatting, the unit tests and both builds before a commit is accepted.&lt;/p&gt;

&lt;h3&gt;
  
  
  A changelog people can read
&lt;/h3&gt;

&lt;p&gt;Every finished feature or fix bumps the version in lockstep across the packages and adds an entry to the in-app changelog. That changelog has its own rule: milestones only, written for visitors, in plain language, never describing how something works or what it runs on. It is the one place where the project talks to its readers, so it avoids the internals on purpose. Version 1.29 is summed up as understand your audience, a project spotlight, a page for every project and a rider on the hills.&lt;/p&gt;

&lt;p&gt;One more honest note. The rulebook is written for contributors and AI assistants alike, and I do use assistants on this project. The rulebook is what keeps that work consistent, reviewable and verified, whoever or whatever writes the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned along the way
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Same names, different layers.&lt;/strong&gt; Vuetify ships helper classes named like Tailwind's (&lt;code&gt;pt-16&lt;/code&gt;, &lt;code&gt;justify-start&lt;/code&gt;), and they sit in a CSS layer that outranks Tailwind's responsive variants. A &lt;code&gt;sm:&lt;/code&gt; override that looks right in the code silently loses. The fix is to write the value that must win as an arbitrary value, such as &lt;code&gt;pt-[4rem]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hidden is not clipped.&lt;/strong&gt; A decorative light travelling around a banner's border was wider than a phone's side margin. Masking it hid the light but did not stop it from widening the page, so the home page slid sideways on phones. Each ring now clips its own light.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A test can pass behind a curtain.&lt;/strong&gt; Playwright counts the content under the startup splash as visible, so an assertion can pass while the screenshot shows only the splash. Waiting out the splash is now part of every spec.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start-up cost is user-facing.&lt;/strong&gt; The first visit after a quiet spell is the one that makes the impression. I trimmed what the server does at boot, so heavy libraries now load on first use, and the splash waits for real content to avoid a second wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constraints are a feature.&lt;/strong&gt; A rule like "no new local constants for values that already have a home" feels slow for a day, and then it pays back every time a label or a colour has to change.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where to look next
&lt;/h2&gt;

&lt;p&gt;If you want the short version: a Vue 3 front end and an Express API around a MongoDB database, where pages are data, blocks are components, the backoffice is the only place anything is written, and a rulebook plus a real-browser test suite keeps the whole thing honest.&lt;/p&gt;

&lt;p&gt;Click the version number in the footer on &lt;a href="https://rusu-ionut.ro" rel="noopener noreferrer"&gt;rusu-ionut.ro&lt;/a&gt; to read the release history, browse the projects to see what else I have built, and if you have a question about any of this, the contact page is a message away.&lt;/p&gt;

</description>
      <category>vue</category>
      <category>typescript</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why I Ditched VS Code for Zed (And How It Accelerated My Full-Stack Work)</title>
      <dc:creator>Rusu Ionut</dc:creator>
      <pubDate>Wed, 03 Jun 2026 17:27:00 +0000</pubDate>
      <link>https://dev.to/sir-john/why-i-ditched-vs-code-for-zed-and-how-it-accelerated-my-full-stack-work-4fo8</link>
      <guid>https://dev.to/sir-john/why-i-ditched-vs-code-for-zed-and-how-it-accelerated-my-full-stack-work-4fo8</guid>
      <description>&lt;p&gt;For the past few years, VS Code was my comfortable, heavily customized home. It had my extensions, my themes, and a decade's worth of muscle memory. But recently, as my stack evolved heavily into &lt;strong&gt;Express.js, Vue 3, and Tailwind CSS&lt;/strong&gt;, I started noticing the micro-frictions. &lt;/p&gt;

&lt;p&gt;VS Code is an industrial powerhouse, but it is built on Electron. Under the hood, it’s running a wrapped version of Google Chrome. When you are deeply indexing thousands of dependencies inside &lt;code&gt;node_modules&lt;/code&gt;, running real-time frontend dev servers, and layering on AI code completions, those micro-stutters and cursor lags start to compound.&lt;/p&gt;

&lt;p&gt;Two weeks ago, I completely cut the cord and switched to &lt;strong&gt;Zed&lt;/strong&gt;—the open-source, GPU-accelerated code editor written from scratch in Rust.&lt;/p&gt;

&lt;p&gt;Here is the real, unvarnished story of why I made the jump, the brutal financial reality check I ran into with token billing, and how this layout shift dramatically accelerated development on my main production project, &lt;strong&gt;&lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;recipe-finder.org&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Blazing Speed (The Rust Difference)
&lt;/h2&gt;

&lt;p&gt;The very first thing you notice when you open Zed is that it doesn't "launch"—it just appears. Because it's compiled natively in Rust and uses your computer's graphics card to render text, everything from file searching (&lt;code&gt;Ctrl + P&lt;/code&gt;) to global project searching is instantaneous. &lt;/p&gt;

&lt;p&gt;When working on the codebase for &lt;strong&gt;&lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;recipe-finder.org&lt;/a&gt;&lt;/strong&gt;, jumping back and forth between a complex Express backend route and a Vue frontend single-file component used to feel like dragging a heavy cart. In Zed, switching tabs and editing massive lines of code feels buttery smooth. It completely eliminated the cursor lag that used to break my typing flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Perfect Out-of-the-Box Tooling for Vue &amp;amp; Tailwind
&lt;/h2&gt;

&lt;p&gt;I was worried that leaving VS Code's massive ecosystem of 50,000+ extensions would break my stack. It didn't. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS:&lt;/strong&gt; Zed has &lt;em&gt;native&lt;/em&gt; support built right in. As I write utility classes inside my &lt;code&gt;.vue&lt;/code&gt; templates, autocomplete popups appear flawlessly. Hovering over a class instantly shows the raw CSS output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vue.js:&lt;/strong&gt; A quick trip to Zed's extension marketplace (&lt;code&gt;Ctrl + Shift + X&lt;/code&gt;) to grab the official Vue extension hooked up Volar (the Vue Language Server) seamlessly. Full type-checking, diagnostics, and scoped styling worked instantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Express.js:&lt;/strong&gt; Because Zed indexes &lt;code&gt;node_modules&lt;/code&gt; instantly in the background without locking up the editor UI thread, my Node/Express server setup became incredibly responsive to work with.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Token Price Shock (And How I Fixed It)
&lt;/h2&gt;

&lt;p&gt;If you use Zed’s native AI panel on the right side, you need to understand how it bills. Zed Pro gives you a $5 monthly token credit and then switches to pay-as-you-go raw API costs +10%. &lt;/p&gt;

&lt;p&gt;Within my first hour, &lt;strong&gt;I burned through $2.63 in just four questions.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;I had massive sticker shock until I realized how LLMs work under the hood. It’s called the &lt;strong&gt;Context Snowball Effect&lt;/strong&gt;. Every time you type a follow-up question in the right-hand panel, Zed bundles the &lt;em&gt;entire chat history&lt;/em&gt; plus your &lt;em&gt;active code files&lt;/em&gt; and flings them back to the model. You aren't paying for small questions; you are paying the AI to re-read your entire file over and over again from scratch.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution: The Ultimate Hybrid Workflow
&lt;/h3&gt;

&lt;p&gt;Instead of paying for metered usage-based billing with Zed-hosted models, I leveraged a brilliant feature built directly into the editor: &lt;strong&gt;I plugged in my existing $39 GitHub Copilot subscription.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By pressing &lt;code&gt;Ctrl + Shift + P&lt;/code&gt; and running &lt;code&gt;copilot: sign in&lt;/code&gt;, I authorized my GitHub account. Then, I switched my right-side AI panel dropdown provider to &lt;strong&gt;"GitHub Copilot Chat"&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;Now, I get the flat-rate predictability of Copilot (ask 500 questions a day, leave massive Vue files attached, no extra fees) combined with the lightning-fast, zero-lag editing environment of Zed. It’s the ultimate setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Accelerated Recipe-Finder.org
&lt;/h2&gt;

&lt;p&gt;We’ve been sprinting hard to push out a massive referral marketing campaign on &lt;strong&gt;&lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;recipe-finder.org&lt;/a&gt;&lt;/strong&gt;: a brand-new invite portal titled &lt;strong&gt;"Get a FREE Month of Premium!"&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;This required writing a specialized absolute overlay over our main Vuetify cards to handle asynchronous loading states smoothly. In my old workflow, tweaking the custom Vue deep selectors (&lt;code&gt;:deep(.v-loader)&lt;/code&gt;) and alignment styling while waiting for code completions to catch up was tedious. &lt;/p&gt;

&lt;p&gt;With Zed docked perfectly to my preferences—with the terminal and project panels pushed over to the right side just like VS Code, and the minimap file preview pinned to &lt;code&gt;always&lt;/code&gt;—the cycle time dropped to zero.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- A snippet of the optimized portal cards we shipped under the new setup --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;v-card&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"premium-invite-card"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;v-card-title&amp;gt;&lt;/span&gt;Get a FREE Month of Premium!&lt;span class="nt"&gt;&amp;lt;/v-card-title&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- Referral portal components --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/v-card&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;style&lt;/span&gt; &lt;span class="na"&gt;scoped&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nc"&gt;.premium-invite-card&lt;/span&gt; &lt;span class="nd"&gt;:deep&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.v-loader&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--v-theme-surface&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;backdrop-filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;6px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;style&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The combination of instant file-saving formatting, snappy inline AI predictions, and zero-latency UI rendering allowed us to prototype, style, and ship the invite portal features significantly faster than before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Final Verdict
&lt;/h2&gt;

&lt;p&gt;If your engineering workflow depends on hyper-niche VS Code extensions (like complex graphical visual debuggers or proprietary database managers), keep VS Code close by. &lt;/p&gt;

&lt;p&gt;But if you are a full-stack developer working in the JavaScript/Node ecosystem, handling reactive frontends, and writing code alongside AI assistants all day, &lt;strong&gt;do not sleep on Zed&lt;/strong&gt;. Connect your existing Copilot key, clean up your layout, and enjoy a text editor that finally keeps up with the speed of your brain.&lt;/p&gt;

&lt;p&gt;Have you tried switching to Zed yet, or are you staying loyal to VS Code? Let me know your setup below!&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>vue</category>
      <category>node</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How I built a Social Recipe Extractor that turns short-form video links into structured recipes</title>
      <dc:creator>Rusu Ionut</dc:creator>
      <pubDate>Wed, 13 May 2026 15:38:18 +0000</pubDate>
      <link>https://dev.to/sir-john/how-i-built-a-social-recipe-extractor-that-turns-short-form-video-links-into-structured-recipes-11j6</link>
      <guid>https://dev.to/sir-john/how-i-built-a-social-recipe-extractor-that-turns-short-form-video-links-into-structured-recipes-11j6</guid>
      <description>&lt;p&gt;I recently built a feature for &lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;https://recipe-finder.org&lt;/a&gt; that takes a TikTok, Instagram, or YouTube cooking link and turns it into a structured recipe.&lt;/p&gt;

&lt;p&gt;The goal was simple: recipe inspiration lives on social platforms, but the actual cooking steps are usually buried inside captions, spoken instructions, or fast cuts. I wanted a workflow where a user pastes a link and gets back something usable: a recipe title, ingredient list, instructions, and a cleaner format for the rest of the product.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Flow
&lt;/h3&gt;

&lt;p&gt;The core user journey is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user pastes a supported social video URL.&lt;/li&gt;
&lt;li&gt;The frontend sends that URL to a protected backend endpoint.&lt;/li&gt;
&lt;li&gt;The backend normalizes the link, fetches available source metadata and text, and runs an extraction pass.&lt;/li&gt;
&lt;li&gt;The result is converted into a structured recipe object.&lt;/li&gt;
&lt;li&gt;The app returns a clean recipe view, keeps recent imports in history, and can reuse cached results for repeated links.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What makes this interesting isn't just the form input—it's the cleanup step between raw social content and something a user can actually cook from. &lt;/p&gt;

&lt;p&gt;Here is a look under the hood at how it's built using Vue 3, TypeScript, and Node.js/Express.&lt;/p&gt;




&lt;h3&gt;
  
  
  1. Frontend Entry Point
&lt;/h3&gt;

&lt;p&gt;The feature lives behind authentication and opens as a dedicated page in the app. The page itself stays simple and hands the main workflow to a dedicated &lt;code&gt;Social Recipe Extractor&lt;/code&gt; component. &lt;/p&gt;

&lt;p&gt;To keep the UI predictable and sleek (opting for a dark mode aesthetic with vibrant orange accents for active states), the component manages four main states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  The pasted social link&lt;/li&gt;
&lt;li&gt;  Loading and error feedback&lt;/li&gt;
&lt;li&gt;  The extracted recipe result&lt;/li&gt;
&lt;li&gt;  Recent import history and usage state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps the input on the left, the result on the right, and prior imports available without mixing feature logic across multiple pages.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Backend-Owned Extraction Flow
&lt;/h3&gt;

&lt;p&gt;The frontend does not parse social content directly. Instead, it sends the link to a backend route dedicated to social recipe extraction. &lt;/p&gt;

&lt;p&gt;That backend layer is responsible for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Validating the request&lt;/li&gt;
&lt;li&gt;  Normalizing the URL into a canonical form&lt;/li&gt;
&lt;li&gt;  Checking whether a usable cached import already exists&lt;/li&gt;
&lt;li&gt;  Fetching source metadata and extracted text from the target platform&lt;/li&gt;
&lt;li&gt;  Transforming unstructured content into a recipe-shaped response&lt;/li&gt;
&lt;li&gt;  Returning a stable payload the frontend can render safely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the right ownership boundary because platform parsing, rate control, caching, and extraction quality all belong on the server. The frontend only ever deals with a typed response.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Structured Extraction
&lt;/h3&gt;

&lt;p&gt;The most important design choice was ensuring the pipeline doesn't stop at "grab some text from the page." It pushes the output into a highly structured shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Recipe title&lt;/li&gt;
&lt;li&gt;  Prep time&lt;/li&gt;
&lt;li&gt;  Ingredients&lt;/li&gt;
&lt;li&gt;  Instructions&lt;/li&gt;
&lt;li&gt;  Normalized ingredients&lt;/li&gt;
&lt;li&gt;  Source metadata (platform, thumbnail, title, author)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters because a structured result is much easier to reuse than a raw text dump. It allows the data to power future features without rebuilding the extraction logic each time.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Normalization and Cleanup
&lt;/h3&gt;

&lt;p&gt;Social content is inherently messy. Titles contain hashtags, descriptions are noisy, and ingredient mentions are often incomplete or informal. &lt;/p&gt;

&lt;p&gt;The implementation handles this by cleaning and normalizing content before &lt;em&gt;and&lt;/em&gt; after extraction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;URL normalization&lt;/strong&gt; ensures equivalent links map to the same import record.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Title cleanup&lt;/strong&gt; prevents social-platform suffixes and noisy text from leaking into the recipe title.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Whitespace and escaped-text cleanup&lt;/strong&gt; is applied before extraction.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Ingredient normalization&lt;/strong&gt; makes the output more consistent for downstream product use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This cleanup layer is where a lot of the actual product quality is realized. &lt;/p&gt;

&lt;h3&gt;
  
  
  5. Caching and History
&lt;/h3&gt;

&lt;p&gt;Two product decisions make the feature feel significantly faster and more useful:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Cached imports&lt;/strong&gt; prevent unnecessary repeat processing for the same normalized link.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Import history&lt;/strong&gt; gives users a lightweight library of recently extracted recipes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The extractor isn't just a one-shot tool; it becomes a reusable workflow inside the product.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Premium-Friendly Usage Model
&lt;/h3&gt;

&lt;p&gt;Finally, the feature returns usage information along with the extraction result. That allows the UI to immediately show whether the user can continue importing, how much of their current allowance is used, and when an upgrade prompt should appear. &lt;/p&gt;

&lt;p&gt;This is a much better user experience than hard-failing late in the flow, as the interface can explain the state before the user hits a dead end.&lt;/p&gt;




&lt;h3&gt;
  
  
  Wrapping Up
&lt;/h3&gt;

&lt;p&gt;This is one of those features where the product value comes purely from reducing friction. People already save recipes from social media. The real improvement is turning that saved link into something searchable, reusable, and easier to cook from. &lt;/p&gt;

&lt;p&gt;If I were extending it further, the next logical step would be connecting the extracted, structured recipe directly into grocery list generation, smart meal planning, and nutrition analysis.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>vue</category>
      <category>node</category>
      <category>fullstack</category>
    </item>
    <item>
      <title>Recipe-Finder.org is Officially on Android! 📱🍲</title>
      <dc:creator>Rusu Ionut</dc:creator>
      <pubDate>Wed, 13 May 2026 14:56:29 +0000</pubDate>
      <link>https://dev.to/sir-john/recipe-finderorg-is-officially-on-android-4e16</link>
      <guid>https://dev.to/sir-john/recipe-finderorg-is-officially-on-android-4e16</guid>
      <description>&lt;p&gt;Hey DEV community! 👋&lt;/p&gt;

&lt;p&gt;If you've ever used &lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;recipe-finder.org&lt;/a&gt; to figure out what to cook with that random assortment of ingredients left in your fridge, I've got some exciting news. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We've officially gone mobile!&lt;/strong&gt; 🎉&lt;/p&gt;

&lt;p&gt;While the web version has been fantastic, we realized early on that dragging a laptop into the kitchen—or trying to keep a mobile browser awake while your hands are covered in flour—isn't exactly the ultimate user experience. &lt;/p&gt;

&lt;h3&gt;
  
  
  Why We Built the App
&lt;/h3&gt;

&lt;p&gt;Moving from the web to a native mobile environment solved a few core problems for us:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Kitchen Experience:&lt;/strong&gt; Native controls allow us to keep the screen awake while you're actively following a recipe. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quick Access:&lt;/strong&gt; No more digging through a sea of browser tabs. Your saved recipes and grocery lists are now just a tap away on your home screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better Performance:&lt;/strong&gt; Snappier ingredient searches, smoother scrolling, and a much cleaner UI optimized specifically for your phone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Transitioning this project from a web-only platform to an Android application was a great learning experience. (I plan to write a follow-up post doing a deeper dive into the tech stack, the architecture we chose, and the hurdles we faced along the way). &lt;/p&gt;

&lt;p&gt;But for today, I'm just incredibly excited to share this milestone with you all!&lt;/p&gt;

&lt;h3&gt;
  
  
  Give It a Spin
&lt;/h3&gt;

&lt;p&gt;If you're an Android user, you can grab the new app directly from our site here:&lt;br&gt;
👉 &lt;strong&gt;&lt;a href="https://recipe-finder.org/android-app" rel="noopener noreferrer"&gt;Download Recipe-Finder for Android&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'd absolutely love to hear your feedback. If you catch any bugs, have feature requests, or just want to tell me what you're cooking for dinner tonight, drop a comment below!&lt;/p&gt;

</description>
      <category>android</category>
      <category>vue</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From Modal to Full Page: How We Refactored a Vue 3 Recipe Detail View</title>
      <dc:creator>Rusu Ionut</dc:creator>
      <pubDate>Sun, 03 May 2026 19:51:54 +0000</pubDate>
      <link>https://dev.to/sir-john/from-modal-to-full-page-how-we-refactored-a-vue-3-recipe-detail-view-hef</link>
      <guid>https://dev.to/sir-john/from-modal-to-full-page-how-we-refactored-a-vue-3-recipe-detail-view-hef</guid>
      <description>&lt;p&gt;One of the longest-lived technical decisions in our recipe finder app was showing recipe details inside a dialog modal. It worked — until it didn't. Here's how we migrated from a bloated modal to a clean, SEO-friendly full page, what we cut along the way, and what the app looks like now.&lt;/p&gt;

&lt;p&gt;Demo:&lt;br&gt;
&lt;a href="https://recipe-finder.org/recipe/644488-german-rhubarb-cake-with-meringue" rel="noopener noreferrer"&gt;https://recipe-finder.org/recipe/644488-german-rhubarb-cake-with-meringue&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Old Approach: Everything in a Modal
&lt;/h2&gt;

&lt;p&gt;The original setup opened a &lt;code&gt;&amp;lt;v-dialog&amp;gt;&lt;/code&gt; when a user clicked a recipe card. The modal held the entire recipe detail UI: ingredients, nutrition, videos, AI chef, grocery import, recipe scaler — all of it. The logic for opening it, fetching the recipe, and handling deep-link slugs lived inside &lt;code&gt;HomePage.vue&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Old: HomePage.vue controlled everything --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;RecipeDetailsModal&lt;/span&gt; &lt;span class="na"&gt;:is-open=&lt;/span&gt;&lt;span class="s"&gt;"isRecipeModalOpen"&lt;/span&gt; &lt;span class="na"&gt;:recipe=&lt;/span&gt;&lt;span class="s"&gt;"selectedRecipeDetails"&lt;/span&gt; &lt;span class="na"&gt;:loading=&lt;/span&gt;&lt;span class="s"&gt;"loadingRecipeDetails"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;close=&lt;/span&gt;&lt;span class="s"&gt;"closeModal"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The problem was that &lt;code&gt;HomePage.vue&lt;/code&gt; had become a god component. It managed:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The search form and results&lt;/li&gt;
&lt;li&gt;Cuisine carousel&lt;/li&gt;
&lt;li&gt;Recipe of the day&lt;/li&gt;
&lt;li&gt;Recent recipes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And&lt;/strong&gt; the modal open/close state, slug parsing, and detail fetch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On top of that, because everything was in a modal, the URL never changed. Users couldn't share a link to a specific recipe, Google couldn't index the content, and the Back button did nothing useful.&lt;/p&gt;




&lt;h2&gt;
  
  
  The New Approach: Dedicated Route + Page
&lt;/h2&gt;

&lt;p&gt;We created &lt;code&gt;/recipe/:slug&lt;/code&gt; as a proper route and moved the recipe detail logic into a standalone &lt;code&gt;RecipeDetailPage.vue&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// router/index.ts&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/recipe/:slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/pages/RecipeDetailPage.vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Recipe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slugs are derived from the recipe ID and title, making them human-readable and stable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// utils/index.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toRecipeSlug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;^-|-$&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;extractRecipeIdFromSlug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What We Cut From HomePage.vue
&lt;/h2&gt;

&lt;p&gt;Once the page was independent, we stripped &lt;code&gt;HomePage.vue&lt;/code&gt; of everything modal-related. Gone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;isRecipeModalOpen&lt;/code&gt; ref&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;selectedRecipeDetails&lt;/code&gt; and &lt;code&gt;loadingRecipeDetails&lt;/code&gt; state&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;openRecipeModal&lt;/code&gt; / &lt;code&gt;closeModal&lt;/code&gt; handlers&lt;/li&gt;
&lt;li&gt;The slug-watching &lt;code&gt;watch&lt;/code&gt; that re-fetched on URL change&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;&amp;lt;RecipeDetailsModal&amp;gt;&lt;/code&gt; import and component registration&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;handleOpenRecipeDetails&lt;/code&gt; function passed down through three component layers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result was &lt;code&gt;HomePage.vue&lt;/code&gt; shrinking by roughly &lt;strong&gt;40%&lt;/strong&gt; in script size. It now does one thing: show the search form and results.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Page Layout Looks Like
&lt;/h2&gt;

&lt;p&gt;The page uses a standard Vuetify two-column grid — main content on the left, sticky sidebar on the right (desktop only). On mobile, the sidebar collapses and the tools surface inline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;v-row&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Left: main content --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;v-col&lt;/span&gt; &lt;span class="na"&gt;cols=&lt;/span&gt;&lt;span class="s"&gt;"12"&lt;/span&gt; &lt;span class="na"&gt;lg=&lt;/span&gt;&lt;span class="s"&gt;"8"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;v-img&lt;/span&gt; &lt;span class="na"&gt;:src=&lt;/span&gt;&lt;span class="s"&gt;"recipe.image"&lt;/span&gt; &lt;span class="na"&gt;cover&lt;/span&gt; &lt;span class="na"&gt;rounded=&lt;/span&gt;&lt;span class="s"&gt;"lg"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"mb-6"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"recipe-title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ recipe.title }}&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- chips, rating, action buttons, summary, ingredients, instructions, nutrition --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/v-col&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Right: sticky sidebar (desktop only) --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;v-col&lt;/span&gt; &lt;span class="na"&gt;cols=&lt;/span&gt;&lt;span class="s"&gt;"12"&lt;/span&gt; &lt;span class="na"&gt;lg=&lt;/span&gt;&lt;span class="s"&gt;"4"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"d-none d-lg-flex flex-column"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"sidebar-sticky"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="c"&gt;&amp;lt;!-- Recipe Tools card --&amp;gt;&lt;/span&gt;
      &lt;span class="c"&gt;&amp;lt;!-- AI Cooking Chef card --&amp;gt;&lt;/span&gt;
      &lt;span class="c"&gt;&amp;lt;!-- Nutrition Snapshot card --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/v-col&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/v-row&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sidebar cards use a glass morphism style that matches the rest of the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.glass-card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;radial-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;circle&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="nb"&gt;top&lt;/span&gt; &lt;span class="nb"&gt;right&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;163&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;92&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="m"&gt;40%&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;165deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.07&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Mobile: Icon Buttons + Bottom Sheet AI
&lt;/h2&gt;

&lt;p&gt;On mobile, the page can't show a sidebar. We solved this in two ways:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action buttons&lt;/strong&gt; become icon-only on small screens, matching how the modal used to look:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Mobile: icon-only --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"d-flex d-sm-none gap-2 mb-6 align-center"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;v-btn&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt; &lt;span class="na"&gt;variant=&lt;/span&gt;&lt;span class="s"&gt;"tonal"&lt;/span&gt; &lt;span class="na"&gt;size=&lt;/span&gt;&lt;span class="s"&gt;"small"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click.stop=&lt;/span&gt;&lt;span class="s"&gt;"handleToggleFavoriteRecipe"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;v-icon&amp;gt;&lt;/span&gt;{{ isFavorited ? 'mdi-heart' : 'mdi-heart-outline' }}&lt;span class="nt"&gt;&amp;lt;/v-icon&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/v-btn&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;v-btn&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt; &lt;span class="na"&gt;variant=&lt;/span&gt;&lt;span class="s"&gt;"tonal"&lt;/span&gt; &lt;span class="na"&gt;size=&lt;/span&gt;&lt;span class="s"&gt;"small"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"shareRecipe"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;v-icon&amp;gt;&lt;/span&gt;{{ isCopied ? 'mdi-check' : 'mdi-share-variant' }}&lt;span class="nt"&gt;&amp;lt;/v-icon&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/v-btn&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;v-btn&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt; &lt;span class="na"&gt;variant=&lt;/span&gt;&lt;span class="s"&gt;"tonal"&lt;/span&gt; &lt;span class="na"&gt;size=&lt;/span&gt;&lt;span class="s"&gt;"small"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"printRecipe"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;v-icon&amp;gt;&lt;/span&gt;mdi-printer&lt;span class="nt"&gt;&amp;lt;/v-icon&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/v-btn&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Desktop: text buttons --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"d-none d-sm-flex flex-wrap gap-2 mb-6"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;v-btn&lt;/span&gt; &lt;span class="na"&gt;prepend-icon=&lt;/span&gt;&lt;span class="s"&gt;"mdi-heart-outline"&lt;/span&gt; &lt;span class="na"&gt;variant=&lt;/span&gt;&lt;span class="s"&gt;"tonal"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"page-action-btn"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Add to Favorites
  &lt;span class="nt"&gt;&amp;lt;/v-btn&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- ... --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The AI chef&lt;/strong&gt; becomes a bottom sheet triggered by a text button inline with the ingredient tools (no floating action button cluttering the screen):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Ingredient row: Scale / Analyze / Grocery / AI Chef --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;v-btn&lt;/span&gt; &lt;span class="na"&gt;color=&lt;/span&gt;&lt;span class="s"&gt;"primary"&lt;/span&gt; &lt;span class="na"&gt;variant=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;size=&lt;/span&gt;&lt;span class="s"&gt;"small"&lt;/span&gt;
  &lt;span class="na"&gt;prepend-icon=&lt;/span&gt;&lt;span class="s"&gt;"mdi-robot-excited"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"showAiSheet = true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  AI Chef
&lt;span class="nt"&gt;&amp;lt;/v-btn&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;v-bottom-sheet&lt;/span&gt; &lt;span class="na"&gt;v-model=&lt;/span&gt;&lt;span class="s"&gt;"showAiSheet"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- full AI interface --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/v-bottom-sheet&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  SEO + Meta Tags
&lt;/h2&gt;

&lt;p&gt;Since the content is now on a real URL, we inject &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; and Open Graph meta tags dynamically on load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;injectMetaTags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pageTitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; | Recipe Finder`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pageTitle&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setMeta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="nf"&gt;setMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;meta[property='og:title']&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageTitle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;meta[name='twitter:title']&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageTitle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;155&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;meta[name='description']&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;meta[property='og:description']&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;meta[property='og:image']&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is called inside a &lt;code&gt;watch&lt;/code&gt; on the recipe computed ref, so it fires on both initial load and when navigating between similar recipes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Async Components Throughout
&lt;/h2&gt;

&lt;p&gt;Every non-critical UI piece is lazy-loaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ImportToGroceryList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineAsyncComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/components/ImportToGroceryList.vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PremiumUpgradeDialog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineAsyncComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/components/PremiumUpgradeDialog.vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RecipeScaler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineAsyncComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/components/RecipeScaler.vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RecipeEmbedWatermark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineAsyncComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/components/RecipeEmbedWatermark.vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main recipe content renders immediately. The grocery dialog, scaler, embed widget, and upgrade dialog only load if the user actually interacts with them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Auth Guard on Grocery Import
&lt;/h2&gt;

&lt;p&gt;One regression we caught: clicking "Send to Grocery List" while logged out was calling &lt;code&gt;getAccessTokenSilently()&lt;/code&gt; and throwing an Auth0 missing refresh token error. Fixed by checking auth state before opening the dialog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onDialogToggle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;dialogOpen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nf"&gt;loginWithRedirect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;loadGroceryLists&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;HomePage.vue&lt;/code&gt; Script Size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~650 lines&lt;/td&gt;
&lt;td&gt;~390 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Recipe URL Shareable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Google Indexable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mobile Layout&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fullscreen modal&lt;/td&gt;
&lt;td&gt;Native page with bottom sheet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth Crash on Grocery&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⚠️ Existed&lt;/td&gt;
&lt;td&gt;✅ Fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI on Mobile&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Floating button&lt;/td&gt;
&lt;td&gt;Inline trigger → bottom sheet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The modal still exists for the recipe list view (quick-peek without leaving the page), but the canonical experience is now a proper page. The code is cleaner, the app is faster to load, and every recipe finally has a real URL.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Vue 3, Vuetify 3, TypeScript, and Tailwind CSS. Auth via Auth0.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vue</category>
      <category>webdev</category>
      <category>refactoring</category>
      <category>ux</category>
    </item>
    <item>
      <title>Building a Self-Healing SEO Architecture for a Vue SPA</title>
      <dc:creator>Rusu Ionut</dc:creator>
      <pubDate>Fri, 01 May 2026 18:18:17 +0000</pubDate>
      <link>https://dev.to/sir-john/building-a-self-healing-seo-architecture-for-a-vue-spa-4lfb</link>
      <guid>https://dev.to/sir-john/building-a-self-healing-seo-architecture-for-a-vue-spa-4lfb</guid>
      <description>&lt;p&gt;Building a modern Single Page Application (SPA) with Vite and Vue is great for user experience, but it's a minefield for SEO. We faced three major hurdles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Aggressive Bot Protection:&lt;/strong&gt; Our &lt;code&gt;.htaccess&lt;/code&gt; was so tight it was blocking crawlers that we actually wanted.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The "SPA Meta Trap":&lt;/strong&gt; Social media bots (Facebook, WhatsApp) couldn't read our dynamic recipe titles or images because they don't execute JavaScript.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The Scale Problem:&lt;/strong&gt; We have access to millions of recipes via the Spoonacular API, but we don't own the full database. How do you tell Google about millions of pages you don't physically store?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Tech Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; Vite + Vue 3 (Hosted on Apache)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Node.js + Express (Hosted on Firebase Functions)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; MongoDB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider:&lt;/strong&gt; Spoonacular API&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Solution: A 3-Step "Self-Healing" Architecture
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Solving the Social Preview (The Meta Injection)
&lt;/h3&gt;

&lt;p&gt;Since our frontend is on a standard Apache host, we couldn't use edge functions easily. Instead, we optimized our URL structure to include SEO-friendly slugs:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;recipe-finder.org/recipe/644488-german-rhubarb-cake-with-meringue&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;We then implemented a backend-driven meta-injection strategy. When a recipe is requested, our Express server pre-fills the Open Graph tags (&lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;) using the recipe summary, ensuring beautiful previews on social media.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. The "Self-Building" Database
&lt;/h3&gt;

&lt;p&gt;We didn't want to scrape millions of recipes (and get banned). Instead, we created an &lt;strong&gt;Organic Growth Engine&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every time a user (guest or authenticated) clicks a recipe, our Express backend performs an &lt;strong&gt;Upsert&lt;/strong&gt; into MongoDB. If it's a new recipe, it's added to our "SEO Index." If it's an existing one, we update the &lt;code&gt;lastViewed&lt;/code&gt; timestamp.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Remove stale entry, then push back to front with a fresh timestamp&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;recipeViewedModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOneAndUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth0Id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$pull&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recipes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;recipe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;recipeViewedModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOneAndUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth0Id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recipes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$each&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;recipe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;viewedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="na"&gt;$position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;new&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures our database only grows with high-quality, relevant content that users actually care about.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. The Dynamic Hybrid Sitemap
&lt;/h3&gt;

&lt;p&gt;A static &lt;code&gt;sitemap.xml&lt;/code&gt; was impossible for millions of potential links. We built a &lt;strong&gt;Dynamic Sitemap Index&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;sitemap-main.xml&lt;/strong&gt; — A static file on our hosting server for core pages (Home, Tools, About).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sitemap-recipes-[n].xml&lt;/strong&gt; — Dynamic routes on Express that query MongoDB and generate XML on the fly in 50,000-unit chunks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Master Index&lt;/strong&gt; — A central &lt;code&gt;sitemap.xml&lt;/code&gt; that bridges the two, served via a silent proxy in &lt;code&gt;.htaccess&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^sitemap\.xml$ [https://your-region-your-project.cloudfunctions.net/api/sitemap.xml](https://your-region-your-project.cloudfunctions.net/api/sitemap.xml) [R=301,L]
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^sitemap-recipes-([0-9]+)\.xml$ [https://your-region-your-project.cloudfunctions.net/api/sitemap-recipes-$1.xml](https://your-region-your-project.cloudfunctions.net/api/sitemap-recipes-$1.xml) [R=301,L]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any request for a sitemap is silently routed to the Express API, which assembles the XML from MongoDB on the fly — no static file maintenance required.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google Search Console Verified:&lt;/strong&gt; Live URL testing shows Google successfully rendering the SPA and reading the dynamic content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated SEO:&lt;/strong&gt; The more our users cook, the larger our sitemap grows. We don't have to manually add a single link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-Maintenance Scaling:&lt;/strong&gt; The system handles 10 recipes or 10 million with the same memory footprint thanks to MongoDB's &lt;code&gt;$group&lt;/code&gt; and &lt;code&gt;$limit&lt;/code&gt; aggregations.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Key Takeaway for CoderLegion
&lt;/h2&gt;

&lt;p&gt;Don't build for millions of pages on Day 1. Build a system that lets your users' activity grow your SEO footprint for you. Work with the bots, not against them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Author:&lt;/strong&gt; Rusu Ionut&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Project:&lt;/strong&gt; &lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;recipe-finder.org&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>vue</category>
      <category>node</category>
    </item>
    <item>
      <title>Get a FREE Month of Premium! 🚀</title>
      <dc:creator>Rusu Ionut</dc:creator>
      <pubDate>Sat, 25 Apr 2026 16:36:01 +0000</pubDate>
      <link>https://dev.to/sir-john/get-a-free-month-of-premium-4go4</link>
      <guid>https://dev.to/sir-john/get-a-free-month-of-premium-4go4</guid>
      <description>&lt;p&gt;Hey everyone! 👋&lt;/p&gt;

&lt;p&gt;We are excited to share a brand new way for you to enjoy our Premium features. If you've been loving the platform, why not bring a friend along? &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get a FREE month of Premium!&lt;/strong&gt; The process is incredibly simple: just share your unique invite link, and when a friend upgrades their account, your next month is completely on us. There's no limit to how many friends you can invite!&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Grab your invite link here:&lt;/strong&gt; &lt;a href="https://recipe-finder.org/invite-one-free-month" rel="noopener noreferrer"&gt;https://recipe-finder.org/invite-one-free-month&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thank you for being such an awesome part of our community. Happy coding (and cooking)! 💻🍳&lt;/p&gt;

</description>
      <category>recipes</category>
      <category>culinary</category>
      <category>food</category>
      <category>cooking</category>
    </item>
    <item>
      <title>Building Recipe-Finder.org: A Full-Stack Journey with Vue, Express, MongoDB, and Vuetify 🍳</title>
      <dc:creator>Rusu Ionut</dc:creator>
      <pubDate>Sat, 25 Apr 2026 16:30:34 +0000</pubDate>
      <link>https://dev.to/sir-john/building-recipe-finderorg-a-full-stack-journey-with-vue-express-mongodb-and-vuetify-2k57</link>
      <guid>https://dev.to/sir-john/building-recipe-finderorg-a-full-stack-journey-with-vue-express-mongodb-and-vuetify-2k57</guid>
      <description>&lt;p&gt;Hello, DEV community! 👋 &lt;/p&gt;

&lt;p&gt;Today, I want to share a project I recently launched: &lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;Recipe-Finder.org&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;Like many developers, I often find myself staring into the fridge wondering what to make with the random ingredients I have left. I wanted a fast, clean, and intuitive way to search for recipes, so I decided to build my own solution. &lt;/p&gt;

&lt;p&gt;It was a fantastic opportunity to dive deeper into full-stack development, and I decided to go with a modified MEVN stack. Here is a breakdown of how I built it, the tools I used, and what I learned along the way.&lt;/p&gt;




&lt;h3&gt;
  
  
  🛠️ The Tech Stack
&lt;/h3&gt;

&lt;p&gt;I wanted a stack that allowed for rapid development while keeping the application highly responsive. Here is what powered the project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; &lt;strong&gt;Vue.js&lt;/strong&gt;. I love Vue for its approachable learning curve and how easily it handles reactive components. It made building the dynamic search interfaces a breeze.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI Framework:&lt;/strong&gt; &lt;strong&gt;Vuetify&lt;/strong&gt;. To get that polished, Material Design look without writing hundreds of lines of custom CSS, Vuetify was my go-to. It provided out-of-the-box components like cards for the recipes, navigation drawers, and responsive grids.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; &lt;strong&gt;Express.js (Node.js)&lt;/strong&gt;. I kept the backend lightweight. Express handles the API routing, processing search requests from the Vue frontend and communicating with the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; &lt;strong&gt;MongoDB&lt;/strong&gt;. Recipes are inherently document-like (they have arrays of ingredients, arrays of instructions, etc.). A NoSQL database like MongoDB was a perfect fit, allowing me to store recipe data flexibly without strict relational tables.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  🏗️ Architecture &amp;amp; How It Works
&lt;/h3&gt;

&lt;p&gt;The architecture is a standard decoupled setup. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;The Client:&lt;/strong&gt; The Vue app handles all the state management (using Pinia) and user interactions. When a user types an ingredient or recipe name, Vue triggers an Axios request.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The API:&lt;/strong&gt; The Express server receives this request. It validates the input and constructs a query.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The Data:&lt;/strong&gt; The server queries MongoDB, retrieves the matching recipe documents, and sends them back as a JSON response.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The Render:&lt;/strong&gt; Vue takes that JSON data and seamlessly updates the Vuetify DOM components to display the delicious results.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  🚧 Biggest Challenges &amp;amp; Lessons Learned
&lt;/h3&gt;

&lt;p&gt;No project is complete without a few bumps in the road. Here are a couple of things that tested my patience and what I learned from them:&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Managing Complex Search Queries
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Challenge:&lt;/strong&gt; Users rarely type exact, sanitized ingredient names. Implementing a search that handled both strict array matching (for ingredients) and fuzzy text matching (for recipe titles) was tricky to get right without sacrificing performance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Solution:&lt;/strong&gt; I ended up utilizing MongoDB's text search indexes and the &lt;code&gt;$text&lt;/code&gt; operator. For more nuanced ingredient matching, I built out an aggregation pipeline in Express that scores and sorts results based on how many ingredients match the user's input.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  2. Responsive UI with Vuetify
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Challenge:&lt;/strong&gt; Getting the recipe cards to look consistent was surprisingly tough. Recipe images had different aspect ratios, and title lengths varied wildly, which kept breaking my grid layouts on mobile screens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Solution:&lt;/strong&gt; I leveraged Vuetify's &lt;code&gt;v-img&lt;/code&gt; aspect-ratio props to enforce uniformity and used the CSS &lt;code&gt;line-clamp&lt;/code&gt; property for text truncation. I also fully utilized Vuetify's responsive grid system (&lt;code&gt;cols="12" sm="6" md="4"&lt;/code&gt;) to ensure the layout degrades gracefully based on viewport size.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  🚀 What's Next?
&lt;/h3&gt;

&lt;p&gt;Getting the core functionality of &lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;Recipe-Finder.org&lt;/a&gt; live was step one. In the future, I plan to add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;User Accounts:&lt;/strong&gt; So people can save and favorite their go-to recipes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meal Planning:&lt;/strong&gt; A calendar feature to plan the week's dinners in advance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smart Shopping Lists:&lt;/strong&gt; Automatically compiling missing ingredients from a chosen recipe into an interactive checklist.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Let me know what you think!
&lt;/h3&gt;

&lt;p&gt;Building this was a lot of fun, and seeing it live on the web is incredibly rewarding. I'd love for you to try it out! &lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Check it out here:&lt;/strong&gt; &lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;Recipe-Finder.org&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have any feedback on the UI, the search functionality, or the code structure, please let me know in the comments below. Happy coding! 👨‍💻👩‍💻&lt;/p&gt;

</description>
      <category>vue</category>
      <category>node</category>
      <category>mongodb</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Turn Viral Recipe Videos into Clean Recipe Cards Instantly 🍳</title>
      <dc:creator>Rusu Ionut</dc:creator>
      <pubDate>Mon, 30 Mar 2026 21:07:05 +0000</pubDate>
      <link>https://dev.to/sir-john/turn-viral-recipe-videos-into-clean-recipe-cards-instantly-p1k</link>
      <guid>https://dev.to/sir-john/turn-viral-recipe-videos-into-clean-recipe-cards-instantly-p1k</guid>
      <description>&lt;h2&gt;
  
  
  ⚡ 60-Second Video -&amp;gt; 1-Click Cooking
&lt;/h2&gt;

&lt;p&gt;We turn chaotic viral cooking videos into organized recipe cards and standardized grocery lists.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Find:&lt;/strong&gt; Copy a video link (TikTok, Instagram, YouTube).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paste:&lt;/strong&gt; Drop it into our extractor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cook:&lt;/strong&gt; Get an instant clean card.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Try it for FREE:&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Everybody gets &lt;strong&gt;2 FREE AI imports per month&lt;/strong&gt;. No credit card required.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://recipe-finder.org/social-recipe-extractor" rel="noopener noreferrer"&gt;👉 Extract Your First Recipe Here&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;Go Premium for UNLIMITED Imports:&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;For power users who cook a lot, get seamless, unlimited imports.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://recipe-finder.org/ai-assistant" rel="noopener noreferrer"&gt;👨‍🍳 Go Premium Now&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
      <category>cooking</category>
    </item>
    <item>
      <title>Empty fridge? I built a tool that tells you what to cook 🍳</title>
      <dc:creator>Rusu Ionut</dc:creator>
      <pubDate>Wed, 25 Mar 2026 07:39:12 +0000</pubDate>
      <link>https://dev.to/sir-john/empty-fridge-i-built-a-tool-that-tells-you-what-to-cook-3a70</link>
      <guid>https://dev.to/sir-john/empty-fridge-i-built-a-tool-that-tells-you-what-to-cook-3a70</guid>
      <description>&lt;p&gt;Hey everyone! 👋&lt;/p&gt;

&lt;p&gt;Tired of staring at 3 random ingredients in your fridge and just ordering takeout? Me too. So I built an app to solve it. &lt;/p&gt;

&lt;p&gt;Just type in whatever is sitting in your kitchen, and it instantly generates meals you can actually make. &lt;/p&gt;

&lt;h3&gt;
  
  
  🔗 Try it out here:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;🍽️ &lt;strong&gt;&lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;Find a Recipe Right Now&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🧅 &lt;strong&gt;&lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;Search by the Ingredients You Have&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;💸 &lt;strong&gt;&lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;Stop Wasting Groceries&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;(Dev note: The site is built for lightning-fast searches using Vue 3, Express, and MongoDB).&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  💬 Let's Talk!
&lt;/h3&gt;

&lt;p&gt;Click the links above, give it a spin, and let me know if it finds you something good. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Also: What is your ultimate "clean-out-the-fridge" meal?&lt;/strong&gt; Drop it below! 👇&lt;/p&gt;

&lt;h1&gt;
  
  
  Food #Recipes #Cooking #WebDev #VueJS
&lt;/h1&gt;

</description>
      <category>food</category>
      <category>recipes</category>
      <category>cooking</category>
      <category>lifehacks</category>
    </item>
    <item>
      <title>I built a Recipe Finder using Vue 3, Express.js, and MongoDB 🍳🚀</title>
      <dc:creator>Rusu Ionut</dc:creator>
      <pubDate>Tue, 24 Mar 2026 20:25:05 +0000</pubDate>
      <link>https://dev.to/sir-john/i-built-a-recipe-finder-using-vue-3-expressjs-and-mongodb-2alg</link>
      <guid>https://dev.to/sir-john/i-built-a-recipe-finder-using-vue-3-expressjs-and-mongodb-2alg</guid>
      <description>&lt;p&gt;Hey Dev Community! 👋 &lt;/p&gt;

&lt;p&gt;We’ve all been there: you’re staring at a half-empty fridge wondering if you can actually make a meal out of a bell pepper and some leftover rice. To solve my own "what’s for dinner" fatigue, I built &lt;strong&gt;&lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;recipe-finder.org&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  🛠️ The Tech Stack
&lt;/h3&gt;

&lt;p&gt;I wanted this project to be fast, reactive, and easy to scale. Here’s how I put it together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend: Vue 3&lt;/strong&gt; I leaned heavily into Vue’s reactivity system. The goal was a seamless UI where ingredients could be added or removed without any clunky page refreshes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend: Express.js&lt;/strong&gt; I went with Express to keep the API layer robust yet lightweight. It handles the logic between the user's pantry and the recipe database with minimal overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database: MongoDB&lt;/strong&gt; Since recipe data can be pretty unstructured, MongoDB's flexible document schema made it the perfect choice for efficient querying.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🚀 Give it a spin!
&lt;/h3&gt;

&lt;p&gt;I’m officially launching it today and would love for you to check it out:&lt;br&gt;&lt;br&gt;
👉 &lt;strong&gt;&lt;a href="https://recipe-finder.org" rel="noopener noreferrer"&gt;recipe-finder.org&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  💬 Let's Talk!
&lt;/h3&gt;

&lt;p&gt;I’d love to hear your thoughts on the UI/UX or any features you'd like to see added. Also, I’m curious—&lt;strong&gt;what is your favorite "clean-out-the-fridge" meal?&lt;/strong&gt; 🍲&lt;/p&gt;

&lt;h1&gt;
  
  
  WebDevelopment #VueJS #ExpressJS #MongoDB #FullStack #SoftwareEngineering #ProjectLaunch #RecipeFinder
&lt;/h1&gt;

</description>
      <category>javascript</category>
      <category>food</category>
      <category>recipes</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
