<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Redlio Labs]]></title><description><![CDATA[Notes from Redlio Labs, a software development company in Ahmedabad, India, since 2014. Practical write-ups on custom software, ERP, SaaS, Next.js, technical SE]]></description><link>https://redliolabs.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ae8632658c8411fbc95b7b2/855d7454-0fed-4798-ad26-1a4cc79af910.png</url><title>Redlio Labs</title><link>https://redliolabs.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 12:39:59 GMT</lastBuildDate><atom:link href="https://redliolabs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Stripe webhooks in Next.js: verify, deduplicate and process each event once]]></title><description><![CDATA[Stripe sends webhooks at least once and out of order. Verify signatures, deduplicate by event ID and process each event once in Next.js.]]></description><link>https://redliolabs.hashnode.dev/stripe-webhooks-in-next-js-verify-deduplicate-and-process-each-event-once</link><guid isPermaLink="true">https://redliolabs.hashnode.dev/stripe-webhooks-in-next-js-verify-deduplicate-and-process-each-event-once</guid><category><![CDATA[Next.js]]></category><category><![CDATA[stripe]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Redlio Labs]]></dc:creator><pubDate>Mon, 02 Nov 2026 10:59:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8632658c8411fbc95b7b2/039cfb04-a360-49e0-8a93-5c5c06b90940.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most Stripe webhook handlers work in testing and fail in a way nobody notices for weeks. A customer pays, the handler times out halfway through, Stripe retries, and the account gets two credits. Or a subscription is cancelled and then renewed within a minute, the events arrive in the wrong order, and the account ends up cancelled.</p>
<p>Stripe is clear about the rules: events are delivered at least once, may arrive out of order, and are retried with exponential backoff for up to three days if your endpoint doesn't return a 2xx. A handler has to be correct under all three. Here's a design for a Next.js App Router project that is.</p>
<h2>The shape of it</h2>
<p>Split the work in two:</p>
<ol>
<li><strong>The route handler</strong> verifies the signature, stores the event, and returns 200. It does nothing else.</li>
<li><strong>A worker</strong> processes stored events, once each, and records that it did.</li>
</ol>
<p>The handler stays fast, so Stripe doesn't time out and retry. Processing failures don't cost you the event, because it's already stored.</p>
<h2>Store events with their Stripe ID as the key</h2>
<pre><code class="language-sql">create table stripe_events (
  id            text primary key,          -- evt_...
  type          text        not null,
  created_at    timestamptz not null,      -- from event.created
  payload       jsonb       not null,
  received_at   timestamptz not null default now(),
  processed_at  timestamptz,
  attempts      int         not null default 0,
  last_error    text
);

create index on stripe_events (received_at) where processed_at is null;
</code></pre>
<p>The primary key does the deduplication. A retried delivery hits the same <code>id</code> and is ignored.</p>
<h2>The route handler</h2>
<p>Signature verification needs the raw request body, exactly as Stripe sent it. In a route handler, read it with <code>req.text()</code> and don't parse it as JSON first:</p>
<pre><code class="language-ts">// app/api/stripe/webhook/route.ts
import Stripe from 'stripe';
import { db } from '@/lib/db';
import { enqueue } from '@/lib/queue';

export const runtime = 'nodejs';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');
  if (!signature) return new Response('Missing signature', { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  const inserted = await db.query(
    `insert into stripe_events (id, type, created_at, payload)
     values ($1, $2, to_timestamp($3), $4)
     on conflict (id) do nothing
     returning id`,
    [event.id, event.type, event.created, event],
  );

  if (inserted.rowCount === 1) {
    await enqueue('stripe-event', { id: event.id });
  }

  return new Response(null, { status: 200 });
}
</code></pre>
<p>Two details matter here. A duplicate delivery still gets a 200, because from Stripe's side it succeeded. And if <code>enqueue</code> fails after the insert, the event isn't lost: a sweeper job (below) picks up anything stored but not processed.</p>
<h2>The worker: process once, under a lock</h2>
<pre><code class="language-ts">// lib/stripe/process-event.ts
export async function processStripeEvent(id: string) {
  await db.transaction(async (tx) =&gt; {
    const { rows } = await tx.query(
      `select payload from stripe_events
       where id = $1 and processed_at is null
       for update skip locked`,
      [id],
    );
    if (rows.length === 0) return; // already processed, or another worker has it

    const event = rows[0].payload as Stripe.Event;
    await handle(tx, event);
    await tx.query('update stripe_events set processed_at = now() where id = $1', [id]);
  }).catch(async (err) =&gt; {
    // the transaction rolled back, so record the failure outside it
    await db.query(
      'update stripe_events set attempts = attempts + 1, last_error = $2 where id = $1',
      [id, String(err)],
    );
    throw err;
  });
}
</code></pre>
<p><code>for update skip locked</code> means two workers can't process the same event at the same time, and the business change and <code>processed_at</code> commit together. Either both happen or neither does.</p>
<p>If <code>handle</code> throws, the transaction rolls back and the event stays unprocessed for the sweeper to retry. The failure is recorded in a separate statement after the rollback, so the attempt count isn't lost with it.</p>
<h2>Don't trust the order of events</h2>
<p>For anything with a lifecycle, such as subscriptions, invoices or disputes, the payload in an old event may be stale by the time you process it. Use the event as a signal and read the current state from Stripe:</p>
<pre><code class="language-ts">async function handle(tx: Tx, event: Stripe.Event) {
  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
    case 'customer.subscription.deleted': {
      const { id } = event.data.object as Stripe.Subscription;
      const sub = await stripe.subscriptions.retrieve(id);
      await tx.query(
        `update accounts
           set subscription_status = $1, price_id = $2
         where stripe_customer_id = $3`,
        [sub.status, sub.items.data[0]?.price.id ?? null, sub.customer],
      );
      return;
    }
    case 'checkout.session.completed': {
      // fulfil the order; the unique event id already guarantees this runs once
      return;
    }
    default:
      return; // stored for the record, nothing to do
  }
}
</code></pre>
<p>If a cancellation and a renewal arrive in the wrong order, both handlers read the same current subscription and write the same result. Order stops mattering.</p>
<p>Calling Stripe's API inside a database transaction holds row locks for the length of the request. That's fine at modest volume. If it isn't for you, fetch the object before opening the transaction and pass it in.</p>
<h2>The sweeper</h2>
<p>A scheduled job closes the gaps: events whose enqueue failed, and events whose processing failed and should be retried.</p>
<pre><code class="language-sql">select id from stripe_events
where processed_at is null
  and received_at &lt; now() - interval '5 minutes'
  and attempts &lt; 10
order by received_at
limit 100;
</code></pre>
<p>Events that reach the attempt limit should alert someone rather than retry forever.</p>
<h2>Test retries on purpose</h2>
<p>The Stripe CLI forwards events to your local server and prints a signing secret for that session:</p>
<pre><code class="language-bash">stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completed
</code></pre>
<p>Then resend the same event with <code>stripe events resend &lt;event_id&gt;</code> and check that nothing happens twice. That one test catches most of the bugs this design exists to prevent.</p>
<p>Idempotency on the way in is the other half of the same problem; we covered client-side retries in <a href="https://dev.to/redliolabs/idempotency-keys-stop-charging-customers-twice-when-they-double-click-pay-4odi">idempotency keys for payment requests</a>.</p>
<p>We build billing like this into the products we work on as part of our <a href="https://redliolabs.com/services/saas-development">SaaS development</a> and <a href="https://redliolabs.com/services/custom-software-development">custom software</a> projects.</p>
<p>How do you handle Stripe events today: inline in the handler, or stored and processed later?</p>
]]></content:encoded></item><item><title><![CDATA[Shopify and ERP inventory sync: how to stop double counting and overselling]]></title><description><![CDATA[Why Shopify and ERP stock levels drift, and a sync design that stops double counting: one owner, absolute quantities, pending orders and nightly checks.]]></description><link>https://redliolabs.hashnode.dev/shopify-and-erp-inventory-sync-how-to-stop-double-counting-and-overselling</link><guid isPermaLink="true">https://redliolabs.hashnode.dev/shopify-and-erp-inventory-sync-how-to-stop-double-counting-and-overselling</guid><category><![CDATA[shopify]]></category><category><![CDATA[Shopify Development]]></category><category><![CDATA[ERP]]></category><category><![CDATA[ecommerce]]></category><dc:creator><![CDATA[Redlio Labs]]></dc:creator><pubDate>Mon, 02 Nov 2026 10:54:31 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8632658c8411fbc95b7b2/ea58153a-92db-4450-87d1-92b0a5758f15.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The symptom is always the same. A product shows 12 in stock on the store, 9 in the ERP, and 4 on the warehouse shelf. Someone fixes it by hand on Friday, and by Tuesday it's wrong again.</p>
<p>The cause is almost never a bug in one system. It's a sync design where both Shopify and the ERP believe they own the stock number, and changes flow in both directions as adjustments. Each side applies the other's change on top of its own, and every order gets counted twice.</p>
<p>Here's how that happens, and a design that stops it.</p>
<h2>How double counting happens</h2>
<p>Take one unit sold online:</p>
<ol>
<li>The order is placed. Shopify reduces its available quantity by 1.</li>
<li>The order is imported into the ERP as a sales order. The ERP allocates 1 unit, so its available quantity drops by 1.</li>
<li>The sync sees that Shopify's available quantity changed, and sends "-1" to the ERP as an adjustment.</li>
</ol>
<p>The ERP has now deducted the same unit twice. Run the same pattern the other way, with ERP changes sent to Shopify as adjustments, and the store drifts too. The fix isn't a smarter adjustment. It's to stop sending adjustments.</p>
<h2>Rule 1: one system owns the number</h2>
<p>Pick one system of record for each stock figure. For most businesses with an ERP, that's the ERP: it sees purchase receipts, transfers, returns, B2B orders and other sales channels, and Shopify sees only its own orders.</p>
<p>Then the data flows in exactly two directions, and they carry different things:</p>
<ul>
<li><strong>Orders flow from Shopify to the ERP.</strong> The ERP allocates stock against them like any other sales order.</li>
<li><strong>Available quantities flow from the ERP to Shopify.</strong> As absolute values, never as adjustments.</li>
</ul>
<p>Nothing about stock levels flows from Shopify back to the ERP. Shopify's own decrement on checkout is a short-term reservation that the next sync replaces.</p>
<h2>Rule 2: send "set to", never "change by"</h2>
<p>An absolute value is idempotent. If the same update is sent twice, or arrives late, the result is still correct. An adjustment applied twice is a wrong number.</p>
<p>Shopify's GraphQL Admin API has a mutation for exactly this, <code>inventorySetQuantities</code>, which sets a named quantity at a location:</p>
<pre><code class="language-graphql">mutation SetAvailable($input: InventorySetQuantitiesInput!) {
  inventorySetQuantities(input: $input) {
    inventoryAdjustmentGroup { reason changes { name delta } }
    userErrors { field message code }
  }
}
</code></pre>
<pre><code class="language-json">{
  "input": {
    "name": "available",
    "reason": "correction",
    "referenceDocumentUri": "erp://inventory-sync/2026-11-02T10:15:00Z",
    "quantities": [
      {
        "inventoryItemId": "gid://shopify/InventoryItem/123",
        "locationId": "gid://shopify/Location/456",
        "quantity": 42,
        "compareQuantity": 40
      }
    ]
  }
}
</code></pre>
<p><code>compareQuantity</code> makes the write conditional: Shopify rejects it if the current value isn't what you expected, so a sale that happened between your read and your write isn't silently overwritten. When that happens, re-read and recompute rather than retrying blindly. Field names around compare-and-set have changed between API versions, so check the reference for the version your app is pinned to. <code>referenceDocumentUri</code> ties each change to your sync run, which makes Shopify's inventory history readable later.</p>
<h2>Rule 3: account for orders the ERP hasn't seen yet</h2>
<p>This is the gap that causes overselling even with a clean one-way design. If the ERP computes availability at 10:15 and an online order placed at 10:14 hasn't been imported yet, the ERP still counts that unit as free. Push its number to Shopify and you've handed the sold unit back to the store.</p>
<p>The fix is to subtract orders that are in Shopify but not yet in the ERP before you push:</p>
<pre><code class="language-ts">function availableToSell(sku: string, location: string): number {
  const erp = erpAvailable(sku, location);               // on hand minus ERP allocations
  const pending = ordersNotYetInErp(sku, location);       // placed in Shopify, not imported
  const buffer = safetyStock(sku, location);             // per-SKU cushion
  return Math.max(0, erp - pending - buffer);
}
</code></pre>
<p>Track "not yet imported" with an import watermark: the order sync records which Shopify orders it has written to the ERP, and anything newer is pending.</p>
<h2>Map locations explicitly</h2>
<p>Shopify holds inventory per location, and so does the ERP, but they rarely line up one to one. Keep an explicit mapping table, and decide what happens to warehouses that don't sell online: excluded, or pooled into one Shopify location. A product that exists in the ERP warehouse but has no mapped Shopify location is the most common reason a store shows zero for something that's on the shelf.</p>
<h2>Handle webhooks as hints, not facts</h2>
<p>Shopify webhooks such as <code>orders/create</code> and <code>refunds/create</code> are the fastest way to learn about changes, but they can arrive more than once and out of order. Treat them as a trigger to fetch current state, and deduplicate on the <code>X-Shopify-Webhook-Id</code> header before doing any work. Run a scheduled poll as well, so a missed webhook delays an update instead of losing it.</p>
<h2>Reconcile every night</h2>
<p>Even a good design drifts: manual edits in Shopify admin, a failed job, a location mapped wrong. A nightly job that compares the ERP's available-to-sell figure with Shopify's available quantity for every SKU and location, and reports the differences, turns silent drift into a short list someone can review each morning. Fix the cause of each difference rather than just overwriting it, or the same SKUs will appear again tomorrow.</p>
<h2>The short version</h2>
<ul>
<li>One owner per number, usually the ERP.</li>
<li>Orders go to the ERP; absolute quantities come back.</li>
<li>Subtract orders the ERP hasn't imported yet.</li>
<li>Use compare-and-set, deduplicate webhooks, and reconcile nightly.</li>
</ul>
<p>We build these integrations as part of our <a href="https://redliolabs.com/services/shopify-development">Shopify development</a> and <a href="https://redliolabs.com/services/erp-software-development">ERP development</a> work, usually starting with the reconciliation report, because it shows where the numbers are drifting today.</p>
<p>Which system owns your stock number today, and does everyone on the team give the same answer?</p>
]]></content:encoded></item><item><title><![CDATA[Audit logs for B2B SaaS: what enterprise security reviews ask, and a Postgres schema that answers it]]></title><description><![CDATA[What enterprise security reviews ask about audit logs, which events to record, and an append-only Postgres schema with retention and tamper evidence.]]></description><link>https://redliolabs.hashnode.dev/audit-logs-for-b2b-saas-what-enterprise-security-reviews-ask-and-a-postgres-schema-that-answers-it</link><guid isPermaLink="true">https://redliolabs.hashnode.dev/audit-logs-for-b2b-saas-what-enterprise-security-reviews-ask-and-a-postgres-schema-that-answers-it</guid><category><![CDATA[SaaS]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Security]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[Redlio Labs]]></dc:creator><pubDate>Mon, 02 Nov 2026 10:50:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8632658c8411fbc95b7b2/ce01a7d1-27bc-43a1-bc83-9bac9ca3b001.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The first enterprise deal usually arrives with a security questionnaire, and somewhere in it is a version of the same question: "Do you keep an audit log, and can our admins see it?" If the honest answer is "we have application logs in our hosting dashboard", the deal slows down.</p>
<p>Application logs and audit logs do different jobs. Application logs help your engineers debug. An audit log is a record your customer can rely on: who did what, to which thing, when, and from where. It has to be complete, hard to alter, kept for a known period, and visible to the customer's own admins.</p>
<p>Here's what buyers ask for, which events are worth recording, and a Postgres design that covers both.</p>
<h2>What security reviews actually ask</h2>
<p>The wording varies between questionnaires, but the questions cluster around six things:</p>
<ol>
<li><strong>Coverage.</strong> Are logins, permission changes, data exports and configuration changes recorded?</li>
<li><strong>Attribution.</strong> Can you tell a human user from an API key, an automated job, or your own support staff?</li>
<li><strong>Integrity.</strong> Can anyone, including your engineers, edit or delete entries?</li>
<li><strong>Retention.</strong> How long are entries kept, and can that be extended by contract?</li>
<li><strong>Access.</strong> Can the customer's admins search and export their own log without raising a ticket?</li>
<li><strong>Export.</strong> Can it be streamed into their SIEM?</li>
</ol>
<p>SOC 2 and ISO 27001 both expect logging and monitoring controls, so an auditor will ask the same things in a different form. Design for these six and you'll answer both.</p>
<h2>Which events to record</h2>
<p>Record actions that change access, configuration or data ownership. Don't record every page view; that's analytics, and it buries the events that matter.</p>
<ul>
<li><strong>Authentication:</strong> sign-in, failed sign-in, sign-out, password reset, MFA enabled or removed, SSO settings changed.</li>
<li><strong>Access:</strong> user invited, removed, role changed, API key created or revoked.</li>
<li><strong>Data:</strong> export or bulk download, bulk delete, records shared outside the workspace.</li>
<li><strong>Configuration:</strong> billing, integrations, webhooks, retention and security settings.</li>
<li><strong>Support access:</strong> any time your own staff view or act inside a customer's workspace.</li>
</ul>
<p>The last one is the event buyers ask about most, and the one teams most often forget to log.</p>
<h2>The table</h2>
<pre><code class="language-sql">create table audit_events (
  id           bigint generated always as identity,
  tenant_id    uuid        not null,
  occurred_at  timestamptz not null default now(),
  actor_type   text        not null check (actor_type in ('user', 'api_key', 'system', 'support')),
  actor_id     text        not null,
  actor_label  text,                  -- email or key name at the time of the event
  action       text        not null,  -- e.g. 'member.role_changed'
  target_type  text,
  target_id    text,
  ip           inet,
  user_agent   text,
  request_id   text,
  changes      jsonb,                 -- {"role": ["member", "admin"]}
  metadata     jsonb       not null default '{}',
  primary key (tenant_id, occurred_at, id)
) partition by range (occurred_at);

create index on audit_events (tenant_id, action, occurred_at desc);
</code></pre>
<p>A few choices worth explaining:</p>
<ul>
<li><strong><code>actor_label</code> is copied in, not joined.</strong> If a user changes their email or is deleted, the log should still show who acted at the time.</li>
<li><strong><code>action</code> uses a fixed vocabulary</strong> like <code>member.role_changed</code> rather than free text, so customers can filter on it and you can document it.</li>
<li><strong><code>changes</code> stores before and after values</strong> for the fields that changed, not the whole record. For sensitive fields, store that the field changed and leave the value out.</li>
<li><strong>Partitioning by month</strong> makes retention cheap. On a partitioned table the primary key has to include the partition column, which is why <code>occurred_at</code> is in it.</li>
</ul>
<p>Create a partition per month (pg_partman can do this for you), and retention becomes dropping a partition instead of a slow <code>delete</code> across millions of rows:</p>
<pre><code class="language-sql">create table audit_events_2026_11 partition of audit_events
  for values from ('2026-11-01') to ('2026-12-01');
</code></pre>
<h2>Make it append-only</h2>
<p>The application should be able to add entries and read them, and nothing else:</p>
<pre><code class="language-sql">revoke update, delete, truncate on audit_events from app_user;
grant insert, select on audit_events to app_user;
</code></pre>
<p>Run partition creation and retention from a separate role that the application never uses. That's the answer to "can your engineers edit the log?": not through the application, and the role that can drop old partitions is only used by a scheduled job.</p>
<h2>Write the event in the same transaction</h2>
<p>If the change commits and the audit insert fails, you have an action with no record. Write both in one transaction:</p>
<pre><code class="language-ts">await db.transaction(async (tx) =&gt; {
  const before = await tx.query(
    'select role from memberships where id = $1 and tenant_id = $2 for update',
    [membershipId, tenantId],
  );
  await tx.query(
    'update memberships set role = $1 where id = $2 and tenant_id = $3',
    [newRole, membershipId, tenantId],
  );
  await tx.query(
    `insert into audit_events
       (tenant_id, actor_type, actor_id, actor_label, action, target_type, target_id, ip, request_id, changes)
     values ($1, 'user', $2, $3, 'member.role_changed', 'membership', $4, $5, $6, $7)`,
    [tenantId, user.id, user.email, membershipId, req.ip, req.id,
     { role: [before.rows[0].role, newRole] }],
  );
});
</code></pre>
<p>Put this behind a small <code>audit()</code> helper so every write path calls it the same way, and add a test per action that fails if the event isn't written.</p>
<h2>Tamper evidence</h2>
<p>Permissions stop the application from editing the log. They don't prove to an auditor that nobody with database access did. A simple way to make tampering detectable: once a day, a job computes a SHA-256 digest over each tenant's events for that day, in id order, and writes the digests to object storage with a retention lock (S3 Object Lock in compliance mode, for example). To verify a day later, recompute the digest and compare. If a row was changed or removed, the digests won't match.</p>
<p>This is cheaper and simpler than hash-chaining every row at write time, and it's enough to answer the integrity question honestly.</p>
<h2>Give customers their own view</h2>
<p>The feature that ends most audit-log questions on a sales call is a screen in the product: a filterable list for workspace admins, by user, action and date, with CSV export. For larger customers, add a stream to their SIEM, either as a webhook per event or as daily files dropped into a bucket they own.</p>
<p>Once that screen exists, the questionnaire answer changes from a paragraph of explanation to "yes, here's a screenshot".</p>
<p>We build this into multi-tenant products as part of our <a href="https://redliolabs.com/services/saas-development">SaaS development</a> work, usually before the first enterprise customer asks for it.</p>
<p>What's the audit-log question that slowed down a deal for you?</p>
]]></content:encoded></item><item><title><![CDATA[llms.txt in Next.js: generate it from your sitemap so it never drifts]]></title><description><![CDATA[Build llms.txt in Next.js from the same page list as sitemap.xml, check its links in CI, and see which AI crawlers actually fetch it.]]></description><link>https://redliolabs.hashnode.dev/llms-txt-in-next-js-generate-it-from-your-sitemap-so-it-never-drifts</link><guid isPermaLink="true">https://redliolabs.hashnode.dev/llms-txt-in-next-js-generate-it-from-your-sitemap-so-it-never-drifts</guid><category><![CDATA[Next.js]]></category><category><![CDATA[SEO]]></category><category><![CDATA[generative ai]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Redlio Labs]]></dc:creator><pubDate>Mon, 02 Nov 2026 10:46:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8632658c8411fbc95b7b2/41b585ed-2198-447b-9dae-e02e3dead4b8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most llms.txt files we've seen were written by hand once and never touched again. Six months later they link to pages that redirect, miss the pages that matter most, and describe services the company no longer sells. A file meant to help language models understand your site ends up misleading them.</p>
<p>We had the same problem on our own site, so we stopped writing it by hand. Our llms.txt is generated from the same list of pages that builds sitemap.xml, so the two can't disagree. Here's the setup for a Next.js App Router site.</p>
<h2>What llms.txt is, and what it isn't</h2>
<p>llms.txt is a proposal, published by Jeremy Howard of Answer.AI in September 2024, for a Markdown file at the root of a site that gives language models a short, curated map of it. The format is simple:</p>
<ul>
<li>an H1 with the site or project name</li>
<li>a blockquote with a one-paragraph summary</li>
<li>optional plain paragraphs with extra context</li>
<li>H2 sections, each a list of links in the form <code>- [Title](url): note</code></li>
<li>an optional section titled <code>## Optional</code> for links a model can skip when its context is short</li>
</ul>
<p>It isn't a standard, and none of the major AI search products has said it reads the file when ranking or citing pages. Treat it as cheap insurance: it takes an afternoon, it doesn't hurt, and if you log requests for it you'll see which crawlers actually fetch it. Don't treat it as a replacement for crawlable pages, a sitemap, or a robots.txt that lets AI crawlers in.</p>
<h2>One page list, two outputs</h2>
<p>The drift problem goes away when both files read from one source. Keep your public pages in a typed module:</p>
<pre><code class="language-ts">// lib/site-pages.ts
export type SitePage = {
  path: string;
  title: string;
  summary: string;      // one line, written for a reader, not for keywords
  section: 'Services' | 'Case studies' | 'Insights' | 'Company';
  llms?: boolean;       // false = keep in sitemap, leave out of llms.txt
  optional?: boolean;   // true = list under "## Optional"
  lastModified?: string;
};

export const SITE_URL = 'https://example.com';

export const pages: SitePage[] = [
  { path: '/', title: 'Home', summary: 'Who we are and what we build.', section: 'Company' },
  { path: '/services/saas-development', title: 'SaaS development', summary: 'Multi-tenant SaaS products, from MVP to scale.', section: 'Services' },
  { path: '/privacy-policy', title: 'Privacy policy', summary: 'How we handle personal data.', section: 'Company', optional: true },
  // ...
];
</code></pre>
<p>If your pages come from a CMS or MDX files, build this array from that source instead. The point is that there's one array.</p>
<p>The sitemap reads it:</p>
<pre><code class="language-ts">// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { pages, SITE_URL } from '@/lib/site-pages';

export default function sitemap(): MetadataRoute.Sitemap {
  return pages.map((p) =&gt; ({
    url: `${SITE_URL}${p.path}`,
    lastModified: p.lastModified,
  }));
}
</code></pre>
<h2>The llms.txt route</h2>
<p>A route handler at <code>app/llms.txt/route.ts</code> serves <code>/llms.txt</code>. With <code>dynamic = 'force-static'</code> it's rendered once at build time, like the sitemap.</p>
<pre><code class="language-ts">// app/llms.txt/route.ts
import { pages, SITE_URL, type SitePage } from '@/lib/site-pages';

export const dynamic = 'force-static';

const ORDER: SitePage['section'][] = ['Services', 'Case studies', 'Insights', 'Company'];

const line = (p: SitePage) =&gt; `- [${p.title}](${SITE_URL}${p.path}): ${p.summary}`;

export function GET() {
  const listed = pages.filter((p) =&gt; p.llms !== false);
  const core = listed.filter((p) =&gt; !p.optional);
  const optional = listed.filter((p) =&gt; p.optional);

  const out: string[] = [
    '# Example Co',
    '',
    '&gt; Software development company building custom software, SaaS and mobile apps for mid-size companies.',
    '',
  ];

  for (const section of ORDER) {
    const items = core.filter((p) =&gt; p.section === section);
    if (items.length === 0) continue;
    out.push(`## ${section}`, '', ...items.map(line), '');
  }

  if (optional.length &gt; 0) {
    out.push('## Optional', '', ...optional.map(line), '');
  }

  return new Response(out.join('\n'), {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  });
}
</code></pre>
<p>Point to it from robots.txt with a comment so people can find it too. Crawlers ignore comments, so this is a note, not a directive:</p>
<pre><code># Plain-language site summary for language models (llmstxt.org)
# LLM-Content: https://example.com/llms.txt
</code></pre>
<h2>Check it in CI</h2>
<p>Generating the file stops it drifting from the sitemap. It doesn't stop either one linking to a page that now redirects or returns a 404. A small check against the preview deploy catches that:</p>
<pre><code class="language-js">// scripts/check-llms.mjs
const base = process.env.PREVIEW_URL;
const txt = await (await fetch(`${base}/llms.txt`)).text();
const urls = [...txt.matchAll(/\]\((https?:\/\/[^)]+)\)/g)].map((m) =&gt; m[1]);

let failed = 0;
for (const url of urls) {
  const res = await fetch(url.replace(/^https?:\/\/[^/]+/, base), { redirect: 'manual' });
  if (res.status !== 200) {
    console.error(`${res.status}  ${url}`);
    failed++;
  }
}
console.log(`${urls.length - failed}/${urls.length} links return 200`);
process.exit(failed ? 1 : 0);
</code></pre>
<p><code>redirect: 'manual'</code> matters: a 301 is a broken entry in a file whose whole job is to point at the right page.</p>
<h2>Writing the summaries</h2>
<p>The generator handles structure. The one-line summaries are still writing, and they're what a model reads. A few rules that have worked for us:</p>
<ul>
<li>Say what the page is for, not what it ranks for. "Multi-tenant SaaS products, from MVP to scale" beats a string of keywords.</li>
<li>Keep it to one line per page. If a page needs a paragraph to describe, it's probably two pages.</li>
<li>Put pages you'd be happy to be quoted from in the main sections, and legal or utility pages under Optional.</li>
</ul>
<h2>Is anyone reading it?</h2>
<p>You won't know unless you look. Filter your access logs or edge logs for <code>/llms.txt</code> and group by user agent. If GPTBot, ClaudeBot or PerplexityBot never request it, that's worth knowing before you spend more time on it. Either way, the pages it links to still need to be crawlable and worth citing on their own.</p>
<p>We build this into the sites we work on as part of <a href="https://redliolabs.com/services/generative-engine-optimization">generative engine optimization</a>: making sure AI search tools can find, read and quote the pages that matter.</p>
<p>Have you seen a crawler actually fetch your llms.txt? We'd like to compare logs.</p>
]]></content:encoded></item><item><title><![CDATA[How to choose a software development company, as one client did after fifteen interviews]]></title><description><![CDATA[Three tests one client used after 15 vendor interviews: how they work on your problem, what the quote assumes, and who does the work.]]></description><link>https://redliolabs.hashnode.dev/how-to-choose-a-software-development-company-as-one-client-did-after-fifteen-interviews</link><guid isPermaLink="true">https://redliolabs.hashnode.dev/how-to-choose-a-software-development-company-as-one-client-did-after-fifteen-interviews</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Development Company in India]]></category><category><![CDATA[outsourcing]]></category><category><![CDATA[Startups]]></category><dc:creator><![CDATA[Redlio Labs]]></dc:creator><pubDate>Mon, 02 Nov 2026 10:30:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8632658c8411fbc95b7b2/837fbb3b-131d-44bf-8be3-612b2edc0b20.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>One client interviewed fifteen software vendors before choosing one. Every portfolio looked strong, so the portfolios didn't decide it. What decided it was how each vendor worked on the client's actual problem.</p>
<p>That's the short version of our full guide. Here are the parts worth knowing before your first vendor call.</p>
<h2>Decide what you are buying first</h2>
<p>A fixed-scope project, a dedicated team and staff augmentation are different purchases. Each one needs a different kind of supplier, so the shortlist goes wrong when this isn't settled up front.</p>
<p><img src="https://redliolabs.com/images/insights/redlio-labs-software-development-engagement-models.jpg" alt="Three engagements and what each buys: a project buys an agreed output, a dedicated team buys an owned workstream, and augmentation buys capacity in your team" /></p>
<h2>Three tests that matter more than a portfolio</h2>
<ol>
<li><strong>How they work on your problem.</strong> Give them a slice of your real requirement and watch how they question it.</li>
<li><strong>What the quote assumes.</strong> Two similar prices can cover very different scope. Ask every vendor to write their assumptions down.</li>
<li><strong>Who does the work.</strong> Meet the people who will actually build it, not only the sales team.</li>
</ol>
<p><img src="https://redliolabs.com/images/insights/redlio-labs-choose-software-company-vendor-tests.jpg" alt="Three tests, your problem, the quote’s assumptions and the delivery team, joined into one step: test with real work" /></p>
<h2>Two more things to get right</h2>
<ul>
<li><strong>Read reviews for the criticism.</strong> What a past client would have liked done differently tells you what working with the vendor is like.</li>
<li><strong>Own your code from day one.</strong> The repository, hosting and store accounts should be in your organization's name.</li>
</ul>
<p>The full guide covers where the vendor is based and what that changes, eight questions to put to every vendor in writing, and why the cheapest quote is often the one that underestimated the work.</p>
<p><strong><a href="https://redliolabs.com/insights/how-to-choose-a-software-development-company">Read the full guide on the Redlio Labs blog</a></strong></p>
]]></content:encoded></item></channel></rss>