<?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[Build with Matija]]></title>
        <description><![CDATA[Insights, tutorials, and expert guides to help you grow your business and master your craft.]]></description>
        <link>https://www.buildwithmatija.com</link>
        <generator>RSS for Node</generator>
        <lastBuildDate>Sun, 09 Aug 2026 16:52:22 GMT</lastBuildDate>
        <atom:link href="https://www.buildwithmatija.com/feed.xml" rel="self" type="application/rss+xml"/>
        <pubDate>Sun, 09 Aug 2026 16:52:22 GMT</pubDate>
        <copyright><![CDATA[© 2026 Build with Matija]]></copyright>
        <language><![CDATA[en]]></language>
        <item>
            <title><![CDATA[Enterprise Software for Small Teams: Proven Two-Person Model]]></title>
            <description><![CDATA[<p>Partway through a recent enterprise opportunity, someone on the client side asked the question that always arrives eventually. Can a team this small safely design, deliver and support a platform that becomes important to our business?</p>
<p>It is a fair question, and it gets sharper when the project touches regulated data, external partners, customer accounts, contractual availability or eventual integration with internal enterprise systems. We are two people. There is no sales department, no layer of account management, no bench of junior developers to absorb a departure, no internal infrastructure team.</p>
<p>The tempting answer is to inflate. Talk about &quot;the team&quot;, describe capabilities in the passive voice, let the buyer imagine an organisation that does not exist. I have watched that approach work in procurement and then fall apart in delivery.</p>
<p>The answer we give instead is a division of responsibility. We stay directly accountable for product direction, user experience, application architecture, implementation, data and integration design, and the quality of what ships. For infrastructure, platform security, availability, framework-level support and independent security validation, we work with specialist vendors and, where it matters, with the companies that build and operate the underlying technology.</p>
<p>A small senior team delivers the product. Enterprise technology vendors support the platform. The client retains control.</p>
<p>This article is not an argument that two people are better than fifty. It is an explanation of how a small senior team can structure responsibility in a way that survives serious enterprise scrutiny.</p>
<h2>The concern is legitimate</h2>
<p>Enterprise software is not evaluated by asking whether the supplier can write the code. A buyer has to think about capacity, about what happens if a key person becomes unavailable, about who operates the infrastructure and who answers the phone during a platform incident. They need to know how security updates are handled, whether availability commitments can be met, whether an external specialist can review the implementation, whether the client can take control if the relationship ends, and whether the platform can keep evolving for years.</p>
<p>Those are operational questions, and a polished demonstration does not answer them. A demo proves a team understands the product and can build it. It does not eliminate long-term supplier risk.</p>
<p>I have written separately about <a href="/blog/procurement-fit-avoid-bad-projects-guide">procurement fit and why a strong product match does not always produce the right engagement</a>, because alignment has to hold across the product, the delivery model and the buying process at the same time.</p>
<h2>Headcount is an imperfect proxy</h2>
<p>Companies use team size as a shortcut for operational stability, and the logic is understandable. Fifty employees look like more replacement capacity than two.</p>
<p>But headcount does not tell you who will actually work on your project, and it does not tell you who operates the systems underneath it. A larger agency may present its senior sales and strategy people during procurement and assign the work to a smaller implementation group once the contract is signed. That agency still depends on a public cloud provider, an identity provider, a managed database, third-party monitoring, an external penetration-testing company and a stack of open-source frameworks maintained by other organisations entirely.</p>
<p>Almost every modern software company runs on a network of vendors. So the useful question is not how many people the implementation company employs. It is who is responsible for each critical layer of the system, and what assurance exists around that responsibility.</p>
<p>That is the question we answer explicitly.</p>
<h2>What we take responsibility for</h2>
<p>We own the application and the product, which is the part you cannot buy by purchasing an Enterprise platform.</p>
<p>On product and experience, we work directly with stakeholders to define who the platform serves, which journeys matter, what belongs in the first release, how different audiences move through the system and where it expands later. There is no handoff from a sales consultant to a separate product team. The people in the room are the people designing and building it.</p>
<p>On architecture, we define the frontend and backend boundaries, the content and operational data models, system ownership, APIs and integrations, authentication and authorization, tenant and partner isolation, caching, analytics, deployment structure and the migration path forward. Where the project includes a CMS, we also decide what that CMS should not control. A flexible CMS can store almost anything, which is exactly why it quietly becomes the source of truth for business data it was never meant to own. I go deeper on that in <a href="/blog/cms-architecture-define-what-cms-must-not-own">CMS Architecture: Define What Your CMS Must Not Own</a>.</p>
<p>On security, the line is worth stating plainly, because it is the one buyers most often blur: a managed hosting platform does not secure badly designed application code. The infrastructure provider secures the platform it operates. We secure the application running on it, which means authorization rules, role design, tenant isolation, input validation, API permissions, session handling, secrets, dependency updates, audit trails and the remediation of anything a security review finds in our implementation.</p>
<p>And we stay responsible for making the system understandable to someone who is not us. Architecture and data-flow documentation, deployment instructions, environment definitions, integration contracts, decision records, known limitations and open risks. Documentation is not a deliverable bolted on at the end of the project. For a small team it is one of the main mechanisms that reduces the client&#39;s dependency on us, which makes it a commercial artifact as much as a technical one.</p>
<h2>What we deliberately do not rebuild</h2>
<p>Some enterprise capabilities are expensive and difficult because they should be. Operating secure, resilient infrastructure takes continuous investment in networking, platform engineering, observability, incident response and security operations.</p>
<p>We have no interest in reproducing a global edge network, network-level DDoS protection, a proprietary web application firewall, multi-region orchestration, an internal 24-hour operations department, our own identity provider or a platform-wide observability product inside a project implementation budget. Those things already exist as managed Enterprise services, operated by organisations whose entire business is providing them.</p>
<p>This is not blind outsourcing. We still have to select, configure and integrate those services correctly, and getting that wrong is its own failure mode. The distinction is that the platform underneath is run by people who do nothing else.</p>
<h2>The responsibility map</h2>
<p>For a typical enterprise web platform, the model looks like this.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Responsible party</th>
<th>Scope</th>
</tr>
</thead>
<tbody><tr>
<td>Product and application</td>
<td>Our studio</td>
<td>Product direction, UX and UI, solution architecture, Next.js development, CMS and backend implementation, integrations, application security, analytics, testing, documentation</td>
</tr>
<tr>
<td>Managed infrastructure</td>
<td>A provider such as Vercel</td>
<td>Hosting, deployment infrastructure, edge delivery, scaling, platform observability, WAF, DDoS mitigation, multi-region options, contractual availability</td>
</tr>
<tr>
<td>Application platform</td>
<td>The framework vendor, for example Payload Enterprise</td>
<td>Product support, escalation of framework-level issues, implementation consultation, architecture reviews, upgrade guidance, roadmap visibility</td>
</tr>
<tr>
<td>Independent security</td>
<td>A specialist security firm</td>
<td>Architecture review, penetration testing, authenticated and black-box testing, configuration review, remediation verification</td>
</tr>
<tr>
<td>Governance</td>
<td>The client&#39;s internal teams</td>
<td>Security governance, vendor approval, data classification, compliance requirements, production approval, risk acceptance</td>
</tr>
</tbody></table>
<p>The exact services depend on the package selected and the client&#39;s requirements, but the shape holds. We remain responsible for the implementation; the vendor provides an escalation path to the people who maintain the framework itself. The security firm is deliberately not us, which creates separation between the company building the application and the company validating it. And the client never loses control simply because the work is delivered externally.</p>
<h2>Why naming parties beats promising everything</h2>
<p>There is a version of this conversation where every procurement requirement gets the same answer. WAF? Yes. Multi-region failover? Yes. Security monitoring, framework support, penetration testing, 24-hour incident response, long-term maintenance? Yes, yes, yes.</p>
<p>From a two-person company, that answer becomes difficult to believe somewhere around the third yes.</p>
<p>The stronger answer identifies the responsible party for each requirement. The implementation team owns authorization. The cloud platform owns the underlying infrastructure. The WAF provider operates perimeter protection. The framework vendor handles product-level escalation. An independent firm conducts the penetration test. The client&#39;s security team defines acceptance.</p>
<p>That replaces a broad promise with named responsibilities, contracts and escalation paths, which means the buyer can inspect the model rather than trust it. In enterprise delivery that is usually worth more than claiming one supplier internally owns every competency.</p>
<h2>Buy the assurance you actually need</h2>
<p>Enterprise support packages are expensive. A client may eventually need advanced platform support, a contractual 99.99% availability SLA, managed security rulesets, multi-region failover, private networking, extended log retention, directory synchronization, SIEM integration and formal architecture reviews.</p>
<p>That does not mean all of it has to be bought before the first user reaches the platform.</p>
<p>A secure MVP can start with approved EU hosting, HTTPS, managed backups, secure secret storage, role-based access, strong tenant isolation, application logging, dependency maintenance, baseline WAF and DDoS protection, monitoring, documented recovery procedures and an independent penetration test before production. The heavier Enterprise layers get activated when internal security policy, real traffic, a contractual commitment, a sensitive integration or a regulator requires them.</p>
<p>The principle is to design for the higher standard from the beginning but not to purchase every higher-cost capability before it is required. That avoids both failure modes: launching cheaply onto an architecture that has to be thrown away, and loading an unvalidated product with substantial annual vendor costs.</p>
<p>When those vendor costs do arrive, they should be visible. A proposal should separate architecture and discovery, product design and implementation, third-party and Enterprise platform costs, independent security services, and ongoing support. Keeping them separate shows the client what it is buying, stops us absorbing unpredictable vendor pricing, allows the client to contract directly with major vendors where that makes sense, and makes later upgrades easy to evaluate. A client asking for high-assurance infrastructure should expect the platform cost that comes with it. That number is not evidence of inefficient development. It is the price of the service level being requested.</p>
<h2>Start with architecture, then scope milestones independently</h2>
<p>None of this works until the requirements are actually understood. Before recommending an expensive Enterprise package, we need to know what data the platform will process, whether it must stay in the EU, which systems it integrates with, whether SSO is required, what availability is contractual, how long logs must be retained and whether they flow into an internal SIEM, who owns the hosting accounts, which vendors are already approved, who performs the penetration test, and whether the first release handles public information or sensitive applications.</p>
<p>That is why we recommend an architecture and discovery sprint before full implementation. It closes the decisions needed to build the first release safely and separates confirmed requirements from assumptions and from things that belong to a later phase. For complex projects this is far more reliable than treating the first mockups as a production specification, a point I make more fully in <a href="/blog/architecture-first-website-rebuilds">Website Rebuilds: Start with Architecture, Not Mockups</a>. The sprint should end with confirmed V1 scope, an architecture recommendation, system boundaries, vendor decisions, security requirements, data ownership, an integration strategy, a roadmap, named risks, acceptance criteria and implementation pricing that means something.</p>
<p>After that, we work in independently scoped milestones rather than an open-ended multi-year commitment, because a small team promising unlimited parallel capacity is exactly the kind of claim that collapses later. A typical shape runs from architecture and discovery, to the core platform foundation of authentication, data models, environments and deployment, to the first complete customer journey, then partner and internal operations, then integrations, security testing and production hardening.</p>
<p>Each milestone defines its deliverables, responsibilities, timeline, price, acceptance criteria, dependencies, handover state and the decisions we need from the client. That gives the client budget control, lets the architecture absorb real information as it arrives, and keeps the committed scope proportionate to the capacity of the people actually doing the work.</p>
<h2>Prove it with working software</h2>
<p>A small team cannot win on a capability deck. The strongest evidence available to us is a working vertical slice.</p>
<p>For a recent enterprise opportunity we demonstrated considerably more than frontend screens. The concept included the customer-facing journey, an AI-guided experience, the workflow operating behind that AI, structured application data, analytics and session replay, partner administration, internal CMS administration, and the architecture and security documentation underneath. The point was not to pretend the production system existed. It was to show that the visible experience connected to a credible implementation model.</p>
<p>I go further into that idea in <a href="/blog/a-software-demo-should-show-the-system-not-just-the-screen">A Software Demo Should Show the System, Not Just the Screen</a>.</p>
<p>This matters more for us than for a large supplier. An agency can point at headcount, departments and twenty years of corporate history. A specialist team has to make its capability visible in the work itself, and the people presenting the system have to be the people who understand how it works.</p>
<h2>Client control matters more when the supplier is small</h2>
<p>A client should never be trapped by an implementation partner. That is true of any supplier and it deserves particular attention with a two-person one.</p>
<p>So we structure projects so the client holds access to the source repository, ownership or administrative access to infrastructure accounts, access to deployment environments, documented secrets procedures, architecture documentation, integration specifications, data exports, deployment and rollback instructions, clear licensing information and a handover point at every milestone. Where it is practical, the client contracts directly with the major Enterprise vendors.</p>
<p>That produces a healthier relationship than dependency does. The client keeps working with us because the collaboration is worth something, not because leaving would make the system inaccessible.</p>
<h2>What this model does not fix</h2>
<p>It does not make us equivalent to a fifty-person agency. Product and implementation knowledge is still concentrated in very few people. Documentation, narrow commitments, phased delivery, vendor support, client access and independent review all reduce that concentration. None of them removes key-person risk entirely, and I am not going to claim otherwise.</p>
<p>A client whose procurement policy requires a large interchangeable internal workforce, several parallel development squads, 24-hour application support delivered by the implementation company itself, or on-site staffing should probably choose a different kind of provider. That is a legitimate decision, and the model works best for clients who value direct access to senior people, fast technical decisions, few communication layers, architecture built for their actual business and the ability to get from concept to working software quickly.</p>
<p>The goal is not to make every enterprise buyer compatible with how we work. It is to describe the model clearly enough that both sides can judge the fit honestly and early.</p>
<h2>Small does not have to mean unsupported</h2>
<p>Size matters. It affects capacity, operational concentration and how a buyer weighs risk, and pretending otherwise is the fastest way to lose credibility in a procurement conversation.</p>
<p>But size is not the only source of assurance. Direct senior accountability, working evidence, realistic commitments, phased delivery, complete documentation, managed infrastructure, direct vendor support, independent security validation and genuine client control are all sources of assurance too, and most of them are things a small team can offer more reliably than a large one.</p>
<p>So we do not try to run a traditional agency in miniature. We focus on the product and the application, use managed Enterprise platforms for infrastructure, bring in the companies behind the technologies when direct vendor assurance is required, let independent specialists validate the security, and keep the client in control of the result.</p>
<p>That is how two people approach serious enterprise software responsibly. Not by promising that two people can do everything, but by making sure the right party is responsible for every important part.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/enterprise-software-small-teams-two-person-model</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/enterprise-software-small-teams-two-person-model</guid>
            <category><![CDATA[Economics]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Fri, 31 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;Partway through a recent enterprise opportunity, someone on the client side asked the question that always arrives eventually. Can a team this small safely design, deliver and support a platform that becomes important to our business?&lt;/p&gt;
&lt;p&gt;It is a fair question, and it gets sharper when the project touches regulated data, external partners, customer accounts, contractual availability or eventual integration with internal enterprise systems. We are two people. There is no sales department, no layer of account management, no bench of junior developers to absorb a departure, no internal infrastructure team.&lt;/p&gt;
&lt;p&gt;The tempting answer is to inflate. Talk about &amp;quot;the team&amp;quot;, describe capabilities in the passive voice, let the buyer imagine an organisation that does not exist. I have watched that approach work in procurement and then fall apart in delivery.&lt;/p&gt;
&lt;p&gt;The answer we give instead is a division of responsibility. We stay directly accountable for product direction, user experience, application architecture, implementation, data and integration design, and the quality of what ships. For infrastructure, platform security, availability, framework-level support and independent security validation, we work with specialist vendors and, where it matters, with the companies that build and operate the underlying technology.&lt;/p&gt;
&lt;p&gt;A small senior team delivers the product. Enterprise technology vendors support the platform. The client retains control.&lt;/p&gt;
&lt;p&gt;This article is not an argument that two people are better than fifty. It is an explanation of how a small senior team can structure responsibility in a way that survives serious enterprise scrutiny.&lt;/p&gt;
&lt;h2&gt;The concern is legitimate&lt;/h2&gt;
&lt;p&gt;Enterprise software is not evaluated by asking whether the supplier can write the code. A buyer has to think about capacity, about what happens if a key person becomes unavailable, about who operates the infrastructure and who answers the phone during a platform incident. They need to know how security updates are handled, whether availability commitments can be met, whether an external specialist can review the implementation, whether the client can take control if the relationship ends, and whether the platform can keep evolving for years.&lt;/p&gt;
&lt;p&gt;Those are operational questions, and a polished demonstration does not answer them. A demo proves a team understands the product and can build it. It does not eliminate long-term supplier risk.&lt;/p&gt;
&lt;p&gt;I have written separately about &lt;a href=&quot;/blog/procurement-fit-avoid-bad-projects-guide&quot;&gt;procurement fit and why a strong product match does not always produce the right engagement&lt;/a&gt;, because alignment has to hold across the product, the delivery model and the buying process at the same time.&lt;/p&gt;
&lt;h2&gt;Headcount is an imperfect proxy&lt;/h2&gt;
&lt;p&gt;Companies use team size as a shortcut for operational stability, and the logic is understandable. Fifty employees look like more replacement capacity than two.&lt;/p&gt;
&lt;p&gt;But headcount does not tell you who will actually work on your project, and it does not tell you who operates the systems underneath it. A larger agency may present its senior sales and strategy people during procurement and assign the work to a smaller implementation group once the contract is signed. That agency still depends on a public cloud provider, an identity provider, a managed database, third-party monitoring, an external penetration-testing company and a stack of open-source frameworks maintained by other organisations entirely.&lt;/p&gt;
&lt;p&gt;Almost every modern software company runs on a network of vendors. So the useful question is not how many people the implementation company employs. It is who is responsible for each critical layer of the system, and what assurance exists around that responsibility.&lt;/p&gt;
&lt;p&gt;That is the question we answer explicitly.&lt;/p&gt;
&lt;h2&gt;What we take responsibility for&lt;/h2&gt;
&lt;p&gt;We own the application and the product, which is the part you cannot buy by purchasing an Enterprise platform.&lt;/p&gt;
&lt;p&gt;On product and experience, we work directly with stakeholders to define who the platform serves, which journeys matter, what belongs in the first release, how different audiences move through the system and where it expands later. There is no handoff from a sales consultant to a separate product team. The people in the room are the people designing and building it.&lt;/p&gt;
&lt;p&gt;On architecture, we define the frontend and backend boundaries, the content and operational data models, system ownership, APIs and integrations, authentication and authorization, tenant and partner isolation, caching, analytics, deployment structure and the migration path forward. Where the project includes a CMS, we also decide what that CMS should not control. A flexible CMS can store almost anything, which is exactly why it quietly becomes the source of truth for business data it was never meant to own. I go deeper on that in &lt;a href=&quot;/blog/cms-architecture-define-what-cms-must-not-own&quot;&gt;CMS Architecture: Define What Your CMS Must Not Own&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;On security, the line is worth stating plainly, because it is the one buyers most often blur: a managed hosting platform does not secure badly designed application code. The infrastructure provider secures the platform it operates. We secure the application running on it, which means authorization rules, role design, tenant isolation, input validation, API permissions, session handling, secrets, dependency updates, audit trails and the remediation of anything a security review finds in our implementation.&lt;/p&gt;
&lt;p&gt;And we stay responsible for making the system understandable to someone who is not us. Architecture and data-flow documentation, deployment instructions, environment definitions, integration contracts, decision records, known limitations and open risks. Documentation is not a deliverable bolted on at the end of the project. For a small team it is one of the main mechanisms that reduces the client&amp;#39;s dependency on us, which makes it a commercial artifact as much as a technical one.&lt;/p&gt;
&lt;h2&gt;What we deliberately do not rebuild&lt;/h2&gt;
&lt;p&gt;Some enterprise capabilities are expensive and difficult because they should be. Operating secure, resilient infrastructure takes continuous investment in networking, platform engineering, observability, incident response and security operations.&lt;/p&gt;
&lt;p&gt;We have no interest in reproducing a global edge network, network-level DDoS protection, a proprietary web application firewall, multi-region orchestration, an internal 24-hour operations department, our own identity provider or a platform-wide observability product inside a project implementation budget. Those things already exist as managed Enterprise services, operated by organisations whose entire business is providing them.&lt;/p&gt;
&lt;p&gt;This is not blind outsourcing. We still have to select, configure and integrate those services correctly, and getting that wrong is its own failure mode. The distinction is that the platform underneath is run by people who do nothing else.&lt;/p&gt;
&lt;h2&gt;The responsibility map&lt;/h2&gt;
&lt;p&gt;For a typical enterprise web platform, the model looks like this.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Responsible party&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Product and application&lt;/td&gt;
&lt;td&gt;Our studio&lt;/td&gt;
&lt;td&gt;Product direction, UX and UI, solution architecture, Next.js development, CMS and backend implementation, integrations, application security, analytics, testing, documentation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed infrastructure&lt;/td&gt;
&lt;td&gt;A provider such as Vercel&lt;/td&gt;
&lt;td&gt;Hosting, deployment infrastructure, edge delivery, scaling, platform observability, WAF, DDoS mitigation, multi-region options, contractual availability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application platform&lt;/td&gt;
&lt;td&gt;The framework vendor, for example Payload Enterprise&lt;/td&gt;
&lt;td&gt;Product support, escalation of framework-level issues, implementation consultation, architecture reviews, upgrade guidance, roadmap visibility&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Independent security&lt;/td&gt;
&lt;td&gt;A specialist security firm&lt;/td&gt;
&lt;td&gt;Architecture review, penetration testing, authenticated and black-box testing, configuration review, remediation verification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Governance&lt;/td&gt;
&lt;td&gt;The client&amp;#39;s internal teams&lt;/td&gt;
&lt;td&gt;Security governance, vendor approval, data classification, compliance requirements, production approval, risk acceptance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The exact services depend on the package selected and the client&amp;#39;s requirements, but the shape holds. We remain responsible for the implementation; the vendor provides an escalation path to the people who maintain the framework itself. The security firm is deliberately not us, which creates separation between the company building the application and the company validating it. And the client never loses control simply because the work is delivered externally.&lt;/p&gt;
&lt;h2&gt;Why naming parties beats promising everything&lt;/h2&gt;
&lt;p&gt;There is a version of this conversation where every procurement requirement gets the same answer. WAF? Yes. Multi-region failover? Yes. Security monitoring, framework support, penetration testing, 24-hour incident response, long-term maintenance? Yes, yes, yes.&lt;/p&gt;
&lt;p&gt;From a two-person company, that answer becomes difficult to believe somewhere around the third yes.&lt;/p&gt;
&lt;p&gt;The stronger answer identifies the responsible party for each requirement. The implementation team owns authorization. The cloud platform owns the underlying infrastructure. The WAF provider operates perimeter protection. The framework vendor handles product-level escalation. An independent firm conducts the penetration test. The client&amp;#39;s security team defines acceptance.&lt;/p&gt;
&lt;p&gt;That replaces a broad promise with named responsibilities, contracts and escalation paths, which means the buyer can inspect the model rather than trust it. In enterprise delivery that is usually worth more than claiming one supplier internally owns every competency.&lt;/p&gt;
&lt;h2&gt;Buy the assurance you actually need&lt;/h2&gt;
&lt;p&gt;Enterprise support packages are expensive. A client may eventually need advanced platform support, a contractual 99.99% availability SLA, managed security rulesets, multi-region failover, private networking, extended log retention, directory synchronization, SIEM integration and formal architecture reviews.&lt;/p&gt;
&lt;p&gt;That does not mean all of it has to be bought before the first user reaches the platform.&lt;/p&gt;
&lt;p&gt;A secure MVP can start with approved EU hosting, HTTPS, managed backups, secure secret storage, role-based access, strong tenant isolation, application logging, dependency maintenance, baseline WAF and DDoS protection, monitoring, documented recovery procedures and an independent penetration test before production. The heavier Enterprise layers get activated when internal security policy, real traffic, a contractual commitment, a sensitive integration or a regulator requires them.&lt;/p&gt;
&lt;p&gt;The principle is to design for the higher standard from the beginning but not to purchase every higher-cost capability before it is required. That avoids both failure modes: launching cheaply onto an architecture that has to be thrown away, and loading an unvalidated product with substantial annual vendor costs.&lt;/p&gt;
&lt;p&gt;When those vendor costs do arrive, they should be visible. A proposal should separate architecture and discovery, product design and implementation, third-party and Enterprise platform costs, independent security services, and ongoing support. Keeping them separate shows the client what it is buying, stops us absorbing unpredictable vendor pricing, allows the client to contract directly with major vendors where that makes sense, and makes later upgrades easy to evaluate. A client asking for high-assurance infrastructure should expect the platform cost that comes with it. That number is not evidence of inefficient development. It is the price of the service level being requested.&lt;/p&gt;
&lt;h2&gt;Start with architecture, then scope milestones independently&lt;/h2&gt;
&lt;p&gt;None of this works until the requirements are actually understood. Before recommending an expensive Enterprise package, we need to know what data the platform will process, whether it must stay in the EU, which systems it integrates with, whether SSO is required, what availability is contractual, how long logs must be retained and whether they flow into an internal SIEM, who owns the hosting accounts, which vendors are already approved, who performs the penetration test, and whether the first release handles public information or sensitive applications.&lt;/p&gt;
&lt;p&gt;That is why we recommend an architecture and discovery sprint before full implementation. It closes the decisions needed to build the first release safely and separates confirmed requirements from assumptions and from things that belong to a later phase. For complex projects this is far more reliable than treating the first mockups as a production specification, a point I make more fully in &lt;a href=&quot;/blog/architecture-first-website-rebuilds&quot;&gt;Website Rebuilds: Start with Architecture, Not Mockups&lt;/a&gt;. The sprint should end with confirmed V1 scope, an architecture recommendation, system boundaries, vendor decisions, security requirements, data ownership, an integration strategy, a roadmap, named risks, acceptance criteria and implementation pricing that means something.&lt;/p&gt;
&lt;p&gt;After that, we work in independently scoped milestones rather than an open-ended multi-year commitment, because a small team promising unlimited parallel capacity is exactly the kind of claim that collapses later. A typical shape runs from architecture and discovery, to the core platform foundation of authentication, data models, environments and deployment, to the first complete customer journey, then partner and internal operations, then integrations, security testing and production hardening.&lt;/p&gt;
&lt;p&gt;Each milestone defines its deliverables, responsibilities, timeline, price, acceptance criteria, dependencies, handover state and the decisions we need from the client. That gives the client budget control, lets the architecture absorb real information as it arrives, and keeps the committed scope proportionate to the capacity of the people actually doing the work.&lt;/p&gt;
&lt;h2&gt;Prove it with working software&lt;/h2&gt;
&lt;p&gt;A small team cannot win on a capability deck. The strongest evidence available to us is a working vertical slice.&lt;/p&gt;
&lt;p&gt;For a recent enterprise opportunity we demonstrated considerably more than frontend screens. The concept included the customer-facing journey, an AI-guided experience, the workflow operating behind that AI, structured application data, analytics and session replay, partner administration, internal CMS administration, and the architecture and security documentation underneath. The point was not to pretend the production system existed. It was to show that the visible experience connected to a credible implementation model.&lt;/p&gt;
&lt;p&gt;I go further into that idea in &lt;a href=&quot;/blog/a-software-demo-should-show-the-system-not-just-the-screen&quot;&gt;A Software Demo Should Show the System, Not Just the Screen&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This matters more for us than for a large supplier. An agency can point at headcount, departments and twenty years of corporate history. A specialist team has to make its capability visible in the work itself, and the people presenting the system have to be the people who understand how it works.&lt;/p&gt;
&lt;h2&gt;Client control matters more when the supplier is small&lt;/h2&gt;
&lt;p&gt;A client should never be trapped by an implementation partner. That is true of any supplier and it deserves particular attention with a two-person one.&lt;/p&gt;
&lt;p&gt;So we structure projects so the client holds access to the source repository, ownership or administrative access to infrastructure accounts, access to deployment environments, documented secrets procedures, architecture documentation, integration specifications, data exports, deployment and rollback instructions, clear licensing information and a handover point at every milestone. Where it is practical, the client contracts directly with the major Enterprise vendors.&lt;/p&gt;
&lt;p&gt;That produces a healthier relationship than dependency does. The client keeps working with us because the collaboration is worth something, not because leaving would make the system inaccessible.&lt;/p&gt;
&lt;h2&gt;What this model does not fix&lt;/h2&gt;
&lt;p&gt;It does not make us equivalent to a fifty-person agency. Product and implementation knowledge is still concentrated in very few people. Documentation, narrow commitments, phased delivery, vendor support, client access and independent review all reduce that concentration. None of them removes key-person risk entirely, and I am not going to claim otherwise.&lt;/p&gt;
&lt;p&gt;A client whose procurement policy requires a large interchangeable internal workforce, several parallel development squads, 24-hour application support delivered by the implementation company itself, or on-site staffing should probably choose a different kind of provider. That is a legitimate decision, and the model works best for clients who value direct access to senior people, fast technical decisions, few communication layers, architecture built for their actual business and the ability to get from concept to working software quickly.&lt;/p&gt;
&lt;p&gt;The goal is not to make every enterprise buyer compatible with how we work. It is to describe the model clearly enough that both sides can judge the fit honestly and early.&lt;/p&gt;
&lt;h2&gt;Small does not have to mean unsupported&lt;/h2&gt;
&lt;p&gt;Size matters. It affects capacity, operational concentration and how a buyer weighs risk, and pretending otherwise is the fastest way to lose credibility in a procurement conversation.&lt;/p&gt;
&lt;p&gt;But size is not the only source of assurance. Direct senior accountability, working evidence, realistic commitments, phased delivery, complete documentation, managed infrastructure, direct vendor support, independent security validation and genuine client control are all sources of assurance too, and most of them are things a small team can offer more reliably than a large one.&lt;/p&gt;
&lt;p&gt;So we do not try to run a traditional agency in miniature. We focus on the product and the application, use managed Enterprise platforms for infrastructure, bring in the companies behind the technologies when direct vendor assurance is required, let independent specialists validate the security, and keep the client in control of the result.&lt;/p&gt;
&lt;p&gt;That is how two people approach serious enterprise software responsibly. Not by promising that two people can do everything, but by making sure the right party is responsible for every important part.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/enterprise-software-small-teams-two-person-model"/>
        </item>
        <item>
            <title><![CDATA[Free Screaming Frog Alternative: Crawl 1,625 Pages Free]]></title>
            <description><![CDATA[<p>I needed a full URL inventory for a WordPress to Payload migration, and Screaming Frog&#39;s free version stops at 500 URLs. The site had more pages than that in its blog archive alone, so the choice was a licence or a different tool.</p>
<p>I went with FreeCrawl, an open-source crawler that runs from the command line, stores everything in a local SQLite database and renders JavaScript through Playwright. It worked. The crawl recovered 1,625 HTML pages, 92,071 internal link relationships and a 441 MB database.</p>
<p>It also failed in three places that cost me a full crawl, and none of those failures are in the documentation. This guide walks through the installation and crawl sequence that actually worked, the two commands that did not, and how to read the crawl database while the crawler is still running.</p>
<h2>What I ran it on</h2>
<p>The workflow below was verified on a MacBook Pro, macOS 15.7.1, Apple Silicon, 10 cores and 16 GB of memory, with Node.js 24.12.0, npm 11.6.2 and Git 2.52.0. FreeCrawl ran CLI-only against commit <code>b91a2b7eae32f0688b11a62a0ce954beea0813df</code>. The Electron desktop application was never opened, Docker was never touched, and JavaScript rendering used Playwright Chromium in true headless mode.</p>
<p>That distinction matters for the Ubuntu section near the end. The macOS path is tested. The Linux path is reasoned from the project structure, not verified.</p>
<h2>Installing from source</h2>
<p>There is no packaged release in this workflow. You clone the repository and build it yourself, so start by confirming your toolchain is present.</p>
<pre><code class="language-bash">node --version
npm --version
git --version
</code></pre>
<p>The project declares Node.js 22 or newer. Node 24 worked without complaint.</p>
<pre><code class="language-bash">git clone https://github.com/kemalai/FreeCrawl-SEO-Tool.git
cd FreeCrawl-SEO-Tool
npm install
</code></pre>
<p>Keep the crawler outside the repository you are auditing. It pulls in its own build output and browser binaries, and there is no reason for Chrome for Testing to end up in a client project.</p>
<p>That <code>npm install</code> is heavier than it looks. The post-install script downloads Chrome for Testing, Chrome Headless Shell and FFmpeg, and the installed footprint came to roughly 1.9 GB before a single URL was crawled: about 1.4 GB for the repository, build output and dependencies, and another 534 MB for the Playwright browser cache.</p>
<p>The install also reported 18 npm vulnerabilities, one of them critical. I did not investigate them, so I cannot tell you whether they were exploitable in this context. If you are putting this on a shared or production server, read the report yourself before deciding how to isolate the tool.</p>
<pre><code class="language-bash">npm audit
</code></pre>
<p>Do not reflexively run <code>npm audit fix</code> here. Review the proposed dependency changes first.</p>
<h2>The build order the docs do not mention</h2>
<p>The obvious command is the one that fails.</p>
<pre><code class="language-bash">npm run build:cli
</code></pre>
<p>From a clean clone, that produced module resolution errors:</p>
<pre><code class="language-text">Cannot find module &#39;@freecrawl/shared-types&#39;
Cannot find module &#39;@freecrawl/core&#39;
Cannot find module &#39;@freecrawl/db&#39;
</code></pre>
<p>FreeCrawl is a TypeScript monorepo, and the CLI package depends on shared packages that have not been compiled yet. Running the TypeScript project build first resolves every one of those errors.</p>
<pre><code class="language-bash">npx tsc -b
npm run build:cli
node apps/cli/dist/index.js --help
</code></pre>
<p>So the complete installation, from nothing to a working binary, is this:</p>
<pre><code class="language-bash">git clone https://github.com/kemalai/FreeCrawl-SEO-Tool.git
cd FreeCrawl-SEO-Tool
npm install
npx tsc -b
npm run build:cli
node apps/cli/dist/index.js --help
git rev-parse HEAD
</code></pre>
<p>That last line is worth keeping. FreeCrawl is under active development, and recording the commit means you can tell later whether a behavior change came from the tool or from your own configuration.</p>
<h2>The external-link behavior that cost me a crawl</h2>
<p>My first production crawl went in the bin because FreeCrawl started making real requests to social-sharing and citation domains that appeared in the page content.</p>
<p>The <code>--external</code> flag reads as though it governs whether external URLs are requested at all. It does not. In the tested version, FreeCrawl made one real status-check request to every discovered external URL whether or not the flag was passed. What the flag controls is whether the crawler then continues recursively through those external pages. I only confirmed this by reading the crawler source after foreign hostnames turned up in the database.</p>
<p>The fix is an explicit exclusion pattern that allows your domain and blocks everything else.</p>
<pre><code class="language-bash">--exclude &quot;^https?://(?!(www\.)?example\.com([:/]|$))&quot;
</code></pre>
<p>That negative lookahead permits <code>https://example.com/</code> and <code>https://www.example.com/</code> and rejects any other host. Add it to every single invocation. Configuration does not carry over between commands, so a crawl you run tomorrow without the flag will start hitting third-party domains again.</p>
<h2>Run twenty URLs before you run twenty thousand</h2>
<p>A ten to twenty URL test crawl would have caught the external-domain problem in under a minute, which is the main reason this section exists.</p>
<pre><code class="language-bash">mkdir -p output/example
</code></pre>
<pre><code class="language-bash">node apps/cli/dist/index.js \
  https://example.com/ \
  --depth 2 \
  --max 20 \
  --concurrency 2 \
  --rps 1 \
  --exclude &quot;^https?://(?!(www\.)?example\.com([:/]|$))&quot; \
  --db output/example/test.seoproject
</code></pre>
<p>Replace both instances of <code>example.com</code> with the domain you are authorized to crawl. Then open the resulting database and check which hosts were actually requested, whether images and scripts are being stored, whether query parameters are generating duplicate URLs, and whether the site is returning throttling responses. Only move on once the scope is provably correct.</p>
<p>While you are here, read the robots file properly rather than assuming.</p>
<pre><code class="language-bash">curl -fsSL https://example.com/robots.txt
</code></pre>
<p>The production site I crawled declared a very high crawl delay. The crawl was authorized by the site owner, so we agreed on a conservative two requests per second instead, and the corrected run returned no 429, 502 or 503 responses at all. Without that authorization, respect what is published.</p>
<p>Sitemaps are worth locating at the same time, because a link-following crawl and a CMS-generated sitemap answer two different questions. The crawl finds pages reachable through links. The sitemap lists URLs the site claims should exist. The gap between them is where orphan pages, stale URLs and navigation holes live, which is exactly what a migration inventory needs.</p>
<pre><code class="language-bash">curl -I https://example.com/sitemap.xml
curl -I https://example.com/sitemap_index.xml
curl -I https://example.com/wp-sitemap.xml
</code></pre>
<p>A <code>200</code>, <code>301</code> or <code>302</code> response tells you a URL responds, not that it contains valid XML. Download it and look at it.</p>
<h2>The primary raw HTML crawl</h2>
<p>With scope confirmed, the real run is the same command with the limits opened up and exports requested.</p>
<pre><code class="language-bash">node apps/cli/dist/index.js \
  https://example.com/ \
  --depth 20 \
  --max 100000 \
  --concurrency 4 \
  --rps 2 \
  --exclude &quot;^https?://(?!(www\.)?example\.com([:/]|$))&quot; \
  --db output/example/example-text.seoproject \
  --out output/example/example-text.json \
  --json
</code></pre>
<p>The depth of 20 lets the crawler follow deeply nested internal paths, and the 100,000 ceiling replaces the artificial 500-URL limit that sent me here in the first place. Concurrency and requests per second are the two politeness controls, and raising concurrency does nothing useful when the rate limit or the origin server is the real bottleneck, so start low. The <code>--db</code> flag writes the SQLite project, and <code>--out</code> plus <code>--json</code> request an export and a machine-readable summary once the crawl finishes naturally.</p>
<p>That last condition matters more than it sounds. I will come back to it.</p>
<h2>A URL count is not a page count</h2>
<p>The crawl reached 5,554 internal URLs. Only 1,625 of them were pages.</p>
<table>
<thead>
<tr>
<th>Content type</th>
<th align="right">Count</th>
</tr>
</thead>
<tbody><tr>
<td>HTML pages</td>
<td align="right">1,625</td>
</tr>
<tr>
<td>Images</td>
<td align="right">3,427</td>
</tr>
<tr>
<td>JavaScript files</td>
<td align="right">233</td>
</tr>
<tr>
<td>CSS files</td>
<td align="right">223</td>
</tr>
<tr>
<td>Fonts</td>
<td align="right">32</td>
</tr>
<tr>
<td>PDFs</td>
<td align="right">2</td>
</tr>
<tr>
<td>Other resources</td>
<td align="right">12</td>
</tr>
<tr>
<td>Total</td>
<td align="right">5,554</td>
</tr>
</tbody></table>
<p>FreeCrawl records linked resources in the same table as documents, so a headline number like &quot;5,554 URLs crawled&quot; will quietly inflate your migration scope by a factor of three if nobody separates them. For a content migration, treat HTML and PDFs as documents and everything else as resources. The resource count still matters for media analysis, but it is not the page inventory.</p>
<h2>Querying the crawl database</h2>
<p>The <code>.seoproject</code> file is an ordinary SQLite database, and this turned out to be the single best thing about the tool. Instead of being limited to whatever reports someone decided to build, every question I had became a query.</p>
<pre><code class="language-bash">brew install sqlite
sqlite3 output/example/example-text.seoproject
</code></pre>
<p>Start with the same breakdown as above, straight from the database.</p>
<pre><code class="language-sql">SELECT
  content_kind,
  COUNT(*) AS total
FROM urls
WHERE is_external = 0
GROUP BY content_kind
ORDER BY total DESC;
</code></pre>
<p>Filtering on <code>is_external = 0</code> is your check that the exclusion rule held. If foreign hostnames appear anywhere in this table, the crawl scope leaked and the run is not trustworthy.</p>
<p>From there, the audit questions are all variations on filtering <code>content_kind = &#39;html&#39;</code>.</p>
<pre><code class="language-sql">SELECT
  status_code,
  COUNT(*) AS total
FROM urls
WHERE content_kind = &#39;html&#39;
GROUP BY status_code
ORDER BY status_code;
</code></pre>
<pre><code class="language-sql">SELECT
  url
FROM urls
WHERE content_kind = &#39;html&#39;
  AND status_code = 200
  AND (title IS NULL OR TRIM(title) = &#39;&#39;);
</code></pre>
<pre><code class="language-sql">SELECT
  title,
  COUNT(*) AS occurrences
FROM urls
WHERE content_kind = &#39;html&#39;
  AND status_code = 200
  AND title IS NOT NULL
  AND TRIM(title) &lt;&gt; &#39;&#39;
GROUP BY title
HAVING COUNT(*) &gt; 1
ORDER BY occurrences DESC;
</code></pre>
<p>Missing titles come from a null-or-empty check restricted to successful HTML responses, so redirects and errors do not pollute the result. Duplicate titles come from grouping the same filtered set and keeping only the groups with more than one member. Neither needs an export step, and both can be rerun against the database a month later when someone asks whether the problem was fixed.</p>
<p>The database is also readable while the crawler is still writing to it, which is how I worked out when to stop.</p>
<pre><code class="language-sql">SELECT COUNT(*) FROM crawl_queue;
</code></pre>
<p>Watching the HTML count sit still at 1,625 while thousands of image requests remained queued told me the page discovery was finished and the remaining work was resources I did not need.</p>
<h2>Read the links table, not the convenience columns</h2>
<p>FreeCrawl stores the discovered link graph in a <code>links</code> table, which held 92,071 internal relationships in my crawl. The <code>urls</code> table also has an <code>inlinks</code> column, and mine was zero across the board, because that column is populated by an end-of-crawl aggregation step that never ran.</p>
<p>If you interrupt a crawl, calculate from the raw graph instead.</p>
<pre><code class="language-sql">SELECT
  to_url,
  COUNT(*) AS inlinks
FROM links
WHERE is_internal = 1
GROUP BY to_url
ORDER BY inlinks DESC;
</code></pre>
<p>The more useful version joins that graph against response status, which gives you every broken page ranked by how many internal links still point at it. That is your redirect priority list for a migration.</p>
<pre><code class="language-sql">SELECT
  l.to_url,
  COUNT(*) AS internal_links
FROM links AS l
JOIN urls AS u
  ON u.url = l.to_url
WHERE l.is_internal = 1
  AND u.status_code &gt;= 400
GROUP BY l.to_url
ORDER BY internal_links DESC;
</code></pre>
<p>A static export cannot answer that question without a script. A relational link graph answers it in six lines.</p>
<h2>Stopping early changes what you get</h2>
<p>I stopped the primary crawl deliberately, once HTML discovery had plateaued and the queue was mostly images.</p>
<pre><code class="language-bash">kill -INT &lt;PROCESS_ID&gt;
</code></pre>
<p>The process exited with code 130, which correctly signals an interrupt rather than a crash, and the SQLite database remained fully usable. Two things did not happen: the JSON export I requested with <code>--out</code> was never written, and the aggregate fields like <code>inlinks</code> were never populated.</p>
<p>The crawl was still successful. The pages, metadata and link graph were all there. The missing export was a consequence of how I ended the process, not evidence of failure.</p>
<p>Treat the SQLite database as the primary artifact and the JSON as a bonus you only trust when the process exits on its own.</p>
<p>Exit codes need the same skepticism. During one JavaScript sample run, FreeCrawl returned exit code <code>1</code> simply because the crawl had encountered HTTP error responses, and the database and summary were both complete. If you are wrapping this in automation, check whether the database exists, how many rows it holds and what the status summary says, rather than treating any non-zero exit as an unusable crawl.</p>
<h2>Test JavaScript rendering on a sample</h2>
<p>A browser-rendered crawl is far more expensive than a raw HTML one, so the question is not whether FreeCrawl can render JavaScript but whether your site needs it.</p>
<p>Put a representative set of URLs in a plain text file at <code>output/example/js-sample-urls.txt</code>:</p>
<pre><code class="language-text">https://example.com/
https://example.com/products/example-product/
https://example.com/blog/example-article/
https://example.com/recipes/example-recipe/
https://example.com/category/example/
</code></pre>
<p>Then a config file at <code>output/example/js-config.json</code>:</p>
<pre><code class="language-json">{
  &quot;renderingMode&quot;: &quot;js&quot;,
  &quot;maxDepth&quot;: 0,
  &quot;maxUrls&quot;: 250,
  &quot;maxConcurrency&quot;: 2,
  &quot;maxRps&quot;: 1,
  &quot;respectRobotsTxt&quot;: true,
  &quot;crawlExternal&quot;: false,
  &quot;jsRender&quot;: {
    &quot;headless&quot;: true
  }
}
</code></pre>
<p>The <code>maxDepth</code> of 0 is the important line. It tells FreeCrawl to render exactly the URLs in your list and not follow links out of them, which keeps an expensive rendering pass from turning into a second full crawl.</p>
<pre><code class="language-bash">node apps/cli/dist/index.js \
  --list output/example/js-sample-urls.txt \
  --config output/example/js-config.json \
  --exclude &quot;^https?://(?!(www\.)?example\.com([:/]|$))&quot; \
  --db output/example/js-sample.seoproject \
  --out output/example/js-sample.json \
  --json
</code></pre>
<p>I compared 39 URLs this way, covering the homepage, navigation pages, products, blog posts, recipes, taxonomy pages and anything likely to contain interactive elements. Chromium launched in true headless mode without any display server, and across all 39 pages the rendered crawl found no meaningful difference in titles, meta descriptions, H1s, canonicals, hreflang counts, structured data types, link counts or word counts.</p>
<p>That is not a rendering failure. It is evidence that this particular WordPress site already served every migration-relevant field in its initial HTML, which meant a full JavaScript crawl would have cost hours and told me nothing new. Crawl raw HTML first, render a sample, compare, and only escalate if the sample shows you something missing.</p>
<h2>What the crawl actually produced</h2>
<p>For the record, here is what came out of the corrected production run.</p>
<table>
<thead>
<tr>
<th>Result</th>
<th align="right">Count</th>
</tr>
</thead>
<tbody><tr>
<td>HTML pages</td>
<td align="right">1,625</td>
</tr>
<tr>
<td>200 responses</td>
<td align="right">1,216</td>
</tr>
<tr>
<td>301 redirects</td>
<td align="right">299</td>
</tr>
<tr>
<td>404 responses</td>
<td align="right">110</td>
</tr>
<tr>
<td>5xx responses</td>
<td align="right">0</td>
</tr>
<tr>
<td>Indexable pages</td>
<td align="right">1,088</td>
</tr>
<tr>
<td>Non-indexable pages</td>
<td align="right">537</td>
</tr>
</tbody></table>
<p>Alongside that, it flagged 474 missing titles, 399 duplicate titles, 699 missing meta descriptions, 588 missing H1s, 691 pages with multiple H1s, 1,113 sitemap-only URLs and 607 crawled URLs absent from the sitemaps.</p>
<p>Those numbers prove FreeCrawl can build a real inventory. They do not prove that every line is a defect. Archive pages, redirects, utility routes and deliberately non-indexable content can legitimately lack the metadata you would demand of a landing page. A crawler produces candidates, and the audit still needs a human deciding which ones matter.</p>
<p>The other boundary is worth stating plainly, because it caught a colleague out on a previous project. A public crawl tells you which URLs are reachable, which are declared, how pages link to each other and what metadata renders. It cannot tell you which WordPress post type owns a URL, which custom fields produced the page, which drafts and private records exist, which page-builder components were used or which translations are incomplete. For a migration you need the crawl and a database or API export from the CMS. One is the external view, the other is the source of truth.</p>
<h2>Running it on a server</h2>
<p>Ubuntu Server was not part of this implementation, so treat the following as a path to test rather than a confirmed reproduction.</p>
<pre><code class="language-bash">sudo apt-get update
sudo apt-get install -y curl git ca-certificates

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs

git clone https://github.com/kemalai/FreeCrawl-SEO-Tool.git
cd FreeCrawl-SEO-Tool

npm install
sudo npx playwright install-deps chromium

npx tsc -b
npm run build:cli

node apps/cli/dist/index.js --help
</code></pre>
<p>The one addition over the macOS sequence is <code>playwright install-deps chromium</code>, which pulls the system libraries Chromium needs on Linux. Because Playwright Chromium supports genuinely headless execution, this should not require GNOME, VNC or any visible browser session. On a shared machine I would also run it under a dedicated non-root user, keep it well away from application repositories, restrict outbound network access and preserve crawl databases somewhere that is not a temporary directory.</p>
<h2>What I would tell you before you start</h2>
<p>FreeCrawl is genuinely usable as a free Screaming Frog alternative, provided you are the kind of person who is comfortable with a terminal, a regular expression, a SQL query and occasionally reading source code when the documentation runs out. The absence of a URL limit and the presence of a queryable database made it a better fit for a developer-led migration than a graphical application would have been.</p>
<p>Three things will bite you, and all three are avoidable now that you know about them. The CLI build fails from a clean clone unless you run <code>npx tsc -b</code> first. External domains get status-checked unless you exclude them explicitly on every command. And an interrupted crawl silently skips the JSON export and the aggregate columns, so the SQLite file is the artifact you should trust.</p>
<p>Use Screaming Frog when you want a mature, polished application and an interface that a non-technical colleague can operate. Use FreeCrawl when you want a crawler you can automate, inspect and query as part of a technical workflow. For the migration inventory I needed, the second was worth more than the first.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/free-screaming-frog-alternative-freecrawl-guide</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/free-screaming-frog-alternative-freecrawl-guide</guid>
            <category><![CDATA[Tools]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Thu, 30 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;I needed a full URL inventory for a WordPress to Payload migration, and Screaming Frog&amp;#39;s free version stops at 500 URLs. The site had more pages than that in its blog archive alone, so the choice was a licence or a different tool.&lt;/p&gt;
&lt;p&gt;I went with FreeCrawl, an open-source crawler that runs from the command line, stores everything in a local SQLite database and renders JavaScript through Playwright. It worked. The crawl recovered 1,625 HTML pages, 92,071 internal link relationships and a 441 MB database.&lt;/p&gt;
&lt;p&gt;It also failed in three places that cost me a full crawl, and none of those failures are in the documentation. This guide walks through the installation and crawl sequence that actually worked, the two commands that did not, and how to read the crawl database while the crawler is still running.&lt;/p&gt;
&lt;h2&gt;What I ran it on&lt;/h2&gt;
&lt;p&gt;The workflow below was verified on a MacBook Pro, macOS 15.7.1, Apple Silicon, 10 cores and 16 GB of memory, with Node.js 24.12.0, npm 11.6.2 and Git 2.52.0. FreeCrawl ran CLI-only against commit &lt;code&gt;b91a2b7eae32f0688b11a62a0ce954beea0813df&lt;/code&gt;. The Electron desktop application was never opened, Docker was never touched, and JavaScript rendering used Playwright Chromium in true headless mode.&lt;/p&gt;
&lt;p&gt;That distinction matters for the Ubuntu section near the end. The macOS path is tested. The Linux path is reasoned from the project structure, not verified.&lt;/p&gt;
&lt;h2&gt;Installing from source&lt;/h2&gt;
&lt;p&gt;There is no packaged release in this workflow. You clone the repository and build it yourself, so start by confirming your toolchain is present.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;node --version
npm --version
git --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The project declares Node.js 22 or newer. Node 24 worked without complaint.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/kemalai/FreeCrawl-SEO-Tool.git
cd FreeCrawl-SEO-Tool
npm install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keep the crawler outside the repository you are auditing. It pulls in its own build output and browser binaries, and there is no reason for Chrome for Testing to end up in a client project.&lt;/p&gt;
&lt;p&gt;That &lt;code&gt;npm install&lt;/code&gt; is heavier than it looks. The post-install script downloads Chrome for Testing, Chrome Headless Shell and FFmpeg, and the installed footprint came to roughly 1.9 GB before a single URL was crawled: about 1.4 GB for the repository, build output and dependencies, and another 534 MB for the Playwright browser cache.&lt;/p&gt;
&lt;p&gt;The install also reported 18 npm vulnerabilities, one of them critical. I did not investigate them, so I cannot tell you whether they were exploitable in this context. If you are putting this on a shared or production server, read the report yourself before deciding how to isolate the tool.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm audit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do not reflexively run &lt;code&gt;npm audit fix&lt;/code&gt; here. Review the proposed dependency changes first.&lt;/p&gt;
&lt;h2&gt;The build order the docs do not mention&lt;/h2&gt;
&lt;p&gt;The obvious command is the one that fails.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run build:cli
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From a clean clone, that produced module resolution errors:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Cannot find module &amp;#39;@freecrawl/shared-types&amp;#39;
Cannot find module &amp;#39;@freecrawl/core&amp;#39;
Cannot find module &amp;#39;@freecrawl/db&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FreeCrawl is a TypeScript monorepo, and the CLI package depends on shared packages that have not been compiled yet. Running the TypeScript project build first resolves every one of those errors.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx tsc -b
npm run build:cli
node apps/cli/dist/index.js --help
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the complete installation, from nothing to a working binary, is this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/kemalai/FreeCrawl-SEO-Tool.git
cd FreeCrawl-SEO-Tool
npm install
npx tsc -b
npm run build:cli
node apps/cli/dist/index.js --help
git rev-parse HEAD
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That last line is worth keeping. FreeCrawl is under active development, and recording the commit means you can tell later whether a behavior change came from the tool or from your own configuration.&lt;/p&gt;
&lt;h2&gt;The external-link behavior that cost me a crawl&lt;/h2&gt;
&lt;p&gt;My first production crawl went in the bin because FreeCrawl started making real requests to social-sharing and citation domains that appeared in the page content.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;--external&lt;/code&gt; flag reads as though it governs whether external URLs are requested at all. It does not. In the tested version, FreeCrawl made one real status-check request to every discovered external URL whether or not the flag was passed. What the flag controls is whether the crawler then continues recursively through those external pages. I only confirmed this by reading the crawler source after foreign hostnames turned up in the database.&lt;/p&gt;
&lt;p&gt;The fix is an explicit exclusion pattern that allows your domain and blocks everything else.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;--exclude &amp;quot;^https?://(?!(www\.)?example\.com([:/]|$))&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That negative lookahead permits &lt;code&gt;https://example.com/&lt;/code&gt; and &lt;code&gt;https://www.example.com/&lt;/code&gt; and rejects any other host. Add it to every single invocation. Configuration does not carry over between commands, so a crawl you run tomorrow without the flag will start hitting third-party domains again.&lt;/p&gt;
&lt;h2&gt;Run twenty URLs before you run twenty thousand&lt;/h2&gt;
&lt;p&gt;A ten to twenty URL test crawl would have caught the external-domain problem in under a minute, which is the main reason this section exists.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p output/example
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;node apps/cli/dist/index.js \
  https://example.com/ \
  --depth 2 \
  --max 20 \
  --concurrency 2 \
  --rps 1 \
  --exclude &amp;quot;^https?://(?!(www\.)?example\.com([:/]|$))&amp;quot; \
  --db output/example/test.seoproject
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace both instances of &lt;code&gt;example.com&lt;/code&gt; with the domain you are authorized to crawl. Then open the resulting database and check which hosts were actually requested, whether images and scripts are being stored, whether query parameters are generating duplicate URLs, and whether the site is returning throttling responses. Only move on once the scope is provably correct.&lt;/p&gt;
&lt;p&gt;While you are here, read the robots file properly rather than assuming.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsSL https://example.com/robots.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The production site I crawled declared a very high crawl delay. The crawl was authorized by the site owner, so we agreed on a conservative two requests per second instead, and the corrected run returned no 429, 502 or 503 responses at all. Without that authorization, respect what is published.&lt;/p&gt;
&lt;p&gt;Sitemaps are worth locating at the same time, because a link-following crawl and a CMS-generated sitemap answer two different questions. The crawl finds pages reachable through links. The sitemap lists URLs the site claims should exist. The gap between them is where orphan pages, stale URLs and navigation holes live, which is exactly what a migration inventory needs.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -I https://example.com/sitemap.xml
curl -I https://example.com/sitemap_index.xml
curl -I https://example.com/wp-sitemap.xml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A &lt;code&gt;200&lt;/code&gt;, &lt;code&gt;301&lt;/code&gt; or &lt;code&gt;302&lt;/code&gt; response tells you a URL responds, not that it contains valid XML. Download it and look at it.&lt;/p&gt;
&lt;h2&gt;The primary raw HTML crawl&lt;/h2&gt;
&lt;p&gt;With scope confirmed, the real run is the same command with the limits opened up and exports requested.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;node apps/cli/dist/index.js \
  https://example.com/ \
  --depth 20 \
  --max 100000 \
  --concurrency 4 \
  --rps 2 \
  --exclude &amp;quot;^https?://(?!(www\.)?example\.com([:/]|$))&amp;quot; \
  --db output/example/example-text.seoproject \
  --out output/example/example-text.json \
  --json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The depth of 20 lets the crawler follow deeply nested internal paths, and the 100,000 ceiling replaces the artificial 500-URL limit that sent me here in the first place. Concurrency and requests per second are the two politeness controls, and raising concurrency does nothing useful when the rate limit or the origin server is the real bottleneck, so start low. The &lt;code&gt;--db&lt;/code&gt; flag writes the SQLite project, and &lt;code&gt;--out&lt;/code&gt; plus &lt;code&gt;--json&lt;/code&gt; request an export and a machine-readable summary once the crawl finishes naturally.&lt;/p&gt;
&lt;p&gt;That last condition matters more than it sounds. I will come back to it.&lt;/p&gt;
&lt;h2&gt;A URL count is not a page count&lt;/h2&gt;
&lt;p&gt;The crawl reached 5,554 internal URLs. Only 1,625 of them were pages.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content type&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;HTML pages&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;1,625&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;3,427&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript files&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;233&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSS files&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;223&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fonts&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFs&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other resources&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;5,554&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;FreeCrawl records linked resources in the same table as documents, so a headline number like &amp;quot;5,554 URLs crawled&amp;quot; will quietly inflate your migration scope by a factor of three if nobody separates them. For a content migration, treat HTML and PDFs as documents and everything else as resources. The resource count still matters for media analysis, but it is not the page inventory.&lt;/p&gt;
&lt;h2&gt;Querying the crawl database&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;.seoproject&lt;/code&gt; file is an ordinary SQLite database, and this turned out to be the single best thing about the tool. Instead of being limited to whatever reports someone decided to build, every question I had became a query.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;brew install sqlite
sqlite3 output/example/example-text.seoproject
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Start with the same breakdown as above, straight from the database.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
  content_kind,
  COUNT(*) AS total
FROM urls
WHERE is_external = 0
GROUP BY content_kind
ORDER BY total DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Filtering on &lt;code&gt;is_external = 0&lt;/code&gt; is your check that the exclusion rule held. If foreign hostnames appear anywhere in this table, the crawl scope leaked and the run is not trustworthy.&lt;/p&gt;
&lt;p&gt;From there, the audit questions are all variations on filtering &lt;code&gt;content_kind = &amp;#39;html&amp;#39;&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
  status_code,
  COUNT(*) AS total
FROM urls
WHERE content_kind = &amp;#39;html&amp;#39;
GROUP BY status_code
ORDER BY status_code;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
  url
FROM urls
WHERE content_kind = &amp;#39;html&amp;#39;
  AND status_code = 200
  AND (title IS NULL OR TRIM(title) = &amp;#39;&amp;#39;);
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
  title,
  COUNT(*) AS occurrences
FROM urls
WHERE content_kind = &amp;#39;html&amp;#39;
  AND status_code = 200
  AND title IS NOT NULL
  AND TRIM(title) &amp;lt;&amp;gt; &amp;#39;&amp;#39;
GROUP BY title
HAVING COUNT(*) &amp;gt; 1
ORDER BY occurrences DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Missing titles come from a null-or-empty check restricted to successful HTML responses, so redirects and errors do not pollute the result. Duplicate titles come from grouping the same filtered set and keeping only the groups with more than one member. Neither needs an export step, and both can be rerun against the database a month later when someone asks whether the problem was fixed.&lt;/p&gt;
&lt;p&gt;The database is also readable while the crawler is still writing to it, which is how I worked out when to stop.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT COUNT(*) FROM crawl_queue;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Watching the HTML count sit still at 1,625 while thousands of image requests remained queued told me the page discovery was finished and the remaining work was resources I did not need.&lt;/p&gt;
&lt;h2&gt;Read the links table, not the convenience columns&lt;/h2&gt;
&lt;p&gt;FreeCrawl stores the discovered link graph in a &lt;code&gt;links&lt;/code&gt; table, which held 92,071 internal relationships in my crawl. The &lt;code&gt;urls&lt;/code&gt; table also has an &lt;code&gt;inlinks&lt;/code&gt; column, and mine was zero across the board, because that column is populated by an end-of-crawl aggregation step that never ran.&lt;/p&gt;
&lt;p&gt;If you interrupt a crawl, calculate from the raw graph instead.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
  to_url,
  COUNT(*) AS inlinks
FROM links
WHERE is_internal = 1
GROUP BY to_url
ORDER BY inlinks DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The more useful version joins that graph against response status, which gives you every broken page ranked by how many internal links still point at it. That is your redirect priority list for a migration.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
  l.to_url,
  COUNT(*) AS internal_links
FROM links AS l
JOIN urls AS u
  ON u.url = l.to_url
WHERE l.is_internal = 1
  AND u.status_code &amp;gt;= 400
GROUP BY l.to_url
ORDER BY internal_links DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A static export cannot answer that question without a script. A relational link graph answers it in six lines.&lt;/p&gt;
&lt;h2&gt;Stopping early changes what you get&lt;/h2&gt;
&lt;p&gt;I stopped the primary crawl deliberately, once HTML discovery had plateaued and the queue was mostly images.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kill -INT &amp;lt;PROCESS_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The process exited with code 130, which correctly signals an interrupt rather than a crash, and the SQLite database remained fully usable. Two things did not happen: the JSON export I requested with &lt;code&gt;--out&lt;/code&gt; was never written, and the aggregate fields like &lt;code&gt;inlinks&lt;/code&gt; were never populated.&lt;/p&gt;
&lt;p&gt;The crawl was still successful. The pages, metadata and link graph were all there. The missing export was a consequence of how I ended the process, not evidence of failure.&lt;/p&gt;
&lt;p&gt;Treat the SQLite database as the primary artifact and the JSON as a bonus you only trust when the process exits on its own.&lt;/p&gt;
&lt;p&gt;Exit codes need the same skepticism. During one JavaScript sample run, FreeCrawl returned exit code &lt;code&gt;1&lt;/code&gt; simply because the crawl had encountered HTTP error responses, and the database and summary were both complete. If you are wrapping this in automation, check whether the database exists, how many rows it holds and what the status summary says, rather than treating any non-zero exit as an unusable crawl.&lt;/p&gt;
&lt;h2&gt;Test JavaScript rendering on a sample&lt;/h2&gt;
&lt;p&gt;A browser-rendered crawl is far more expensive than a raw HTML one, so the question is not whether FreeCrawl can render JavaScript but whether your site needs it.&lt;/p&gt;
&lt;p&gt;Put a representative set of URLs in a plain text file at &lt;code&gt;output/example/js-sample-urls.txt&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;https://example.com/
https://example.com/products/example-product/
https://example.com/blog/example-article/
https://example.com/recipes/example-recipe/
https://example.com/category/example/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then a config file at &lt;code&gt;output/example/js-config.json&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;renderingMode&amp;quot;: &amp;quot;js&amp;quot;,
  &amp;quot;maxDepth&amp;quot;: 0,
  &amp;quot;maxUrls&amp;quot;: 250,
  &amp;quot;maxConcurrency&amp;quot;: 2,
  &amp;quot;maxRps&amp;quot;: 1,
  &amp;quot;respectRobotsTxt&amp;quot;: true,
  &amp;quot;crawlExternal&amp;quot;: false,
  &amp;quot;jsRender&amp;quot;: {
    &amp;quot;headless&amp;quot;: true
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;maxDepth&lt;/code&gt; of 0 is the important line. It tells FreeCrawl to render exactly the URLs in your list and not follow links out of them, which keeps an expensive rendering pass from turning into a second full crawl.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;node apps/cli/dist/index.js \
  --list output/example/js-sample-urls.txt \
  --config output/example/js-config.json \
  --exclude &amp;quot;^https?://(?!(www\.)?example\.com([:/]|$))&amp;quot; \
  --db output/example/js-sample.seoproject \
  --out output/example/js-sample.json \
  --json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I compared 39 URLs this way, covering the homepage, navigation pages, products, blog posts, recipes, taxonomy pages and anything likely to contain interactive elements. Chromium launched in true headless mode without any display server, and across all 39 pages the rendered crawl found no meaningful difference in titles, meta descriptions, H1s, canonicals, hreflang counts, structured data types, link counts or word counts.&lt;/p&gt;
&lt;p&gt;That is not a rendering failure. It is evidence that this particular WordPress site already served every migration-relevant field in its initial HTML, which meant a full JavaScript crawl would have cost hours and told me nothing new. Crawl raw HTML first, render a sample, compare, and only escalate if the sample shows you something missing.&lt;/p&gt;
&lt;h2&gt;What the crawl actually produced&lt;/h2&gt;
&lt;p&gt;For the record, here is what came out of the corrected production run.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;HTML pages&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;1,625&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200 responses&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;1,216&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;301 redirects&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;299&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;404 responses&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;110&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5xx responses&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexable pages&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;1,088&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-indexable pages&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;537&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Alongside that, it flagged 474 missing titles, 399 duplicate titles, 699 missing meta descriptions, 588 missing H1s, 691 pages with multiple H1s, 1,113 sitemap-only URLs and 607 crawled URLs absent from the sitemaps.&lt;/p&gt;
&lt;p&gt;Those numbers prove FreeCrawl can build a real inventory. They do not prove that every line is a defect. Archive pages, redirects, utility routes and deliberately non-indexable content can legitimately lack the metadata you would demand of a landing page. A crawler produces candidates, and the audit still needs a human deciding which ones matter.&lt;/p&gt;
&lt;p&gt;The other boundary is worth stating plainly, because it caught a colleague out on a previous project. A public crawl tells you which URLs are reachable, which are declared, how pages link to each other and what metadata renders. It cannot tell you which WordPress post type owns a URL, which custom fields produced the page, which drafts and private records exist, which page-builder components were used or which translations are incomplete. For a migration you need the crawl and a database or API export from the CMS. One is the external view, the other is the source of truth.&lt;/p&gt;
&lt;h2&gt;Running it on a server&lt;/h2&gt;
&lt;p&gt;Ubuntu Server was not part of this implementation, so treat the following as a path to test rather than a confirmed reproduction.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt-get update
sudo apt-get install -y curl git ca-certificates

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs

git clone https://github.com/kemalai/FreeCrawl-SEO-Tool.git
cd FreeCrawl-SEO-Tool

npm install
sudo npx playwright install-deps chromium

npx tsc -b
npm run build:cli

node apps/cli/dist/index.js --help
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The one addition over the macOS sequence is &lt;code&gt;playwright install-deps chromium&lt;/code&gt;, which pulls the system libraries Chromium needs on Linux. Because Playwright Chromium supports genuinely headless execution, this should not require GNOME, VNC or any visible browser session. On a shared machine I would also run it under a dedicated non-root user, keep it well away from application repositories, restrict outbound network access and preserve crawl databases somewhere that is not a temporary directory.&lt;/p&gt;
&lt;h2&gt;What I would tell you before you start&lt;/h2&gt;
&lt;p&gt;FreeCrawl is genuinely usable as a free Screaming Frog alternative, provided you are the kind of person who is comfortable with a terminal, a regular expression, a SQL query and occasionally reading source code when the documentation runs out. The absence of a URL limit and the presence of a queryable database made it a better fit for a developer-led migration than a graphical application would have been.&lt;/p&gt;
&lt;p&gt;Three things will bite you, and all three are avoidable now that you know about them. The CLI build fails from a clean clone unless you run &lt;code&gt;npx tsc -b&lt;/code&gt; first. External domains get status-checked unless you exclude them explicitly on every command. And an interrupted crawl silently skips the JSON export and the aggregate columns, so the SQLite file is the artifact you should trust.&lt;/p&gt;
&lt;p&gt;Use Screaming Frog when you want a mature, polished application and an interface that a non-technical colleague can operate. Use FreeCrawl when you want a crawler you can automate, inspect and query as part of a technical workflow. For the migration inventory I needed, the second was worth more than the first.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/free-screaming-frog-alternative-freecrawl-guide"/>
        </item>
        <item>
            <title><![CDATA[Software Demo: 6 Layers to Prove Your System Works]]></title>
            <description><![CDATA[<p>A credible software demo shows enough of the system to explain how the visible customer experience gets created, controlled, operated, measured, and eventually moved into production. That means going past a polished frontend into the workflow behind an AI assistant, the data records powering a search result, the admin tools an internal team will use daily, the analytics that tell you whether the product is working, and the architecture decisions that make production possible. Building this doesn&#39;t require a finished product before a contract exists — it requires choosing a vertical slice through the important layers of the system rather than stopping at the screen.</p>
<p>I ran into this recently while presenting for a regulated financial-services client. An earlier session had covered the customer-facing concept alone. For the follow-up, we expanded the demonstration to include the revised user journey, an AI guide, the automation workflow running behind it, the underlying vehicle data, behavioural analytics, partner administration, the internal CMS, and documentation covering architecture, hosting, and security. The reaction was noticeably stronger than the first session — not just because the visuals had improved, but because the client could see how the platform might actually operate once it existed.</p>
<h2>Why a polished frontend is only proof of design intent</h2>
<p>The frontend is the natural starting point for any demo. It&#39;s where the customer, employee, or partner experiences the product, and it can show where users begin, how they navigate, which information gets emphasis, and how a multi-step journey behaves across its stages.</p>
<p>A working frontend prototype demonstrates real things: where users start, how they search, which choices get simplified, how different audiences enter the platform, and how the experience might expand later. Many software problems are interface problems at heart, and a confusing journey stays confusing no matter how clean the architecture behind it is.</p>
<p>That evidence covers one layer. A screen showing a monthly payment doesn&#39;t explain where the calculation comes from. An AI assistant recommending a product doesn&#39;t reveal whether it&#39;s pulling from trusted data or generating a plausible guess. A partner dashboard doesn&#39;t show whether partners are properly isolated from each other. A content-management screen doesn&#39;t identify which system actually owns the underlying information. Answering those questions is the job of the layers behind the interface.</p>
<p>This connects to a distinction I&#39;ve written about before: a prototype <a href="/blog/cms-prototype-vs-implementation-8-steps-avoid-rework">contributes to an implementation specification</a> — it doesn&#39;t stand in for one. A sales demo doesn&#39;t need the full specification yet. It needs enough evidence that a plausible implementation sits behind the interface.</p>
<h2>Layer 1: Anchor the demo in one customer journey</h2>
<p>Start the demo with a realistic scenario rather than a set of unrelated screens.</p>
<p>Instead of demonstrating a generic vehicle marketplace, follow one customer: someone who needs a second family vehicle, has a maximum budget, owns a vehicle they could trade in, is considering an electric model, has questions about battery life and service availability, and wants to understand the monthly cost.</p>
<p>Every part of the demo now has a reason to exist. The search experience addresses the budget. The AI guide addresses the uncertainty about batteries and service. The vehicle detail page covers product information. The calculator addresses affordability. The application step covers the commercial outcome.</p>
<p>A connected scenario like this gives stakeholders a real basis for judging whether the platform solves a problem, and it exposes gaps that a set of individually polished screens can hide. Before moving behind the interface, the journey should answer five questions: what the person is trying to accomplish, what the platform needs to learn from them, how it helps them decide, where the journey leads, and what happens after the final call to action.</p>
<h2>Layer 2: Show the business logic behind the interaction</h2>
<p>The next layer is the logic controlling what the customer sees, and this matters most where the interface includes AI.</p>
<p>An AI chat window is simple to add to a prototype and easy to misjudge. The questions worth answering aren&#39;t about how fluently the assistant writes — they&#39;re about what it can access, which actions it can perform, how it queries the platform, which calculations stay deterministic, which rules constrain its recommendations, what gets logged, and where a human or a controlled system takes over when the model is uncertain.</p>
<p>In our demo, the frontend AI guide connected to an automation workflow with identifiable steps: interpret the request, determine what information is missing, ask a follow-up question, convert the answer into structured values, query trusted platform data, apply constraints, assemble the response, record the interaction, and trigger the next action when appropriate.</p>
<p>The customer experienced a natural conversation. Showing the steps behind it reframed the demo from &quot;the chatbot can answer questions&quot; into a working example of natural language acting as another interface into controlled platform services — which is the distinction that matters in any serious AI project. Product data, pricing rules, permissions, financial calculations, and business actions should live in systems that can be validated and audited, with the language model handling interpretation and conversation on top of them. A useful demo makes that boundary visible.</p>
<h2>Layer 3: Show where the data actually lives</h2>
<p>Once the business logic is visible, show the data feeding it. This doesn&#39;t require the full database schema — it requires proving that important information has a defined home.</p>
<p>Our vehicle records carried structured fields: make and model, price, powertrain, battery capacity, estimated range, battery condition, financing attributes, partner ownership, and publication status. When the AI guide answered a question about a specific vehicle, we opened the admin interface and pointed to the corresponding stored value. That small step turns an answer that sounds convincing into one that&#39;s traceable back to a record someone can inspect.</p>
<p>The same principle travels outside automotive platforms. A healthcare site recommending content by health goal should be able to show where the approved health-goal relationships live. A manufacturing portal displaying specifications should show whether they come from the CMS, ERP, PIM, or elsewhere. A customer dashboard displaying account status should show which operational system owns that state. This is also the reasoning behind <a href="/blog/cms-architecture-define-what-cms-must-not-own">deciding upfront what the CMS must not own</a> — a flexible CMS can store almost anything, which is exactly why its scope needs a boundary. The demo doesn&#39;t need to settle every ownership question, but it should show the boundaries have been considered.</p>
<h2>Layer 4: Show how the platform gets operated</h2>
<p>Most software demos focus entirely on the customer, and the platform still needs someone to run it after launch — internal editors, account managers, marketplace partners, administrators, support staff, compliance reviewers, product managers, analysts.</p>
<p>Customers browsing listings is only part of a marketplace; someone still has to create, import, review, update, suspend, and remove those listings. An AI assistant answering questions is only part of the product; someone has to maintain its information, review failed interactions, and update its rules. Content rendering on a page is only part of a content platform; someone has to draft, approve, publish, translate, and retire it.</p>
<p>In our demo, we showed two separate admin experiences: a tailored partner workspace where a partner could sign in, see its own records, manage listings, and review its inquiries; and a broader platform admin environment where internal users managed content, vehicles, partners, users, roles, publishing states, and structured data. The separation communicates a design decision worth stating directly: different users get the interface suited to their responsibilities, even when both interfaces sit on the same platform. A partner doesn&#39;t need the full technical CMS. An editor doesn&#39;t need infrastructure controls. A platform administrator may need access across several domains at once.</p>
<p>Showing these operational views tends to unlock better implementation conversations — stakeholders start asking who approves a new partner, whether a dealer can publish directly, which changes require review, whether one partner can see another&#39;s leads, what happens when a listing expires, and which fields need an audit trail. Surfacing these production questions early, during the demo, is far cheaper than surfacing them during delivery.</p>
<h2>Layer 5: Show how the team will measure behaviour after launch</h2>
<p>A platform shouldn&#39;t go dark once it&#39;s live. The team needs visibility into where users arrive, which journeys they choose, where they hesitate, what they search for, which features they use, where they abandon the process, and which interactions actually produce qualified inquiries.</p>
<p>Traditional web analytics covers acquisition sources, campaigns, and broad conversion numbers — useful for marketing, but only part of the picture. Product analytics tools such as PostHog add funnels, interaction events, feature adoption, session recordings, and the friction points that repeat across users.</p>
<p>In our demo, we replayed the session that had just happened. Stakeholders watched the journey, the scrolling, and the interaction with the AI guide play back in front of them. That&#39;s a stronger claim than promising &quot;everything can be tracked&quot; — it&#39;s showing exactly what a product team would be able to inspect after launch, tied to specific questions worth answering: do users prefer classic or guided search, which financing concepts create confusion, which products get attention without inquiries, and where do users leave the application journey.</p>
<h2>Layer 6: Show the architecture and the operational controls behind it</h2>
<p>The final layer explains how the demo becomes a production system, and it doesn&#39;t need to dominate the meeting — most stakeholders don&#39;t want thirty minutes on hosting topology and deployment pipelines. The information still needs to exist and be easy to reach.</p>
<p>We built a self-service documentation area covering the proposed technology stack, application architecture, CMS selection, hosting options, environments, authentication, permissions, integrations, security controls, secret management, backups, deployment, operational responsibilities, open decisions, and implementation risks. The goal wasn&#39;t to claim every architectural decision was final — it was to show the proposed solution had been thought through beyond the browser window.</p>
<p>This is where the demo connects naturally into discovery. Some decisions can be proposed during a demo; others depend on information only the client&#39;s technical, security, compliance, and operational teams can supply. The useful response to that gap is documenting the current assumption, its impact, and what needs validating before implementation — which is also why I generally recommend teams <a href="/blog/architecture-first-website-rebuilds">start with architecture rather than mockups alone</a> on complex platform work. The interface and the architecture should inform each other, and structural decisions shouldn&#39;t get made by accident while developers are already building production features.</p>
<p>A self-service documentation environment gives every stakeholder group what they need from the same material: business stakeholders can stay focused on the journey, technical stakeholders can inspect the proposed stack, security teams can review controls and open questions, and future implementation partners can see the decisions already made.</p>
<h2>Naming what&#39;s real and what&#39;s simulated</h2>
<p>A demo where every button works, every response succeeds, every integration appears available, and every field is populated looks finished. It can also create false certainty about how much of the system actually exists.</p>
<p>A more credible demo separates what&#39;s already working from what&#39;s simulated, what uses synthetic data, what depends on an external integration still to be confirmed, what&#39;s a recommended direction versus a client-confirmed decision, and what still needs discovery or belongs in a later phase. An AI guide can be genuinely working while pulling from demonstration vehicle records. A trade-in valuation step can be interactive while the production version depends on an external provider not yet selected. A financing calculation can respond live while its final formula and regulatory wording still need approval. Stating these boundaries clearly makes the demo more trustworthy, not less — the goal is proving the proposed experience connects to a credible system and that the team knows what&#39;s still unresolved, not creating the illusion that the project is already finished.</p>
<h2>A demo sets direction; discovery turns it into a contract</h2>
<p>Showing more of the system doesn&#39;t mean finishing the architecture, specification, and implementation before the project starts. A demo proves capability and direction. Discovery turns that direction into an agreed delivery contract — confirming V1 scope, user journeys, system boundaries, integrations, data ownership, hosting, security requirements, roles and permissions, operational workflows, analytics, migration, testing, acceptance criteria, and later phases.</p>
<p>Getting the depth right here matters on both ends. A shallow demo leaves the client unsure whether the provider understands the full system. An oversized demo quietly does the discovery work for free before any contract exists. The right level sits between the two: enough depth to demonstrate connected thinking, followed by a paid process that resolves what implementation actually requires.</p>
<h2>What each layer proves</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>What it demonstrates</th>
</tr>
</thead>
<tbody><tr>
<td>Customer journey</td>
<td>The team understands the user&#39;s real problem and decision path</td>
</tr>
<tr>
<td>Business logic / workflow</td>
<td>Visible interactions connect to controlled, auditable logic rather than an unconstrained model</td>
</tr>
<tr>
<td>Data model</td>
<td>Important information has a structured, traceable home</td>
</tr>
<tr>
<td>Operational interfaces</td>
<td>The platform can actually be run day to day, by the right people</td>
</tr>
<tr>
<td>Analytics</td>
<td>Behaviour can be measured and the product can improve after launch</td>
</tr>
<tr>
<td>Architecture documentation</td>
<td>Production constraints are already part of the thinking, not an afterthought</td>
</tr>
</tbody></table>
<p>Together these layers create a far stronger signal than a sequence of polished screens, though they still leave real questions open — they don&#39;t eliminate delivery risk, settle every integration, replace a security review, or confirm that the buyer and provider are a good commercial match. Those belong to the broader question of <a href="/blog/procurement-fit-avoid-bad-projects-guide">procurement and delivery fit</a>. What this kind of demo does reliably is shift the conversation: stakeholders stop debating button sizes and start discussing data ownership, partner permissions, integration constraints, measurement, security, and the commercial path forward.</p>
<h2>FAQ</h2>
<p><strong>Does a demo like this require production-grade infrastructure?</strong>
No. It needs a vertical slice through the important layers — journey, logic, data, operations, analytics, architecture — using demonstration data and a documented set of assumptions, not a finished, scaled system.</p>
<p><strong>How much of the demo should focus on AI specifically?</strong>
Enough to show the workflow behind it: what the assistant can access, which actions it triggers, which calculations stay deterministic, and where a human or controlled system takes over. Fluent conversation alone isn&#39;t the evidence stakeholders need.</p>
<p><strong>Isn&#39;t showing this much of the system just doing discovery for free?</strong>
Only if the demo goes too far. The right scope demonstrates connected thinking and surfaces the right questions; it stops short of resolving scope, integrations, and acceptance criteria, which is what a paid discovery phase is for.</p>
<p><strong>What&#39;s the risk of demoing only the frontend?</strong>
Stakeholders can mistake interface polish for system readiness. A confusing or fragile system can sit behind a clean screen, and a demo that never goes past the screen won&#39;t surface that.</p>
<p><strong>Should every demo include admin and operational views?</strong>
For any platform someone will need to run after launch — which is most of them — yes. Skipping this layer tends to move basic operational questions (who approves what, who can see what) from the demo into delivery, where they&#39;re more expensive to answer.</p>
<h2>Bringing it together</h2>
<p>A credible software demo runs through six connected layers: the customer journey, the business logic behind it, the data it depends on, the interfaces that keep it running, the analytics that measure it, and the architecture that lets it go to production. None of this requires building the whole system before a contract exists — it requires choosing a slice that shows the thinking behind the interface, not just the interface itself. That&#39;s the difference between a demo that shows what a platform might look like and one that shows how it could actually work.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/software-demo-6-layers-prove-system-works</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/software-demo-6-layers-prove-system-works</guid>
            <category><![CDATA[AI]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Wed, 29 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;A credible software demo shows enough of the system to explain how the visible customer experience gets created, controlled, operated, measured, and eventually moved into production. That means going past a polished frontend into the workflow behind an AI assistant, the data records powering a search result, the admin tools an internal team will use daily, the analytics that tell you whether the product is working, and the architecture decisions that make production possible. Building this doesn&amp;#39;t require a finished product before a contract exists — it requires choosing a vertical slice through the important layers of the system rather than stopping at the screen.&lt;/p&gt;
&lt;p&gt;I ran into this recently while presenting for a regulated financial-services client. An earlier session had covered the customer-facing concept alone. For the follow-up, we expanded the demonstration to include the revised user journey, an AI guide, the automation workflow running behind it, the underlying vehicle data, behavioural analytics, partner administration, the internal CMS, and documentation covering architecture, hosting, and security. The reaction was noticeably stronger than the first session — not just because the visuals had improved, but because the client could see how the platform might actually operate once it existed.&lt;/p&gt;
&lt;h2&gt;Why a polished frontend is only proof of design intent&lt;/h2&gt;
&lt;p&gt;The frontend is the natural starting point for any demo. It&amp;#39;s where the customer, employee, or partner experiences the product, and it can show where users begin, how they navigate, which information gets emphasis, and how a multi-step journey behaves across its stages.&lt;/p&gt;
&lt;p&gt;A working frontend prototype demonstrates real things: where users start, how they search, which choices get simplified, how different audiences enter the platform, and how the experience might expand later. Many software problems are interface problems at heart, and a confusing journey stays confusing no matter how clean the architecture behind it is.&lt;/p&gt;
&lt;p&gt;That evidence covers one layer. A screen showing a monthly payment doesn&amp;#39;t explain where the calculation comes from. An AI assistant recommending a product doesn&amp;#39;t reveal whether it&amp;#39;s pulling from trusted data or generating a plausible guess. A partner dashboard doesn&amp;#39;t show whether partners are properly isolated from each other. A content-management screen doesn&amp;#39;t identify which system actually owns the underlying information. Answering those questions is the job of the layers behind the interface.&lt;/p&gt;
&lt;p&gt;This connects to a distinction I&amp;#39;ve written about before: a prototype &lt;a href=&quot;/blog/cms-prototype-vs-implementation-8-steps-avoid-rework&quot;&gt;contributes to an implementation specification&lt;/a&gt; — it doesn&amp;#39;t stand in for one. A sales demo doesn&amp;#39;t need the full specification yet. It needs enough evidence that a plausible implementation sits behind the interface.&lt;/p&gt;
&lt;h2&gt;Layer 1: Anchor the demo in one customer journey&lt;/h2&gt;
&lt;p&gt;Start the demo with a realistic scenario rather than a set of unrelated screens.&lt;/p&gt;
&lt;p&gt;Instead of demonstrating a generic vehicle marketplace, follow one customer: someone who needs a second family vehicle, has a maximum budget, owns a vehicle they could trade in, is considering an electric model, has questions about battery life and service availability, and wants to understand the monthly cost.&lt;/p&gt;
&lt;p&gt;Every part of the demo now has a reason to exist. The search experience addresses the budget. The AI guide addresses the uncertainty about batteries and service. The vehicle detail page covers product information. The calculator addresses affordability. The application step covers the commercial outcome.&lt;/p&gt;
&lt;p&gt;A connected scenario like this gives stakeholders a real basis for judging whether the platform solves a problem, and it exposes gaps that a set of individually polished screens can hide. Before moving behind the interface, the journey should answer five questions: what the person is trying to accomplish, what the platform needs to learn from them, how it helps them decide, where the journey leads, and what happens after the final call to action.&lt;/p&gt;
&lt;h2&gt;Layer 2: Show the business logic behind the interaction&lt;/h2&gt;
&lt;p&gt;The next layer is the logic controlling what the customer sees, and this matters most where the interface includes AI.&lt;/p&gt;
&lt;p&gt;An AI chat window is simple to add to a prototype and easy to misjudge. The questions worth answering aren&amp;#39;t about how fluently the assistant writes — they&amp;#39;re about what it can access, which actions it can perform, how it queries the platform, which calculations stay deterministic, which rules constrain its recommendations, what gets logged, and where a human or a controlled system takes over when the model is uncertain.&lt;/p&gt;
&lt;p&gt;In our demo, the frontend AI guide connected to an automation workflow with identifiable steps: interpret the request, determine what information is missing, ask a follow-up question, convert the answer into structured values, query trusted platform data, apply constraints, assemble the response, record the interaction, and trigger the next action when appropriate.&lt;/p&gt;
&lt;p&gt;The customer experienced a natural conversation. Showing the steps behind it reframed the demo from &amp;quot;the chatbot can answer questions&amp;quot; into a working example of natural language acting as another interface into controlled platform services — which is the distinction that matters in any serious AI project. Product data, pricing rules, permissions, financial calculations, and business actions should live in systems that can be validated and audited, with the language model handling interpretation and conversation on top of them. A useful demo makes that boundary visible.&lt;/p&gt;
&lt;h2&gt;Layer 3: Show where the data actually lives&lt;/h2&gt;
&lt;p&gt;Once the business logic is visible, show the data feeding it. This doesn&amp;#39;t require the full database schema — it requires proving that important information has a defined home.&lt;/p&gt;
&lt;p&gt;Our vehicle records carried structured fields: make and model, price, powertrain, battery capacity, estimated range, battery condition, financing attributes, partner ownership, and publication status. When the AI guide answered a question about a specific vehicle, we opened the admin interface and pointed to the corresponding stored value. That small step turns an answer that sounds convincing into one that&amp;#39;s traceable back to a record someone can inspect.&lt;/p&gt;
&lt;p&gt;The same principle travels outside automotive platforms. A healthcare site recommending content by health goal should be able to show where the approved health-goal relationships live. A manufacturing portal displaying specifications should show whether they come from the CMS, ERP, PIM, or elsewhere. A customer dashboard displaying account status should show which operational system owns that state. This is also the reasoning behind &lt;a href=&quot;/blog/cms-architecture-define-what-cms-must-not-own&quot;&gt;deciding upfront what the CMS must not own&lt;/a&gt; — a flexible CMS can store almost anything, which is exactly why its scope needs a boundary. The demo doesn&amp;#39;t need to settle every ownership question, but it should show the boundaries have been considered.&lt;/p&gt;
&lt;h2&gt;Layer 4: Show how the platform gets operated&lt;/h2&gt;
&lt;p&gt;Most software demos focus entirely on the customer, and the platform still needs someone to run it after launch — internal editors, account managers, marketplace partners, administrators, support staff, compliance reviewers, product managers, analysts.&lt;/p&gt;
&lt;p&gt;Customers browsing listings is only part of a marketplace; someone still has to create, import, review, update, suspend, and remove those listings. An AI assistant answering questions is only part of the product; someone has to maintain its information, review failed interactions, and update its rules. Content rendering on a page is only part of a content platform; someone has to draft, approve, publish, translate, and retire it.&lt;/p&gt;
&lt;p&gt;In our demo, we showed two separate admin experiences: a tailored partner workspace where a partner could sign in, see its own records, manage listings, and review its inquiries; and a broader platform admin environment where internal users managed content, vehicles, partners, users, roles, publishing states, and structured data. The separation communicates a design decision worth stating directly: different users get the interface suited to their responsibilities, even when both interfaces sit on the same platform. A partner doesn&amp;#39;t need the full technical CMS. An editor doesn&amp;#39;t need infrastructure controls. A platform administrator may need access across several domains at once.&lt;/p&gt;
&lt;p&gt;Showing these operational views tends to unlock better implementation conversations — stakeholders start asking who approves a new partner, whether a dealer can publish directly, which changes require review, whether one partner can see another&amp;#39;s leads, what happens when a listing expires, and which fields need an audit trail. Surfacing these production questions early, during the demo, is far cheaper than surfacing them during delivery.&lt;/p&gt;
&lt;h2&gt;Layer 5: Show how the team will measure behaviour after launch&lt;/h2&gt;
&lt;p&gt;A platform shouldn&amp;#39;t go dark once it&amp;#39;s live. The team needs visibility into where users arrive, which journeys they choose, where they hesitate, what they search for, which features they use, where they abandon the process, and which interactions actually produce qualified inquiries.&lt;/p&gt;
&lt;p&gt;Traditional web analytics covers acquisition sources, campaigns, and broad conversion numbers — useful for marketing, but only part of the picture. Product analytics tools such as PostHog add funnels, interaction events, feature adoption, session recordings, and the friction points that repeat across users.&lt;/p&gt;
&lt;p&gt;In our demo, we replayed the session that had just happened. Stakeholders watched the journey, the scrolling, and the interaction with the AI guide play back in front of them. That&amp;#39;s a stronger claim than promising &amp;quot;everything can be tracked&amp;quot; — it&amp;#39;s showing exactly what a product team would be able to inspect after launch, tied to specific questions worth answering: do users prefer classic or guided search, which financing concepts create confusion, which products get attention without inquiries, and where do users leave the application journey.&lt;/p&gt;
&lt;h2&gt;Layer 6: Show the architecture and the operational controls behind it&lt;/h2&gt;
&lt;p&gt;The final layer explains how the demo becomes a production system, and it doesn&amp;#39;t need to dominate the meeting — most stakeholders don&amp;#39;t want thirty minutes on hosting topology and deployment pipelines. The information still needs to exist and be easy to reach.&lt;/p&gt;
&lt;p&gt;We built a self-service documentation area covering the proposed technology stack, application architecture, CMS selection, hosting options, environments, authentication, permissions, integrations, security controls, secret management, backups, deployment, operational responsibilities, open decisions, and implementation risks. The goal wasn&amp;#39;t to claim every architectural decision was final — it was to show the proposed solution had been thought through beyond the browser window.&lt;/p&gt;
&lt;p&gt;This is where the demo connects naturally into discovery. Some decisions can be proposed during a demo; others depend on information only the client&amp;#39;s technical, security, compliance, and operational teams can supply. The useful response to that gap is documenting the current assumption, its impact, and what needs validating before implementation — which is also why I generally recommend teams &lt;a href=&quot;/blog/architecture-first-website-rebuilds&quot;&gt;start with architecture rather than mockups alone&lt;/a&gt; on complex platform work. The interface and the architecture should inform each other, and structural decisions shouldn&amp;#39;t get made by accident while developers are already building production features.&lt;/p&gt;
&lt;p&gt;A self-service documentation environment gives every stakeholder group what they need from the same material: business stakeholders can stay focused on the journey, technical stakeholders can inspect the proposed stack, security teams can review controls and open questions, and future implementation partners can see the decisions already made.&lt;/p&gt;
&lt;h2&gt;Naming what&amp;#39;s real and what&amp;#39;s simulated&lt;/h2&gt;
&lt;p&gt;A demo where every button works, every response succeeds, every integration appears available, and every field is populated looks finished. It can also create false certainty about how much of the system actually exists.&lt;/p&gt;
&lt;p&gt;A more credible demo separates what&amp;#39;s already working from what&amp;#39;s simulated, what uses synthetic data, what depends on an external integration still to be confirmed, what&amp;#39;s a recommended direction versus a client-confirmed decision, and what still needs discovery or belongs in a later phase. An AI guide can be genuinely working while pulling from demonstration vehicle records. A trade-in valuation step can be interactive while the production version depends on an external provider not yet selected. A financing calculation can respond live while its final formula and regulatory wording still need approval. Stating these boundaries clearly makes the demo more trustworthy, not less — the goal is proving the proposed experience connects to a credible system and that the team knows what&amp;#39;s still unresolved, not creating the illusion that the project is already finished.&lt;/p&gt;
&lt;h2&gt;A demo sets direction; discovery turns it into a contract&lt;/h2&gt;
&lt;p&gt;Showing more of the system doesn&amp;#39;t mean finishing the architecture, specification, and implementation before the project starts. A demo proves capability and direction. Discovery turns that direction into an agreed delivery contract — confirming V1 scope, user journeys, system boundaries, integrations, data ownership, hosting, security requirements, roles and permissions, operational workflows, analytics, migration, testing, acceptance criteria, and later phases.&lt;/p&gt;
&lt;p&gt;Getting the depth right here matters on both ends. A shallow demo leaves the client unsure whether the provider understands the full system. An oversized demo quietly does the discovery work for free before any contract exists. The right level sits between the two: enough depth to demonstrate connected thinking, followed by a paid process that resolves what implementation actually requires.&lt;/p&gt;
&lt;h2&gt;What each layer proves&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it demonstrates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Customer journey&lt;/td&gt;
&lt;td&gt;The team understands the user&amp;#39;s real problem and decision path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business logic / workflow&lt;/td&gt;
&lt;td&gt;Visible interactions connect to controlled, auditable logic rather than an unconstrained model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data model&lt;/td&gt;
&lt;td&gt;Important information has a structured, traceable home&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational interfaces&lt;/td&gt;
&lt;td&gt;The platform can actually be run day to day, by the right people&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;Behaviour can be measured and the product can improve after launch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Architecture documentation&lt;/td&gt;
&lt;td&gt;Production constraints are already part of the thinking, not an afterthought&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Together these layers create a far stronger signal than a sequence of polished screens, though they still leave real questions open — they don&amp;#39;t eliminate delivery risk, settle every integration, replace a security review, or confirm that the buyer and provider are a good commercial match. Those belong to the broader question of &lt;a href=&quot;/blog/procurement-fit-avoid-bad-projects-guide&quot;&gt;procurement and delivery fit&lt;/a&gt;. What this kind of demo does reliably is shift the conversation: stakeholders stop debating button sizes and start discussing data ownership, partner permissions, integration constraints, measurement, security, and the commercial path forward.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Does a demo like this require production-grade infrastructure?&lt;/strong&gt;
No. It needs a vertical slice through the important layers — journey, logic, data, operations, analytics, architecture — using demonstration data and a documented set of assumptions, not a finished, scaled system.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How much of the demo should focus on AI specifically?&lt;/strong&gt;
Enough to show the workflow behind it: what the assistant can access, which actions it triggers, which calculations stay deterministic, and where a human or controlled system takes over. Fluent conversation alone isn&amp;#39;t the evidence stakeholders need.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Isn&amp;#39;t showing this much of the system just doing discovery for free?&lt;/strong&gt;
Only if the demo goes too far. The right scope demonstrates connected thinking and surfaces the right questions; it stops short of resolving scope, integrations, and acceptance criteria, which is what a paid discovery phase is for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What&amp;#39;s the risk of demoing only the frontend?&lt;/strong&gt;
Stakeholders can mistake interface polish for system readiness. A confusing or fragile system can sit behind a clean screen, and a demo that never goes past the screen won&amp;#39;t surface that.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Should every demo include admin and operational views?&lt;/strong&gt;
For any platform someone will need to run after launch — which is most of them — yes. Skipping this layer tends to move basic operational questions (who approves what, who can see what) from the demo into delivery, where they&amp;#39;re more expensive to answer.&lt;/p&gt;
&lt;h2&gt;Bringing it together&lt;/h2&gt;
&lt;p&gt;A credible software demo runs through six connected layers: the customer journey, the business logic behind it, the data it depends on, the interfaces that keep it running, the analytics that measure it, and the architecture that lets it go to production. None of this requires building the whole system before a contract exists — it requires choosing a slice that shows the thinking behind the interface, not just the interface itself. That&amp;#39;s the difference between a demo that shows what a platform might look like and one that shows how it could actually work.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/software-demo-6-layers-prove-system-works"/>
        </item>
        <item>
            <title><![CDATA[Payload CMS Next.js: Build One Dynamic page.tsx Route]]></title>
            <description><![CDATA[<p>A Payload CMS site rendered through Next.js can serve most of its editorial pages — home, about, contact, services, campaigns, microsites, nested marketing pages — from one route: <code>app/[locale]/[[...slug]]/page.tsx</code>. The route resolves a tenant, locale, and slug into a single Payload document, and a block renderer turns that document&#39;s stored <code>layout</code> array into the page. Presentation lives entirely in the blocks attached to the document, so a growing site doesn&#39;t need a new template file every time an editor wants a new page shape. Below I&#39;ll walk through the Pages collection, the block renderer, the catch-all route, and how this holds up across a multi-brand platform — plus where you still want a dedicated route instead.</p>
<p>I&#39;ve built this pattern most recently on a multi-brand rebuild covering three separate consumer sites sharing one Payload instance. Each brand needed its own theme, navigation, and content, while editors across all three needed the same set of approved blocks to build pages without opening a pull request. That constraint is what pushed the architecture toward one shared route with tenant-aware queries, rather than a route per brand or a template per page type.</p>
<h2>The habit this breaks</h2>
<p>Developers arriving from WordPress, Drupal, or Craft CMS usually look for a template file per page type: <code>page-about.php</code>, <code>page-contact.php</code>, <code>single-product.php</code>. Developers already comfortable with Next.js often reach for the same instinct in App Router form: a folder per page, <code>app/about/page.tsx</code>, <code>app/contact/page.tsx</code>, <code>app/services/page.tsx</code>.</p>
<p>That instinct comes from CMS platforms where the CMS itself participates in template selection. A page record carries a template value — Default Page, Landing Page, Campaign Page — and the CMS picks a matching file out of the active theme. The rendering path runs from URL to router to page record to selected template to rendered HTML, with the CMS deciding which theme file executes.</p>
<p>Payload works from a different starting point. It&#39;s a code-first CMS installed directly into your Next.js application, and it stores structured content rather than owning a template layer. Routing and rendering stay in your Next.js code; Payload&#39;s job is to hand that code the data and configuration an editor set up.</p>
<h2>The Pages collection</h2>
<p>Here&#39;s a simplified version of the collection that backs the shared route:</p>
<pre><code class="language-ts">// File: src/collections/Pages/config.ts
import type { CollectionConfig } from &#39;payload&#39;

import { HeroBlock } from &#39;@/blocks/Hero/config&#39;
import { TextMediaBlock } from &#39;@/blocks/TextMedia/config&#39;
import { CardsBlock } from &#39;@/blocks/Cards/config&#39;
import { CallToActionBlock } from &#39;@/blocks/CallToAction/config&#39;
import { FAQBlock } from &#39;@/blocks/FAQ/config&#39;

export const Pages: CollectionConfig = {
  slug: &#39;pages&#39;,

  versions: {
    drafts: true,
  },

  admin: {
    useAsTitle: &#39;title&#39;,
  },

  fields: [
    {
      name: &#39;title&#39;,
      type: &#39;text&#39;,
      required: true,
      localized: true,
    },
    {
      name: &#39;slug&#39;,
      type: &#39;text&#39;,
      required: true,
      index: true,
    },
    {
      name: &#39;layout&#39;,
      type: &#39;blocks&#39;,
      localized: true,
      blocks: [
        HeroBlock,
        TextMediaBlock,
        CardsBlock,
        CallToActionBlock,
        FAQBlock,
      ],
    },
  ],
}
</code></pre>
<p>The field that matters here is <code>layout</code>. It&#39;s a <code>blocks</code> field, which means each page document stores an ordered array of block entries, and editors choose which blocks appear and in what order. Payload tags each entry with a <code>blockType</code> value matching the block&#39;s <code>slug</code>, which the frontend later uses to pick a renderer.</p>
<p>A Hero block definition looks like this:</p>
<pre><code class="language-ts">// File: src/blocks/Hero/config.ts
import type { Block } from &#39;payload&#39;

export const HeroBlock: Block = {
  slug: &#39;hero&#39;,
  interfaceName: &#39;HeroBlock&#39;,

  fields: [
    {
      name: &#39;heading&#39;,
      type: &#39;text&#39;,
      required: true,
      localized: true,
    },
    {
      name: &#39;description&#39;,
      type: &#39;textarea&#39;,
      localized: true,
    },
    {
      name: &#39;image&#39;,
      type: &#39;upload&#39;,
      relationTo: &#39;media&#39;,
    },
    {
      name: &#39;actions&#39;,
      type: &#39;array&#39;,
      maxRows: 2,
      fields: [
        {
          name: &#39;label&#39;,
          type: &#39;text&#39;,
          required: true,
          localized: true,
        },
        {
          name: &#39;href&#39;,
          type: &#39;text&#39;,
          required: true,
        },
      ],
    },
  ],
}
</code></pre>
<p>This config is the contract an editor sees in the admin UI: heading, description, an image upload, and up to two actions. Every field here becomes a piece of data the frontend can rely on being present in a predictable shape. The React implementation behind this contract is a separate concern, which is what the renderer below handles.</p>
<h2>The block renderer</h2>
<p>A page document&#39;s <code>layout</code> array needs one thing to turn into HTML: a lookup from <code>blockType</code> to a React component. That lookup is the entire job of the renderer.</p>
<pre><code class="language-tsx">// File: src/blocks/RenderBlocks.tsx
import { HeroBlock } from &#39;@/blocks/Hero/Component&#39;
import { TextMediaBlock } from &#39;@/blocks/TextMedia/Component&#39;
import { CardsBlock } from &#39;@/blocks/Cards/Component&#39;
import { CallToActionBlock } from &#39;@/blocks/CallToAction/Component&#39;
import { FAQBlock } from &#39;@/blocks/FAQ/Component&#39;

const blockRenderers = {
  hero: HeroBlock,
  textMedia: TextMediaBlock,
  cards: CardsBlock,
  callToAction: CallToActionBlock,
  faq: FAQBlock,
}

type RenderBlocksProps = {
  blocks: Array&lt;{
    id?: string | null
    blockType: string
    [key: string]: unknown
  }&gt;
  site: {
    id: string
    slug: string
  }
}

export function RenderBlocks({ blocks, site }: RenderBlocksProps) {
  if (!blocks?.length) {
    return null
  }

  return blocks.map((block, index) =&gt; {
    const Renderer =
      blockRenderers[block.blockType as keyof typeof blockRenderers]

    if (!Renderer) {
      console.warn(`No renderer registered for block: ${block.blockType}`)
      return null
    }

    return (
      &lt;Renderer
        key={block.id ?? `${block.blockType}-${index}`}
        {...block}
        site={site}
      /&gt;
    )
  })
}
</code></pre>
<p>Add a new block type in the future, and this is the one file you touch to register it. The route calling <code>RenderBlocks</code> never needs an update, because it only ever passes through whatever <code>layout</code> array the page document contains.</p>
<h2>The shared route</h2>
<p>With the collection and renderer in place, the route itself stays small. Next.js App Router&#39;s optional catch-all segment, <code>[[...slug]]</code>, matches the site root along with any number of nested path segments, which is exactly the shape a flexible Pages collection needs.</p>
<pre><code class="language-text">app/
  (frontend)/
    [locale]/
      [[...slug]]/
        page.tsx
</code></pre>
<pre><code class="language-tsx">// File: app/(frontend)/[locale]/[[...slug]]/page.tsx
import { notFound } from &#39;next/navigation&#39;

import { RenderBlocks } from &#39;@/blocks/RenderBlocks&#39;
import { getPage } from &#39;@/data/getPage&#39;
import { resolveSiteFromRequest } from &#39;@/sites/resolveSiteFromRequest&#39;
import { SiteThemeProvider } from &#39;@/sites/SiteThemeProvider&#39;

type PageProps = {
  params: Promise&lt;{
    locale: string
    slug?: string[]
  }&gt;
}

export default async function Page({ params }: PageProps) {
  const { locale, slug } = await params

  const site = await resolveSiteFromRequest()

  const path = slug?.join(&#39;/&#39;) ?? &#39;home&#39;

  const page = await getPage({
    siteID: site.id,
    locale,
    path,
  })

  if (!page) {
    notFound()
  }

  return (
    &lt;SiteThemeProvider site={site}&gt;
      &lt;RenderBlocks blocks={page.layout} site={site} /&gt;
    &lt;/SiteThemeProvider&gt;
  )
}
</code></pre>
<p>Note the <code>await params</code> — current App Router versions deliver dynamic route parameters asynchronously, so that await is required, not optional. Beyond that, the route&#39;s job is five lookups: resolve the tenant, read the locale, join the slug segments into a path, fetch the matching document, and hand its blocks to the renderer. There&#39;s no branching on which page this is. The document coming back from <code>getPage</code> already carries everything the renderer needs.</p>
<h2>Fetching the page document</h2>
<p><code>getPage</code> runs on Payload&#39;s Local API, which supports collection queries, locale and fallback-locale handling, relationship depth, and access control in a single call:</p>
<pre><code class="language-ts">// File: src/data/getPage.ts
import { getPayload } from &#39;payload&#39;

import config from &#39;@payload-config&#39;

type GetPageArgs = {
  siteID: string
  locale: string
  path: string
}

export async function getPage({ siteID, locale, path }: GetPageArgs) {
  const payload = await getPayload({ config })

  const result = await payload.find({
    collection: &#39;pages&#39;,
    locale,
    fallbackLocale: false,
    draft: false,
    limit: 1,
    depth: 2,
    where: {
      and: [
        {
          tenant: {
            equals: siteID,
          },
        },
        {
          slug: {
            equals: path,
          },
        },
      ],
    },
  })

  return result.docs[0] ?? null
}
</code></pre>
<p>For deeper page trees you&#39;ll usually want a dedicated <code>path</code> field instead of a bare <code>slug</code> — something that can hold values like <code>about/our-story</code> or <code>campaigns/summer/wellness-guide</code> — plus draft preview support, redirects, and caching on top of this base query. The lookup itself always reduces to the same equation: tenant plus locale plus path resolves to one page document.</p>
<h2>Running this across multiple brands</h2>
<p>This pattern earns its keep on a multi-brand platform. Take three sites sharing one Payload instance: <code>canprev.ca</code>, <code>cytomatrix.ca</code>, <code>orangenaturals.com</code>. A request to <code>https://canprev.ca/en/about</code> resolves to tenant <code>canprev</code>, locale <code>en</code>, path <code>about</code>. A request to <code>https://cytomatrix.ca/en/about</code> resolves to tenant <code>cytomatrix</code>, same locale, same path. The route handling both requests is the same file. What differs is the document <code>getPage</code> returns, and the theme <code>SiteThemeProvider</code> applies around it.</p>
<p>Payload&#39;s official multi-tenant plugin handles the tenant relationship side of this: it adds tenant fields to your configured collections and scopes both frontend queries and admin-panel visibility by tenant.</p>
<p>All three brands can draw from the same block library — Hero, Text and Media, Cards, Call to Action, FAQ, and so on — because a block&#39;s data contract stays constant across tenants even when its visual output changes. A <code>HeroBlockData</code> shape of heading, description, image, and actions works identically whether the brand wants rounded imagery and green accents or clinical typography and blue accents. Most of that variation lives in CSS variables and design tokens rather than in separate block definitions:</p>
<pre><code class="language-tsx">// File: src/sites/SiteThemeProvider.tsx
export function SiteThemeProvider({
  site,
  children,
}: {
  site: Site
  children: React.ReactNode
}) {
  return (
    &lt;div
      data-site={site.slug}
      style={
        {
          &#39;--brand-primary&#39;: site.theme.primaryColor,
          &#39;--brand-secondary&#39;: site.theme.secondaryColor,
          &#39;--font-heading&#39;: site.theme.headingFont,
          &#39;--radius-card&#39;: site.theme.cardRadius,
        } as React.CSSProperties
      }
    &gt;
      {children}
    &lt;/div&gt;
  )
}
</code></pre>
<p>A shared Hero component reads those tokens directly:</p>
<pre><code class="language-tsx">// File: src/blocks/Hero/Component.tsx
export function HeroBlock({
  heading,
  description,
  image,
  actions,
}: HeroBlockData) {
  return (
    &lt;section className=&quot;bg-[var(--brand-primary)]&quot;&gt;
      &lt;div className=&quot;site-container&quot;&gt;
        &lt;h1 className=&quot;font-[var(--font-heading)]&quot;&gt;{heading}&lt;/h1&gt;
        {description &amp;&amp; &lt;p&gt;{description}&lt;/p&gt;}
        {/* Image and actions */}
      &lt;/div&gt;
    &lt;/section&gt;
  )
}
</code></pre>
<p>When a brand&#39;s visual requirements go past what tokens can express, the block implementation can route to a tenant-specific presentation while keeping one Payload block definition:</p>
<pre><code class="language-tsx">// File: src/blocks/Hero/Component.tsx
const heroPresentations = {
  canprev: CanPrevHeroPresentation,
  cytomatrix: CytomatrixHeroPresentation,
  &#39;orange-naturals&#39;: OrangeNaturalsHeroPresentation,
}

export function HeroBlock({
  site,
  ...block
}: HeroBlockData &amp; { site: Site }) {
  const Presentation =
    heroPresentations[site.slug as keyof typeof heroPresentations] ??
    DefaultHeroPresentation

  return &lt;Presentation {...block} /&gt;
}
</code></pre>
<p>Editors across every tenant still configure the same <code>hero</code> block in the admin panel. The tenant-specific React path is an internal detail of that one block&#39;s implementation.</p>
<h2>Where a dedicated route is the right call</h2>
<p>A shared Pages route handles free-form editorial content well, and it&#39;s a poor fit for content with a fixed, predictable structure. Products, recipes, events, and giveaways are the common examples — each has a stable set of fields an editor fills in, rather than a stack of blocks they arrange freely.</p>
<p>A recipe typically has title, description, prep time, cook time, servings, ingredients, instructions, dietary tags, related products, an author, and a featured image — a shape that stays constant across every recipe on the site. That belongs in its own collection with its own route:</p>
<pre><code class="language-tsx">// File: app/(frontend)/[locale]/recipes/[slug]/page.tsx
import { notFound } from &#39;next/navigation&#39;

import { RecipeTemplate } from &#39;@/templates/Recipe&#39;
import { getRecipe } from &#39;@/data/getRecipe&#39;
import { resolveSiteFromRequest } from &#39;@/sites/resolveSiteFromRequest&#39;

type RecipePageProps = {
  params: Promise&lt;{
    locale: string
    slug: string
  }&gt;
}

export default async function RecipePage({ params }: RecipePageProps) {
  const { locale, slug } = await params

  const site = await resolveSiteFromRequest()

  const recipe = await getRecipe({
    siteID: site.id,
    locale,
    slug,
  })

  if (!recipe) {
    notFound()
  }

  return &lt;RecipeTemplate recipe={recipe} site={site} /&gt;
}
</code></pre>
<p><code>RecipeTemplate</code> composes existing blocks into a fixed order rather than reading an editor-defined <code>layout</code> array:</p>
<pre><code class="language-tsx">// File: src/templates/Recipe.tsx
import { RecipeHeroBlock } from &#39;@/blocks/RecipeHero/Component&#39;
import { RecipeMetaBlock } from &#39;@/blocks/RecipeMeta/Component&#39;
import { RecipeIngredientsBlock } from &#39;@/blocks/RecipeIngredients/Component&#39;
import { RecipeInstructionsBlock } from &#39;@/blocks/RecipeInstructions/Component&#39;
import { RelatedProductsBlock } from &#39;@/blocks/RelatedProducts/Component&#39;
import { FAQBlock } from &#39;@/blocks/FAQ/Component&#39;

export function RecipeTemplate({
  recipe,
  site,
}: {
  recipe: Recipe
  site: Site
}) {
  return (
    &lt;&gt;
      &lt;RecipeHeroBlock
        title={recipe.title}
        description={recipe.description}
        image={recipe.image}
        site={site}
      /&gt;
      &lt;RecipeMetaBlock
        preparationTime={recipe.preparationTime}
        cookingTime={recipe.cookingTime}
        servings={recipe.servings}
        site={site}
      /&gt;
      &lt;RecipeIngredientsBlock ingredients={recipe.ingredients} site={site} /&gt;
      &lt;RecipeInstructionsBlock instructions={recipe.instructions} site={site} /&gt;
      &lt;RelatedProductsBlock products={recipe.relatedProducts} site={site} /&gt;
      {recipe.faq?.length &gt; 0 &amp;&amp; &lt;FAQBlock items={recipe.faq} site={site} /&gt;}
    &lt;/&gt;
  )
}
</code></pre>
<p>The building blocks here — RecipeHero, RecipeMeta, RecipeIngredients — are the same kind of reusable presentation units as the Pages collection&#39;s blocks. The difference is who controls their order: an editor arranges blocks freely on a Pages document, while a template fixes the arrangement in code for a structured content type.</p>
<table>
<thead>
<tr>
<th>Content shape</th>
<th>Rendering approach</th>
<th>Editor control</th>
</tr>
</thead>
<tbody><tr>
<td>Free-form editorial pages (about, campaigns, landing pages)</td>
<td>Shared <code>[[...slug]]</code> route, Pages collection, <code>layout</code> blocks array</td>
<td>Choose and order blocks freely</td>
</tr>
<tr>
<td>Stable structured content (recipes, products, events)</td>
<td>Dedicated collection and route, fixed template composing blocks</td>
<td>Fill in structured fields; layout stays fixed</td>
</tr>
<tr>
<td>Content needing specialized data joins (a product route merging Payload content with PIM, inventory, and reviews)</td>
<td>Dedicated route with a custom resolver</td>
<td>Fill in structured fields; composition logic lives in the resolver</td>
</tr>
</tbody></table>
<h2>Terminology worth keeping straight</h2>
<p>Three words get used loosely on projects like this, and mixing them up causes real confusion between design and engineering:</p>
<p><strong>Block</strong> — presentation logic exposed through Payload&#39;s admin UI. It defines what fields an editor configures and what data shape the frontend receives. Hero, FAQ, Cards, and CTA are blocks.</p>
<p><strong>Component</strong> — an implementation detail inside the frontend codebase. Button, Container, Heading, and Modal are components that a block&#39;s React implementation might use internally, without ever being exposed to an editor directly.</p>
<p><strong>Template</strong> — a code-defined composition of blocks for a structured content type. <code>RecipeTemplate</code> is a template; it decides the fixed order recipe blocks appear in.</p>
<p>Keeping these separate is what keeps the block library from sprawling: components support blocks, blocks provide the presentation editors can configure, templates compose blocks for fixed content types, and routes decide which of those paths a given request takes.</p>
<h2>Common mistakes worth avoiding</h2>
<p><strong>A collection per visual variation.</strong> Landing Pages, Campaign Pages, and Standard Pages collections often end up storing near-identical block structures. Check whether these are genuinely different content types before splitting them apart.</p>
<p><strong>A Payload block for every component.</strong> Buttons, containers, and layout wrappers don&#39;t need to be editor-configurable. Expose the presentation choices that matter and keep the rest as implementation.</p>
<p><strong>Tenant checks scattered through routes.</strong> Resolve the tenant once, in <code>resolveSiteFromRequest</code>, and pass the result through a consistent site object rather than checking <code>hostname</code> conditionally across the codebase.</p>
<p><strong>Structured content that becomes too freeform.</strong> A recipe should keep behaving like a recipe. Blocks are meant to add configurable flexibility within a content type&#39;s model, not replace that model entirely.</p>
<h2>FAQ</h2>
<p><strong>Does every page on a Payload site need to go through the same route?</strong>
No. The shared <code>[[...slug]]</code> route is for free-form editorial content. Structured content types — products, recipes, events — get their own collections and routes with fixed templates.</p>
<p><strong>How does the frontend know which component to render for a given block?</strong>
Each block entry in a page&#39;s <code>layout</code> array carries a <code>blockType</code> value matching the block&#39;s <code>slug</code> in its Payload config. The renderer looks up that value in a <code>blockType</code>-to-component map and renders the match.</p>
<p><strong>Can different tenants use different versions of the same block?</strong>
Yes. The Payload block definition and its data contract stay shared, while the block&#39;s React implementation can branch internally by tenant slug to render a different presentation.</p>
<p><strong>What happens if an editor adds a block type the frontend hasn&#39;t implemented yet?</strong>
The renderer&#39;s lookup returns nothing for an unregistered <code>blockType</code>, logs a warning, and skips that block rather than crashing the page. Registering the new block in <code>blockRenderers</code> is what makes it render.</p>
<p><strong>Does this replace the need for SEO metadata handling per page?</strong>
No — metadata generation still needs its own logic per route (or per document, for the shared route), typically pulled from SEO fields stored on the Payload document alongside the <code>layout</code> array.</p>
<h2>Wrapping up</h2>
<p>The shift this pattern asks for is where presentation logic lives. Payload&#39;s <code>layout</code> blocks array on a page document carries the editor&#39;s chosen structure; a block renderer maps each entry to a React component; a single catch-all route resolves tenant, locale, and path into that document. Structured content types keep their own collections and fixed templates alongside this shared route, rather than being forced through it. Once that split is in place, adding a new editorial page becomes a content operation instead of a pull request, and a new tenant reuses the same routing and rendering code with its own theme and content on top.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/payload-nextjs-single-dynamic-page</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/payload-nextjs-single-dynamic-page</guid>
            <category><![CDATA[Payload]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Tue, 28 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;A Payload CMS site rendered through Next.js can serve most of its editorial pages — home, about, contact, services, campaigns, microsites, nested marketing pages — from one route: &lt;code&gt;app/[locale]/[[...slug]]/page.tsx&lt;/code&gt;. The route resolves a tenant, locale, and slug into a single Payload document, and a block renderer turns that document&amp;#39;s stored &lt;code&gt;layout&lt;/code&gt; array into the page. Presentation lives entirely in the blocks attached to the document, so a growing site doesn&amp;#39;t need a new template file every time an editor wants a new page shape. Below I&amp;#39;ll walk through the Pages collection, the block renderer, the catch-all route, and how this holds up across a multi-brand platform — plus where you still want a dedicated route instead.&lt;/p&gt;
&lt;p&gt;I&amp;#39;ve built this pattern most recently on a multi-brand rebuild covering three separate consumer sites sharing one Payload instance. Each brand needed its own theme, navigation, and content, while editors across all three needed the same set of approved blocks to build pages without opening a pull request. That constraint is what pushed the architecture toward one shared route with tenant-aware queries, rather than a route per brand or a template per page type.&lt;/p&gt;
&lt;h2&gt;The habit this breaks&lt;/h2&gt;
&lt;p&gt;Developers arriving from WordPress, Drupal, or Craft CMS usually look for a template file per page type: &lt;code&gt;page-about.php&lt;/code&gt;, &lt;code&gt;page-contact.php&lt;/code&gt;, &lt;code&gt;single-product.php&lt;/code&gt;. Developers already comfortable with Next.js often reach for the same instinct in App Router form: a folder per page, &lt;code&gt;app/about/page.tsx&lt;/code&gt;, &lt;code&gt;app/contact/page.tsx&lt;/code&gt;, &lt;code&gt;app/services/page.tsx&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That instinct comes from CMS platforms where the CMS itself participates in template selection. A page record carries a template value — Default Page, Landing Page, Campaign Page — and the CMS picks a matching file out of the active theme. The rendering path runs from URL to router to page record to selected template to rendered HTML, with the CMS deciding which theme file executes.&lt;/p&gt;
&lt;p&gt;Payload works from a different starting point. It&amp;#39;s a code-first CMS installed directly into your Next.js application, and it stores structured content rather than owning a template layer. Routing and rendering stay in your Next.js code; Payload&amp;#39;s job is to hand that code the data and configuration an editor set up.&lt;/p&gt;
&lt;h2&gt;The Pages collection&lt;/h2&gt;
&lt;p&gt;Here&amp;#39;s a simplified version of the collection that backs the shared route:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/collections/Pages/config.ts
import type { CollectionConfig } from &amp;#39;payload&amp;#39;

import { HeroBlock } from &amp;#39;@/blocks/Hero/config&amp;#39;
import { TextMediaBlock } from &amp;#39;@/blocks/TextMedia/config&amp;#39;
import { CardsBlock } from &amp;#39;@/blocks/Cards/config&amp;#39;
import { CallToActionBlock } from &amp;#39;@/blocks/CallToAction/config&amp;#39;
import { FAQBlock } from &amp;#39;@/blocks/FAQ/config&amp;#39;

export const Pages: CollectionConfig = {
  slug: &amp;#39;pages&amp;#39;,

  versions: {
    drafts: true,
  },

  admin: {
    useAsTitle: &amp;#39;title&amp;#39;,
  },

  fields: [
    {
      name: &amp;#39;title&amp;#39;,
      type: &amp;#39;text&amp;#39;,
      required: true,
      localized: true,
    },
    {
      name: &amp;#39;slug&amp;#39;,
      type: &amp;#39;text&amp;#39;,
      required: true,
      index: true,
    },
    {
      name: &amp;#39;layout&amp;#39;,
      type: &amp;#39;blocks&amp;#39;,
      localized: true,
      blocks: [
        HeroBlock,
        TextMediaBlock,
        CardsBlock,
        CallToActionBlock,
        FAQBlock,
      ],
    },
  ],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The field that matters here is &lt;code&gt;layout&lt;/code&gt;. It&amp;#39;s a &lt;code&gt;blocks&lt;/code&gt; field, which means each page document stores an ordered array of block entries, and editors choose which blocks appear and in what order. Payload tags each entry with a &lt;code&gt;blockType&lt;/code&gt; value matching the block&amp;#39;s &lt;code&gt;slug&lt;/code&gt;, which the frontend later uses to pick a renderer.&lt;/p&gt;
&lt;p&gt;A Hero block definition looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/blocks/Hero/config.ts
import type { Block } from &amp;#39;payload&amp;#39;

export const HeroBlock: Block = {
  slug: &amp;#39;hero&amp;#39;,
  interfaceName: &amp;#39;HeroBlock&amp;#39;,

  fields: [
    {
      name: &amp;#39;heading&amp;#39;,
      type: &amp;#39;text&amp;#39;,
      required: true,
      localized: true,
    },
    {
      name: &amp;#39;description&amp;#39;,
      type: &amp;#39;textarea&amp;#39;,
      localized: true,
    },
    {
      name: &amp;#39;image&amp;#39;,
      type: &amp;#39;upload&amp;#39;,
      relationTo: &amp;#39;media&amp;#39;,
    },
    {
      name: &amp;#39;actions&amp;#39;,
      type: &amp;#39;array&amp;#39;,
      maxRows: 2,
      fields: [
        {
          name: &amp;#39;label&amp;#39;,
          type: &amp;#39;text&amp;#39;,
          required: true,
          localized: true,
        },
        {
          name: &amp;#39;href&amp;#39;,
          type: &amp;#39;text&amp;#39;,
          required: true,
        },
      ],
    },
  ],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This config is the contract an editor sees in the admin UI: heading, description, an image upload, and up to two actions. Every field here becomes a piece of data the frontend can rely on being present in a predictable shape. The React implementation behind this contract is a separate concern, which is what the renderer below handles.&lt;/p&gt;
&lt;h2&gt;The block renderer&lt;/h2&gt;
&lt;p&gt;A page document&amp;#39;s &lt;code&gt;layout&lt;/code&gt; array needs one thing to turn into HTML: a lookup from &lt;code&gt;blockType&lt;/code&gt; to a React component. That lookup is the entire job of the renderer.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// File: src/blocks/RenderBlocks.tsx
import { HeroBlock } from &amp;#39;@/blocks/Hero/Component&amp;#39;
import { TextMediaBlock } from &amp;#39;@/blocks/TextMedia/Component&amp;#39;
import { CardsBlock } from &amp;#39;@/blocks/Cards/Component&amp;#39;
import { CallToActionBlock } from &amp;#39;@/blocks/CallToAction/Component&amp;#39;
import { FAQBlock } from &amp;#39;@/blocks/FAQ/Component&amp;#39;

const blockRenderers = {
  hero: HeroBlock,
  textMedia: TextMediaBlock,
  cards: CardsBlock,
  callToAction: CallToActionBlock,
  faq: FAQBlock,
}

type RenderBlocksProps = {
  blocks: Array&amp;lt;{
    id?: string | null
    blockType: string
    [key: string]: unknown
  }&amp;gt;
  site: {
    id: string
    slug: string
  }
}

export function RenderBlocks({ blocks, site }: RenderBlocksProps) {
  if (!blocks?.length) {
    return null
  }

  return blocks.map((block, index) =&amp;gt; {
    const Renderer =
      blockRenderers[block.blockType as keyof typeof blockRenderers]

    if (!Renderer) {
      console.warn(`No renderer registered for block: ${block.blockType}`)
      return null
    }

    return (
      &amp;lt;Renderer
        key={block.id ?? `${block.blockType}-${index}`}
        {...block}
        site={site}
      /&amp;gt;
    )
  })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add a new block type in the future, and this is the one file you touch to register it. The route calling &lt;code&gt;RenderBlocks&lt;/code&gt; never needs an update, because it only ever passes through whatever &lt;code&gt;layout&lt;/code&gt; array the page document contains.&lt;/p&gt;
&lt;h2&gt;The shared route&lt;/h2&gt;
&lt;p&gt;With the collection and renderer in place, the route itself stays small. Next.js App Router&amp;#39;s optional catch-all segment, &lt;code&gt;[[...slug]]&lt;/code&gt;, matches the site root along with any number of nested path segments, which is exactly the shape a flexible Pages collection needs.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;app/
  (frontend)/
    [locale]/
      [[...slug]]/
        page.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// File: app/(frontend)/[locale]/[[...slug]]/page.tsx
import { notFound } from &amp;#39;next/navigation&amp;#39;

import { RenderBlocks } from &amp;#39;@/blocks/RenderBlocks&amp;#39;
import { getPage } from &amp;#39;@/data/getPage&amp;#39;
import { resolveSiteFromRequest } from &amp;#39;@/sites/resolveSiteFromRequest&amp;#39;
import { SiteThemeProvider } from &amp;#39;@/sites/SiteThemeProvider&amp;#39;

type PageProps = {
  params: Promise&amp;lt;{
    locale: string
    slug?: string[]
  }&amp;gt;
}

export default async function Page({ params }: PageProps) {
  const { locale, slug } = await params

  const site = await resolveSiteFromRequest()

  const path = slug?.join(&amp;#39;/&amp;#39;) ?? &amp;#39;home&amp;#39;

  const page = await getPage({
    siteID: site.id,
    locale,
    path,
  })

  if (!page) {
    notFound()
  }

  return (
    &amp;lt;SiteThemeProvider site={site}&amp;gt;
      &amp;lt;RenderBlocks blocks={page.layout} site={site} /&amp;gt;
    &amp;lt;/SiteThemeProvider&amp;gt;
  )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the &lt;code&gt;await params&lt;/code&gt; — current App Router versions deliver dynamic route parameters asynchronously, so that await is required, not optional. Beyond that, the route&amp;#39;s job is five lookups: resolve the tenant, read the locale, join the slug segments into a path, fetch the matching document, and hand its blocks to the renderer. There&amp;#39;s no branching on which page this is. The document coming back from &lt;code&gt;getPage&lt;/code&gt; already carries everything the renderer needs.&lt;/p&gt;
&lt;h2&gt;Fetching the page document&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;getPage&lt;/code&gt; runs on Payload&amp;#39;s Local API, which supports collection queries, locale and fallback-locale handling, relationship depth, and access control in a single call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/data/getPage.ts
import { getPayload } from &amp;#39;payload&amp;#39;

import config from &amp;#39;@payload-config&amp;#39;

type GetPageArgs = {
  siteID: string
  locale: string
  path: string
}

export async function getPage({ siteID, locale, path }: GetPageArgs) {
  const payload = await getPayload({ config })

  const result = await payload.find({
    collection: &amp;#39;pages&amp;#39;,
    locale,
    fallbackLocale: false,
    draft: false,
    limit: 1,
    depth: 2,
    where: {
      and: [
        {
          tenant: {
            equals: siteID,
          },
        },
        {
          slug: {
            equals: path,
          },
        },
      ],
    },
  })

  return result.docs[0] ?? null
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For deeper page trees you&amp;#39;ll usually want a dedicated &lt;code&gt;path&lt;/code&gt; field instead of a bare &lt;code&gt;slug&lt;/code&gt; — something that can hold values like &lt;code&gt;about/our-story&lt;/code&gt; or &lt;code&gt;campaigns/summer/wellness-guide&lt;/code&gt; — plus draft preview support, redirects, and caching on top of this base query. The lookup itself always reduces to the same equation: tenant plus locale plus path resolves to one page document.&lt;/p&gt;
&lt;h2&gt;Running this across multiple brands&lt;/h2&gt;
&lt;p&gt;This pattern earns its keep on a multi-brand platform. Take three sites sharing one Payload instance: &lt;code&gt;canprev.ca&lt;/code&gt;, &lt;code&gt;cytomatrix.ca&lt;/code&gt;, &lt;code&gt;orangenaturals.com&lt;/code&gt;. A request to &lt;code&gt;https://canprev.ca/en/about&lt;/code&gt; resolves to tenant &lt;code&gt;canprev&lt;/code&gt;, locale &lt;code&gt;en&lt;/code&gt;, path &lt;code&gt;about&lt;/code&gt;. A request to &lt;code&gt;https://cytomatrix.ca/en/about&lt;/code&gt; resolves to tenant &lt;code&gt;cytomatrix&lt;/code&gt;, same locale, same path. The route handling both requests is the same file. What differs is the document &lt;code&gt;getPage&lt;/code&gt; returns, and the theme &lt;code&gt;SiteThemeProvider&lt;/code&gt; applies around it.&lt;/p&gt;
&lt;p&gt;Payload&amp;#39;s official multi-tenant plugin handles the tenant relationship side of this: it adds tenant fields to your configured collections and scopes both frontend queries and admin-panel visibility by tenant.&lt;/p&gt;
&lt;p&gt;All three brands can draw from the same block library — Hero, Text and Media, Cards, Call to Action, FAQ, and so on — because a block&amp;#39;s data contract stays constant across tenants even when its visual output changes. A &lt;code&gt;HeroBlockData&lt;/code&gt; shape of heading, description, image, and actions works identically whether the brand wants rounded imagery and green accents or clinical typography and blue accents. Most of that variation lives in CSS variables and design tokens rather than in separate block definitions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// File: src/sites/SiteThemeProvider.tsx
export function SiteThemeProvider({
  site,
  children,
}: {
  site: Site
  children: React.ReactNode
}) {
  return (
    &amp;lt;div
      data-site={site.slug}
      style={
        {
          &amp;#39;--brand-primary&amp;#39;: site.theme.primaryColor,
          &amp;#39;--brand-secondary&amp;#39;: site.theme.secondaryColor,
          &amp;#39;--font-heading&amp;#39;: site.theme.headingFont,
          &amp;#39;--radius-card&amp;#39;: site.theme.cardRadius,
        } as React.CSSProperties
      }
    &amp;gt;
      {children}
    &amp;lt;/div&amp;gt;
  )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A shared Hero component reads those tokens directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// File: src/blocks/Hero/Component.tsx
export function HeroBlock({
  heading,
  description,
  image,
  actions,
}: HeroBlockData) {
  return (
    &amp;lt;section className=&amp;quot;bg-[var(--brand-primary)]&amp;quot;&amp;gt;
      &amp;lt;div className=&amp;quot;site-container&amp;quot;&amp;gt;
        &amp;lt;h1 className=&amp;quot;font-[var(--font-heading)]&amp;quot;&amp;gt;{heading}&amp;lt;/h1&amp;gt;
        {description &amp;amp;&amp;amp; &amp;lt;p&amp;gt;{description}&amp;lt;/p&amp;gt;}
        {/* Image and actions */}
      &amp;lt;/div&amp;gt;
    &amp;lt;/section&amp;gt;
  )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When a brand&amp;#39;s visual requirements go past what tokens can express, the block implementation can route to a tenant-specific presentation while keeping one Payload block definition:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// File: src/blocks/Hero/Component.tsx
const heroPresentations = {
  canprev: CanPrevHeroPresentation,
  cytomatrix: CytomatrixHeroPresentation,
  &amp;#39;orange-naturals&amp;#39;: OrangeNaturalsHeroPresentation,
}

export function HeroBlock({
  site,
  ...block
}: HeroBlockData &amp;amp; { site: Site }) {
  const Presentation =
    heroPresentations[site.slug as keyof typeof heroPresentations] ??
    DefaultHeroPresentation

  return &amp;lt;Presentation {...block} /&amp;gt;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Editors across every tenant still configure the same &lt;code&gt;hero&lt;/code&gt; block in the admin panel. The tenant-specific React path is an internal detail of that one block&amp;#39;s implementation.&lt;/p&gt;
&lt;h2&gt;Where a dedicated route is the right call&lt;/h2&gt;
&lt;p&gt;A shared Pages route handles free-form editorial content well, and it&amp;#39;s a poor fit for content with a fixed, predictable structure. Products, recipes, events, and giveaways are the common examples — each has a stable set of fields an editor fills in, rather than a stack of blocks they arrange freely.&lt;/p&gt;
&lt;p&gt;A recipe typically has title, description, prep time, cook time, servings, ingredients, instructions, dietary tags, related products, an author, and a featured image — a shape that stays constant across every recipe on the site. That belongs in its own collection with its own route:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// File: app/(frontend)/[locale]/recipes/[slug]/page.tsx
import { notFound } from &amp;#39;next/navigation&amp;#39;

import { RecipeTemplate } from &amp;#39;@/templates/Recipe&amp;#39;
import { getRecipe } from &amp;#39;@/data/getRecipe&amp;#39;
import { resolveSiteFromRequest } from &amp;#39;@/sites/resolveSiteFromRequest&amp;#39;

type RecipePageProps = {
  params: Promise&amp;lt;{
    locale: string
    slug: string
  }&amp;gt;
}

export default async function RecipePage({ params }: RecipePageProps) {
  const { locale, slug } = await params

  const site = await resolveSiteFromRequest()

  const recipe = await getRecipe({
    siteID: site.id,
    locale,
    slug,
  })

  if (!recipe) {
    notFound()
  }

  return &amp;lt;RecipeTemplate recipe={recipe} site={site} /&amp;gt;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;RecipeTemplate&lt;/code&gt; composes existing blocks into a fixed order rather than reading an editor-defined &lt;code&gt;layout&lt;/code&gt; array:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// File: src/templates/Recipe.tsx
import { RecipeHeroBlock } from &amp;#39;@/blocks/RecipeHero/Component&amp;#39;
import { RecipeMetaBlock } from &amp;#39;@/blocks/RecipeMeta/Component&amp;#39;
import { RecipeIngredientsBlock } from &amp;#39;@/blocks/RecipeIngredients/Component&amp;#39;
import { RecipeInstructionsBlock } from &amp;#39;@/blocks/RecipeInstructions/Component&amp;#39;
import { RelatedProductsBlock } from &amp;#39;@/blocks/RelatedProducts/Component&amp;#39;
import { FAQBlock } from &amp;#39;@/blocks/FAQ/Component&amp;#39;

export function RecipeTemplate({
  recipe,
  site,
}: {
  recipe: Recipe
  site: Site
}) {
  return (
    &amp;lt;&amp;gt;
      &amp;lt;RecipeHeroBlock
        title={recipe.title}
        description={recipe.description}
        image={recipe.image}
        site={site}
      /&amp;gt;
      &amp;lt;RecipeMetaBlock
        preparationTime={recipe.preparationTime}
        cookingTime={recipe.cookingTime}
        servings={recipe.servings}
        site={site}
      /&amp;gt;
      &amp;lt;RecipeIngredientsBlock ingredients={recipe.ingredients} site={site} /&amp;gt;
      &amp;lt;RecipeInstructionsBlock instructions={recipe.instructions} site={site} /&amp;gt;
      &amp;lt;RelatedProductsBlock products={recipe.relatedProducts} site={site} /&amp;gt;
      {recipe.faq?.length &amp;gt; 0 &amp;amp;&amp;amp; &amp;lt;FAQBlock items={recipe.faq} site={site} /&amp;gt;}
    &amp;lt;/&amp;gt;
  )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The building blocks here — RecipeHero, RecipeMeta, RecipeIngredients — are the same kind of reusable presentation units as the Pages collection&amp;#39;s blocks. The difference is who controls their order: an editor arranges blocks freely on a Pages document, while a template fixes the arrangement in code for a structured content type.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content shape&lt;/th&gt;
&lt;th&gt;Rendering approach&lt;/th&gt;
&lt;th&gt;Editor control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Free-form editorial pages (about, campaigns, landing pages)&lt;/td&gt;
&lt;td&gt;Shared &lt;code&gt;[[...slug]]&lt;/code&gt; route, Pages collection, &lt;code&gt;layout&lt;/code&gt; blocks array&lt;/td&gt;
&lt;td&gt;Choose and order blocks freely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stable structured content (recipes, products, events)&lt;/td&gt;
&lt;td&gt;Dedicated collection and route, fixed template composing blocks&lt;/td&gt;
&lt;td&gt;Fill in structured fields; layout stays fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content needing specialized data joins (a product route merging Payload content with PIM, inventory, and reviews)&lt;/td&gt;
&lt;td&gt;Dedicated route with a custom resolver&lt;/td&gt;
&lt;td&gt;Fill in structured fields; composition logic lives in the resolver&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;Terminology worth keeping straight&lt;/h2&gt;
&lt;p&gt;Three words get used loosely on projects like this, and mixing them up causes real confusion between design and engineering:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Block&lt;/strong&gt; — presentation logic exposed through Payload&amp;#39;s admin UI. It defines what fields an editor configures and what data shape the frontend receives. Hero, FAQ, Cards, and CTA are blocks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Component&lt;/strong&gt; — an implementation detail inside the frontend codebase. Button, Container, Heading, and Modal are components that a block&amp;#39;s React implementation might use internally, without ever being exposed to an editor directly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Template&lt;/strong&gt; — a code-defined composition of blocks for a structured content type. &lt;code&gt;RecipeTemplate&lt;/code&gt; is a template; it decides the fixed order recipe blocks appear in.&lt;/p&gt;
&lt;p&gt;Keeping these separate is what keeps the block library from sprawling: components support blocks, blocks provide the presentation editors can configure, templates compose blocks for fixed content types, and routes decide which of those paths a given request takes.&lt;/p&gt;
&lt;h2&gt;Common mistakes worth avoiding&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A collection per visual variation.&lt;/strong&gt; Landing Pages, Campaign Pages, and Standard Pages collections often end up storing near-identical block structures. Check whether these are genuinely different content types before splitting them apart.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A Payload block for every component.&lt;/strong&gt; Buttons, containers, and layout wrappers don&amp;#39;t need to be editor-configurable. Expose the presentation choices that matter and keep the rest as implementation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tenant checks scattered through routes.&lt;/strong&gt; Resolve the tenant once, in &lt;code&gt;resolveSiteFromRequest&lt;/code&gt;, and pass the result through a consistent site object rather than checking &lt;code&gt;hostname&lt;/code&gt; conditionally across the codebase.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Structured content that becomes too freeform.&lt;/strong&gt; A recipe should keep behaving like a recipe. Blocks are meant to add configurable flexibility within a content type&amp;#39;s model, not replace that model entirely.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Does every page on a Payload site need to go through the same route?&lt;/strong&gt;
No. The shared &lt;code&gt;[[...slug]]&lt;/code&gt; route is for free-form editorial content. Structured content types — products, recipes, events — get their own collections and routes with fixed templates.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How does the frontend know which component to render for a given block?&lt;/strong&gt;
Each block entry in a page&amp;#39;s &lt;code&gt;layout&lt;/code&gt; array carries a &lt;code&gt;blockType&lt;/code&gt; value matching the block&amp;#39;s &lt;code&gt;slug&lt;/code&gt; in its Payload config. The renderer looks up that value in a &lt;code&gt;blockType&lt;/code&gt;-to-component map and renders the match.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can different tenants use different versions of the same block?&lt;/strong&gt;
Yes. The Payload block definition and its data contract stay shared, while the block&amp;#39;s React implementation can branch internally by tenant slug to render a different presentation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens if an editor adds a block type the frontend hasn&amp;#39;t implemented yet?&lt;/strong&gt;
The renderer&amp;#39;s lookup returns nothing for an unregistered &lt;code&gt;blockType&lt;/code&gt;, logs a warning, and skips that block rather than crashing the page. Registering the new block in &lt;code&gt;blockRenderers&lt;/code&gt; is what makes it render.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does this replace the need for SEO metadata handling per page?&lt;/strong&gt;
No — metadata generation still needs its own logic per route (or per document, for the shared route), typically pulled from SEO fields stored on the Payload document alongside the &lt;code&gt;layout&lt;/code&gt; array.&lt;/p&gt;
&lt;h2&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;The shift this pattern asks for is where presentation logic lives. Payload&amp;#39;s &lt;code&gt;layout&lt;/code&gt; blocks array on a page document carries the editor&amp;#39;s chosen structure; a block renderer maps each entry to a React component; a single catch-all route resolves tenant, locale, and path into that document. Structured content types keep their own collections and fixed templates alongside this shared route, rather than being forced through it. Once that split is in place, adding a new editorial page becomes a content operation instead of a pull request, and a new tenant reuses the same routing and rendering code with its own theme and content on top.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/payload-nextjs-single-dynamic-page"/>
        </item>
        <item>
            <title><![CDATA[7 Reasons Mid-Market B2B Needs a Content Operating System]]></title>
            <description><![CDATA[<p>If publishing one article at your company still takes a spreadsheet, five email threads, and a last-minute approval from someone who&#39;s never seen the draft, a new CMS won&#39;t fix that. This guide breaks down the seven components of a real content operating system: demand intelligence, knowledge capture, editorial production, expert validation, structured publishing, distribution, and measurement, plus where AI genuinely helps inside that system and where automating too early backfires.</p>
<p>I&#39;ve walked several mid-market B2B clients through exactly this gap: companies with a CMS, a CRM, Search Console, an email platform, project archives, and a handful of AI subscriptions, still watching one useful article crawl through the organization for weeks before publishing. The software was never the missing piece. The missing piece was a defined system connecting the people, the data, and the decisions, and that&#39;s what I want to walk through here.</p>
<h2>The Software Was Never the Bottleneck</h2>
<p>A CMS gives your team somewhere to store and publish content. A content operating system defines how useful ideas get identified, how internal expertise gets captured, how material gets reviewed, how it gets structured, how it gets distributed, and how performance feeds back into the next decision. Those are two different layers, and confusing them is why so many CMS migrations disappoint six months later.</p>
<p>An established B2B company can have decades of experience, a strong reputation, and employees who understand the market at real depth, and still watch most of that knowledge stay trapped in conversations, project folders, email threads, and individual heads instead of reaching the website. A handful of ideas make it through to become content. The rest simply never gets captured, and no CMS upgrade changes that on its own.</p>
<h2>A Better CMS Improves the Editor, Not the Workflow</h2>
<p>Replacing an old CMS genuinely improves the editing experience: easier page building, reusable components, multi-language support, better permissions, less developer dependency. Those are real wins, and none of them answer the operational questions that determine whether the company actually publishes useful content on a consistent basis.</p>
<p>Questions like who decides what gets published, which data informs that decision, who provides the technical substance, who owns the first draft, who verifies factual claims, which approvals are actually required, where supporting images and files live, which fields are mandatory before something goes live, how content gets adapted for social and email, who checks whether it performed, how a successful topic gets expanded, how a weak page gets improved, and what happens to institutional knowledge when the expert who provided it leaves the company. A CMS can support each of these steps technically. Designing the actual process is separate work, and it&#39;s the work most companies skip.</p>
<h2>The Pattern I See Constantly in Mid-Market Companies</h2>
<p>This shows up repeatedly in established technical, industrial, manufacturing, distribution, and specialist service companies: too large to run content informally, not large enough to staff a dedicated team for every stage of production. Marketing is a small internal team, sometimes supported by agencies or freelancers. Technical staff hold valuable knowledge and limited time. Sales hears customer questions directly and rarely documents them systematically. Leadership wants more visibility, more leads, and better use of AI, while staying protective of accuracy and brand voice.</p>
<p>The result is a company with enough tools to run a sophisticated system and a workflow that stays improvised in practice. A typical article moves through something like this: marketing proposes a topic, a subject-matter expert gets asked for input, the expert sends a few notes two weeks later, marketing drafts from those notes, the draft circulates by email with comments scattered across three different threads, nobody&#39;s quite sure who has final approval, the approved copy gets manually pasted into the CMS, images get requested from another department, a LinkedIn version gets written separately, the newsletter team copies part of the article into another platform, campaign tracking gets added inconsistently, and the article publishes with nobody reviewing it again unless something&#39;s obviously broken.</p>
<p>That produces content. It doesn&#39;t build a repeatable operating capability, and those are different outcomes worth telling apart.</p>
<h2>Content Operations Start Before the First Draft</h2>
<p>Most content discussions start with the article itself, which is already too late. A stronger system starts with opportunity identification: a repeatable method for deciding which problems deserve content and which format fits, drawing on Search Console data, website analytics, paid search data, sales questions, CRM notes, support requests, product launches, project experience, competitor activity, regulatory changes, internal strategic priorities, and gaps flagged by subject-matter experts.</p>
<p>The goal isn&#39;t letting data dictate every decision. It&#39;s keeping the content calendar from being run entirely by opinion, habit, or whoever leadership talked to last. For each proposed piece, the company should be able to state the customer problem it addresses, the evidence that the topic matters, the intended audience, where it sits in the buying process, what the company knows that competitors likely don&#39;t, which business objective it supports, what the reader should do next, and how success will be measured. That connects content directly to commercial value instead of leaving the connection implicit.</p>
<h2>The Seven Components of a Content Operating System</h2>
<h3>1. Demand intelligence</h3>
<p>Demand intelligence identifies what the market is asking, comparing, evaluating, or trying to solve, pulling from search data, CRM information, sales observations, customer interviews, support questions, and market trends. The output should be <a href="/blog/search-led-content-workflow-b2b-prioritize-wins">a prioritized list of opportunities</a> tied to existing pages, services, products, industries, or customer journeys, not a keyword spreadsheet nobody opens again.</p>
<p>A useful output might surface that a service page has high impressions and few clicks, that prospects keep confusing two product categories, that a technical question comes up in nearly every sales call, that several regional projects could support a location page, that buyers want a rough estimate before contacting sales, or that a page sitting near page one could climb with a focused round of additions. Demand intelligence answers where the company should invest attention next.</p>
<h3>2. Knowledge capture</h3>
<p>Once an opportunity is chosen, the company needs an efficient way to <a href="/blog/b2b-content-strategy-turn-decades-expertise-into-traffic">pull the relevant internal expertise out of the people who hold it</a>, without handing an engineer, salesperson, or executive a blank page and asking them to write. A structured input works better: a recorded interview, a short workshop, a questionnaire, notes from a sales call, a project review, existing proposals, technical documentation, customer objections, photographs and diagrams, relevant data, and pre-approved claims and qualifications.</p>
<p>Expert participation should stay focused and time-limited. A 45-minute interview with the right person is often worth more than several weeks of delayed comments on a generic draft. The expert supplies judgment, context, examples, and corrections. The content team turns that into a usable structure.</p>
<h3>3. Editorial production</h3>
<p>Editorial production turns the source material into the right format: a service page improvement, a technical article, a comparison guide, a case study, an FAQ, a buyer&#39;s checklist, a regional landing page, an industry page, a calculator, a downloadable resource, a short social post, or a newsletter piece. The format decision should follow the customer&#39;s actual need. Some questions resolve better through a calculator, estimator, or guided form than through another 2,000-word article, and a deliberate system makes that call each time instead of defaulting to a blog post by habit.</p>
<h3>4. Expert validation and governance</h3>
<p>Factual review isn&#39;t optional for expertise-rich B2B companies, since the content in question often touches technical performance, compliance, safety, manufacturing, implementation, or operational risk. A polished piece carrying a misleading generalization damages trust fast. The governance layer should name who verifies technical accuracy, who approves commercial claims, who checks legal or regulatory exposure, who owns brand and editorial quality, who holds final publishing authority, and which changes trigger reapproval.</p>
<p>The level of review should scale with the risk: a company announcement needs a light marketing sign-off, a technical guide needs an engineer or product specialist&#39;s review, and a regulated claim needs legal or compliance approval. Distinguishing these cases up front keeps the company from publishing too loosely on one end or building an approval chain so heavy that nothing ships on the other.</p>
<h3>5. Structured CMS publishing</h3>
<p>The CMS should hold more than a single rich-text field. Depending on content type, useful fields include title, summary, body, author, expert reviewer, publication date, review date, content owner, category, industry, product or service relationship, geographic relevance, featured image, social image, SEO title and description, FAQs, related projects, related articles, calls to action, campaign parameters, approval status, translation status, and revision history.</p>
<p>That structure is what makes content reusable: a project entered once can surface on a service page, an industry page, a regional page, and a related-content block; an FAQ stored centrally can appear on every page it&#39;s relevant to; an article can feed a resource library, related-content recommendations, social distribution, and a newsletter, all from a single structured source. The CMS earns its value by representing the relationships inside the company&#39;s knowledge, not just the layout of individual pages.</p>
<h3>6. Distribution workflows</h3>
<p>Publishing on the website is the start of distribution, not the end of it. Approved content can adapt across LinkedIn, Facebook, email newsletters, sales enablement, internal knowledge systems, partner communications, customer onboarding, and industry portals, with the website staying the canonical source. Social and email should extend that source rather than becoming disconnected homes for the company&#39;s best material with no ownership behind them.</p>
<p>A practical distribution workflow detects that an approved article has published, reads its title, summary, image, URL, category, and campaign fields, prepares channel-specific drafts, routes them for review, publishes the approved versions, applies consistent tracking parameters, records status, and flags the content owner if a channel fails. This is where automation earns its place, since nobody should be manually re-copying the same title, link, excerpt, and image into four different systems, as long as editorial control stays intact where it matters.</p>
<h3>7. Measurement and iteration</h3>
<p>Publishing isn&#39;t the finish line. The company should come back to each piece and check whether visibility increased, rankings improved, clicks went up, the page attracted the intended audience, prospects engaged with related content, the article shaped sales conversations, it generated qualified inquiries, sales started reusing it, it resolved a question that used to eat staff time, and whether it showed up in an AI-generated answer.</p>
<table>
<thead>
<tr>
<th>Result</th>
<th>Likely next step</th>
</tr>
</thead>
<tbody><tr>
<td>Strong performance</td>
<td>Build a supporting comparison, calculator, industry variation, or deeper guide</td>
</tr>
<tr>
<td>Weak performance</td>
<td>Rework the title or intent, add evidence, improve internal links, consolidate, or retire</td>
</tr>
</tbody></table>
<p>That feedback loop is what turns publishing into an iterative system instead of a string of one-off campaigns.</p>
<h2>Keep the Website as the Single Source of Truth</h2>
<p>Many B2B companies end up running several disconnected content systems without meaning to: the website team publishes one version, the social team writes another, the email team drafts a third, and sales keeps a fourth in a slide deck. Claims drift apart, images differ, links go stale, and nobody&#39;s certain which version is the approved one anymore.</p>
<p>A stronger model keeps the complete, approved content inside the company-controlled system and lets every other channel receive an adapted version drawn from that source. A single technical article can become a short LinkedIn observation, a visual carousel, a newsletter summary, a sales follow-up link, three separate FAQs, and a section in a broader industry guide, all while staying connected back to one authoritative version that&#39;s easy to update.</p>
<h2>Where AI Fits Inside the System</h2>
<p>AI meaningfully speeds up query analysis, topic clustering, transcript summarization, draft outlines, first drafts, metadata, excerpts, social adaptations, internal-link suggestions, content classification, duplicate detection, translation support, content gap analysis, and review checklists. It performs best when processing and organizing material that&#39;s already grounded in real company data and expert input, and performs noticeably worse when asked to generate authoritative-sounding content without reliable source material behind it.</p>
<p>For an established B2B company, the workflow that actually holds up gives AI access to approved information, relevant source material, editorial rules, existing content, and structured data, then lets it prepare the useful work, while people make the calls involving truth, judgment, risk, and positioning. That&#39;s controlled acceleration, and it&#39;s a meaningfully different target than fully autonomous publishing.</p>
<h2>Why Automating Too Early Backfires</h2>
<p>Once a company sees the potential here, the instinct to automate everything at once is understandable. Collect the data, identify topics, generate content, publish it, distribute it, measure it, all running on its own. Automation layered onto a process the team doesn&#39;t fully understand yet tends to produce repetitive content, inaccurate claims, weak topic selection, duplicate pages, unclear ownership, excessive review overhead, inconsistent classification, context-free social posts, and a growing pile of material nobody actually trusts.</p>
<p>Running the workflow manually first surfaces which decisions are genuinely stable and which ones need ongoing judgment. A workable progression:</p>
<p><strong>Stage 1: Manual execution.</strong> The team runs every step deliberately and documents what actually happens.</p>
<p><strong>Stage 2: Standardization.</strong> Templates, checklists, roles, required fields, and approval rules get defined.</p>
<p><strong>Stage 3: Assisted execution.</strong> AI and automation support research, drafting, classification, and distribution while people stay closely involved.</p>
<p><strong>Stage 4: Selective automation.</strong> Stable, low-risk steps run automatically with human review at defined checkpoints.</p>
<p><strong>Stage 5: Continuous optimization.</strong> Performance data drives prioritization and the system keeps getting more efficient.</p>
<p>This feels slower at the start. It&#39;s consistently faster than untangling a badly automated system after the fact.</p>
<h2>Manual Overrides Belong in the System</h2>
<p>Trying to remove every manual decision from a content system is rarely the right target for B2B publishing. Automatic related-content recommendations can run correctly most of the time and still need an editor&#39;s override on occasion. A social post can generate automatically while a specific campaign still needs custom messaging written by hand. A standard approval route can cover most articles while a regulated topic routes through additional review.</p>
<p>A good content operating system automates the predictable parts and keeps editorial judgment intact everywhere it&#39;s actually needed, so people spend their time on the decisions that genuinely require them.</p>
<h2>Clear Ownership Matters More Than the Tech Stack</h2>
<p>A company can build a technically excellent workflow and still watch it stall if ownership is vague. Each part of the system needs a named responsible person or role: marketing owns opportunity planning, sales contributes customer questions and commercial context, technical teams validate accuracy, leadership approves strategic positioning where required, compliance reviews regulated claims, a content owner manages publication, a platform owner maintains the CMS and automations, and marketing operations reviews performance.</p>
<p>In a mid-market company, one person often holds several of these roles at once, which is fine as long as each responsibility is explicit rather than assumed. Vague ownership is what stalls content between departments. Visible ownership is what makes the workflow measurable and improvable over time.</p>
<h2>A Realistic First Version</h2>
<p>A content operating system doesn&#39;t need to launch as a company-wide transformation. Pilot it around one commercially important service, product, industry, or problem, and define the demand input (which search queries, sales questions, and market signals get reviewed), the expert source (who holds the most useful practical knowledge), the output (page improvement, article, comparison, FAQ set, case study, or tool), the review step (who validates accuracy and approves publication), the CMS structure (which fields, categories, and relationships are required), the distribution plan (which channels receive adapted versions), and the measurement plan (which outcomes get reviewed after publishing).</p>
<p>Run that loop several times, document the delays, confusion, and repeated manual work that show up, and refine the workflow before investing heavily in automating any of it.</p>
<h2>An Example Operating Flow</h2>
<p>A practical version of this in motion: search and sales data surface a recurring customer question, marketing writes a content brief, a subject-matter expert sits for a recorded interview, AI converts the transcript and existing documents into an outline, marketing produces the first draft, the expert reviews the technical sections, the content owner finalizes the page, required CMS fields and relationships get completed, the approved article publishes, automation prepares LinkedIn and newsletter versions, marketing reviews and releases those adaptations, analytics and search performance get monitored, the team decides whether to update the page or build supporting material, and the underlying knowledge gets retained for future content and internal use.</p>
<p>Every step in that sequence has an input, an owner, an output, and a decision attached to it, which is what actually makes it an operating system rather than a workflow diagram.</p>
<h2>The Value Reaches Past Marketing</h2>
<p>A well-designed content operating system pays off outside the marketing function too. Sales gains reliable resources to send prospects before or after a call. Onboarding gives new employees structured explanations and real project evidence to learn from. Customer support gets consistent answers to recurring questions. Recruitment candidates get a clearer picture of the company&#39;s actual work. Approved company knowledge becomes usable inside internal assistants, search tools, and automated workflows. Institutional knowledge stays accessible after an experienced employee retires or leaves. And the company&#39;s visible digital presence starts to actually reflect its real level of experience, which matters most for companies whose current site understates what they know.</p>
<h2>When a CMS Project Turns Into an Operating-Model Project</h2>
<p>Companies often start this conversation asking for a better editor, more flexible pages, reusable components, improved SEO fields, easier publishing, social integration, and AI features. Those are legitimate requirements on their own. Partway through implementation, the deeper questions surface anyway: who owns the content, how should categories work, which content should be reusable, when should related content appear automatically, what requires approval, which system is the source of truth, where should AI be used, how should content move into social or email, and what should get measured.</p>
<p>At that point the project has moved past the website. It&#39;s really about how the company turns internal knowledge into market-facing assets, with the technology supporting that model rather than accidentally defining it.</p>
<h2>What This Actually Comes Down To</h2>
<p>A CMS stores an article. A content operating system explains why the article exists, where its information came from, who approved it, how it connects to everything else the company has published, where it gets distributed, and how the company decides what comes next.</p>
<p>Most established mid-market B2B companies already have the expertise, the data, and the tools this system needs. What&#39;s usually missing is the connective layer between them. Once that layer exists, the company stops depending on occasional bursts of publishing energy and gains a repeatable way to find useful opportunities, capture what its people know, produce credible material, distribute it efficiently, and learn from what happens next.</p>
<h2>FAQ</h2>
<p><strong>Do we need new software to build a content operating system?</strong>
Usually not right away. Most of the seven components run on tools you already have. The missing piece is almost always the defined process connecting them, not another platform.</p>
<p><strong>Where should a small marketing team start if this feels overwhelming?</strong>
Pick one component that&#39;s causing the most visible pain, usually knowledge capture or governance, and fix that one piece around a single pilot topic before touching the rest.</p>
<p><strong>How do we handle governance without slowing everything down?</strong>
Scale review to risk. A blog observation needs a light check. A technical or regulated claim needs a real expert or compliance review. Treating every piece the same is what creates the slowdown.</p>
<p><strong>What happens to this system when a key subject-matter expert leaves?</strong>
That&#39;s exactly the failure mode a structured knowledge-capture step protects against. Recorded interviews, structured notes, and a documented CMS reduce how much walks out the door with any one person.</p>
<p><strong>Is this workflow only relevant for large enterprises?</strong>
No. It&#39;s designed for mid-market companies specifically, since they have enough complexity to need structure and rarely have the headcount to run content by adding more people to the problem.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical guides on building content systems that hold up under scrutiny.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/content-operating-system-mid-market-b2b</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/content-operating-system-mid-market-b2b</guid>
            <category><![CDATA[AI]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Mon, 27 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;If publishing one article at your company still takes a spreadsheet, five email threads, and a last-minute approval from someone who&amp;#39;s never seen the draft, a new CMS won&amp;#39;t fix that. This guide breaks down the seven components of a real content operating system: demand intelligence, knowledge capture, editorial production, expert validation, structured publishing, distribution, and measurement, plus where AI genuinely helps inside that system and where automating too early backfires.&lt;/p&gt;
&lt;p&gt;I&amp;#39;ve walked several mid-market B2B clients through exactly this gap: companies with a CMS, a CRM, Search Console, an email platform, project archives, and a handful of AI subscriptions, still watching one useful article crawl through the organization for weeks before publishing. The software was never the missing piece. The missing piece was a defined system connecting the people, the data, and the decisions, and that&amp;#39;s what I want to walk through here.&lt;/p&gt;
&lt;h2&gt;The Software Was Never the Bottleneck&lt;/h2&gt;
&lt;p&gt;A CMS gives your team somewhere to store and publish content. A content operating system defines how useful ideas get identified, how internal expertise gets captured, how material gets reviewed, how it gets structured, how it gets distributed, and how performance feeds back into the next decision. Those are two different layers, and confusing them is why so many CMS migrations disappoint six months later.&lt;/p&gt;
&lt;p&gt;An established B2B company can have decades of experience, a strong reputation, and employees who understand the market at real depth, and still watch most of that knowledge stay trapped in conversations, project folders, email threads, and individual heads instead of reaching the website. A handful of ideas make it through to become content. The rest simply never gets captured, and no CMS upgrade changes that on its own.&lt;/p&gt;
&lt;h2&gt;A Better CMS Improves the Editor, Not the Workflow&lt;/h2&gt;
&lt;p&gt;Replacing an old CMS genuinely improves the editing experience: easier page building, reusable components, multi-language support, better permissions, less developer dependency. Those are real wins, and none of them answer the operational questions that determine whether the company actually publishes useful content on a consistent basis.&lt;/p&gt;
&lt;p&gt;Questions like who decides what gets published, which data informs that decision, who provides the technical substance, who owns the first draft, who verifies factual claims, which approvals are actually required, where supporting images and files live, which fields are mandatory before something goes live, how content gets adapted for social and email, who checks whether it performed, how a successful topic gets expanded, how a weak page gets improved, and what happens to institutional knowledge when the expert who provided it leaves the company. A CMS can support each of these steps technically. Designing the actual process is separate work, and it&amp;#39;s the work most companies skip.&lt;/p&gt;
&lt;h2&gt;The Pattern I See Constantly in Mid-Market Companies&lt;/h2&gt;
&lt;p&gt;This shows up repeatedly in established technical, industrial, manufacturing, distribution, and specialist service companies: too large to run content informally, not large enough to staff a dedicated team for every stage of production. Marketing is a small internal team, sometimes supported by agencies or freelancers. Technical staff hold valuable knowledge and limited time. Sales hears customer questions directly and rarely documents them systematically. Leadership wants more visibility, more leads, and better use of AI, while staying protective of accuracy and brand voice.&lt;/p&gt;
&lt;p&gt;The result is a company with enough tools to run a sophisticated system and a workflow that stays improvised in practice. A typical article moves through something like this: marketing proposes a topic, a subject-matter expert gets asked for input, the expert sends a few notes two weeks later, marketing drafts from those notes, the draft circulates by email with comments scattered across three different threads, nobody&amp;#39;s quite sure who has final approval, the approved copy gets manually pasted into the CMS, images get requested from another department, a LinkedIn version gets written separately, the newsletter team copies part of the article into another platform, campaign tracking gets added inconsistently, and the article publishes with nobody reviewing it again unless something&amp;#39;s obviously broken.&lt;/p&gt;
&lt;p&gt;That produces content. It doesn&amp;#39;t build a repeatable operating capability, and those are different outcomes worth telling apart.&lt;/p&gt;
&lt;h2&gt;Content Operations Start Before the First Draft&lt;/h2&gt;
&lt;p&gt;Most content discussions start with the article itself, which is already too late. A stronger system starts with opportunity identification: a repeatable method for deciding which problems deserve content and which format fits, drawing on Search Console data, website analytics, paid search data, sales questions, CRM notes, support requests, product launches, project experience, competitor activity, regulatory changes, internal strategic priorities, and gaps flagged by subject-matter experts.&lt;/p&gt;
&lt;p&gt;The goal isn&amp;#39;t letting data dictate every decision. It&amp;#39;s keeping the content calendar from being run entirely by opinion, habit, or whoever leadership talked to last. For each proposed piece, the company should be able to state the customer problem it addresses, the evidence that the topic matters, the intended audience, where it sits in the buying process, what the company knows that competitors likely don&amp;#39;t, which business objective it supports, what the reader should do next, and how success will be measured. That connects content directly to commercial value instead of leaving the connection implicit.&lt;/p&gt;
&lt;h2&gt;The Seven Components of a Content Operating System&lt;/h2&gt;
&lt;h3&gt;1. Demand intelligence&lt;/h3&gt;
&lt;p&gt;Demand intelligence identifies what the market is asking, comparing, evaluating, or trying to solve, pulling from search data, CRM information, sales observations, customer interviews, support questions, and market trends. The output should be &lt;a href=&quot;/blog/search-led-content-workflow-b2b-prioritize-wins&quot;&gt;a prioritized list of opportunities&lt;/a&gt; tied to existing pages, services, products, industries, or customer journeys, not a keyword spreadsheet nobody opens again.&lt;/p&gt;
&lt;p&gt;A useful output might surface that a service page has high impressions and few clicks, that prospects keep confusing two product categories, that a technical question comes up in nearly every sales call, that several regional projects could support a location page, that buyers want a rough estimate before contacting sales, or that a page sitting near page one could climb with a focused round of additions. Demand intelligence answers where the company should invest attention next.&lt;/p&gt;
&lt;h3&gt;2. Knowledge capture&lt;/h3&gt;
&lt;p&gt;Once an opportunity is chosen, the company needs an efficient way to &lt;a href=&quot;/blog/b2b-content-strategy-turn-decades-expertise-into-traffic&quot;&gt;pull the relevant internal expertise out of the people who hold it&lt;/a&gt;, without handing an engineer, salesperson, or executive a blank page and asking them to write. A structured input works better: a recorded interview, a short workshop, a questionnaire, notes from a sales call, a project review, existing proposals, technical documentation, customer objections, photographs and diagrams, relevant data, and pre-approved claims and qualifications.&lt;/p&gt;
&lt;p&gt;Expert participation should stay focused and time-limited. A 45-minute interview with the right person is often worth more than several weeks of delayed comments on a generic draft. The expert supplies judgment, context, examples, and corrections. The content team turns that into a usable structure.&lt;/p&gt;
&lt;h3&gt;3. Editorial production&lt;/h3&gt;
&lt;p&gt;Editorial production turns the source material into the right format: a service page improvement, a technical article, a comparison guide, a case study, an FAQ, a buyer&amp;#39;s checklist, a regional landing page, an industry page, a calculator, a downloadable resource, a short social post, or a newsletter piece. The format decision should follow the customer&amp;#39;s actual need. Some questions resolve better through a calculator, estimator, or guided form than through another 2,000-word article, and a deliberate system makes that call each time instead of defaulting to a blog post by habit.&lt;/p&gt;
&lt;h3&gt;4. Expert validation and governance&lt;/h3&gt;
&lt;p&gt;Factual review isn&amp;#39;t optional for expertise-rich B2B companies, since the content in question often touches technical performance, compliance, safety, manufacturing, implementation, or operational risk. A polished piece carrying a misleading generalization damages trust fast. The governance layer should name who verifies technical accuracy, who approves commercial claims, who checks legal or regulatory exposure, who owns brand and editorial quality, who holds final publishing authority, and which changes trigger reapproval.&lt;/p&gt;
&lt;p&gt;The level of review should scale with the risk: a company announcement needs a light marketing sign-off, a technical guide needs an engineer or product specialist&amp;#39;s review, and a regulated claim needs legal or compliance approval. Distinguishing these cases up front keeps the company from publishing too loosely on one end or building an approval chain so heavy that nothing ships on the other.&lt;/p&gt;
&lt;h3&gt;5. Structured CMS publishing&lt;/h3&gt;
&lt;p&gt;The CMS should hold more than a single rich-text field. Depending on content type, useful fields include title, summary, body, author, expert reviewer, publication date, review date, content owner, category, industry, product or service relationship, geographic relevance, featured image, social image, SEO title and description, FAQs, related projects, related articles, calls to action, campaign parameters, approval status, translation status, and revision history.&lt;/p&gt;
&lt;p&gt;That structure is what makes content reusable: a project entered once can surface on a service page, an industry page, a regional page, and a related-content block; an FAQ stored centrally can appear on every page it&amp;#39;s relevant to; an article can feed a resource library, related-content recommendations, social distribution, and a newsletter, all from a single structured source. The CMS earns its value by representing the relationships inside the company&amp;#39;s knowledge, not just the layout of individual pages.&lt;/p&gt;
&lt;h3&gt;6. Distribution workflows&lt;/h3&gt;
&lt;p&gt;Publishing on the website is the start of distribution, not the end of it. Approved content can adapt across LinkedIn, Facebook, email newsletters, sales enablement, internal knowledge systems, partner communications, customer onboarding, and industry portals, with the website staying the canonical source. Social and email should extend that source rather than becoming disconnected homes for the company&amp;#39;s best material with no ownership behind them.&lt;/p&gt;
&lt;p&gt;A practical distribution workflow detects that an approved article has published, reads its title, summary, image, URL, category, and campaign fields, prepares channel-specific drafts, routes them for review, publishes the approved versions, applies consistent tracking parameters, records status, and flags the content owner if a channel fails. This is where automation earns its place, since nobody should be manually re-copying the same title, link, excerpt, and image into four different systems, as long as editorial control stays intact where it matters.&lt;/p&gt;
&lt;h3&gt;7. Measurement and iteration&lt;/h3&gt;
&lt;p&gt;Publishing isn&amp;#39;t the finish line. The company should come back to each piece and check whether visibility increased, rankings improved, clicks went up, the page attracted the intended audience, prospects engaged with related content, the article shaped sales conversations, it generated qualified inquiries, sales started reusing it, it resolved a question that used to eat staff time, and whether it showed up in an AI-generated answer.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Likely next step&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Strong performance&lt;/td&gt;
&lt;td&gt;Build a supporting comparison, calculator, industry variation, or deeper guide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weak performance&lt;/td&gt;
&lt;td&gt;Rework the title or intent, add evidence, improve internal links, consolidate, or retire&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;That feedback loop is what turns publishing into an iterative system instead of a string of one-off campaigns.&lt;/p&gt;
&lt;h2&gt;Keep the Website as the Single Source of Truth&lt;/h2&gt;
&lt;p&gt;Many B2B companies end up running several disconnected content systems without meaning to: the website team publishes one version, the social team writes another, the email team drafts a third, and sales keeps a fourth in a slide deck. Claims drift apart, images differ, links go stale, and nobody&amp;#39;s certain which version is the approved one anymore.&lt;/p&gt;
&lt;p&gt;A stronger model keeps the complete, approved content inside the company-controlled system and lets every other channel receive an adapted version drawn from that source. A single technical article can become a short LinkedIn observation, a visual carousel, a newsletter summary, a sales follow-up link, three separate FAQs, and a section in a broader industry guide, all while staying connected back to one authoritative version that&amp;#39;s easy to update.&lt;/p&gt;
&lt;h2&gt;Where AI Fits Inside the System&lt;/h2&gt;
&lt;p&gt;AI meaningfully speeds up query analysis, topic clustering, transcript summarization, draft outlines, first drafts, metadata, excerpts, social adaptations, internal-link suggestions, content classification, duplicate detection, translation support, content gap analysis, and review checklists. It performs best when processing and organizing material that&amp;#39;s already grounded in real company data and expert input, and performs noticeably worse when asked to generate authoritative-sounding content without reliable source material behind it.&lt;/p&gt;
&lt;p&gt;For an established B2B company, the workflow that actually holds up gives AI access to approved information, relevant source material, editorial rules, existing content, and structured data, then lets it prepare the useful work, while people make the calls involving truth, judgment, risk, and positioning. That&amp;#39;s controlled acceleration, and it&amp;#39;s a meaningfully different target than fully autonomous publishing.&lt;/p&gt;
&lt;h2&gt;Why Automating Too Early Backfires&lt;/h2&gt;
&lt;p&gt;Once a company sees the potential here, the instinct to automate everything at once is understandable. Collect the data, identify topics, generate content, publish it, distribute it, measure it, all running on its own. Automation layered onto a process the team doesn&amp;#39;t fully understand yet tends to produce repetitive content, inaccurate claims, weak topic selection, duplicate pages, unclear ownership, excessive review overhead, inconsistent classification, context-free social posts, and a growing pile of material nobody actually trusts.&lt;/p&gt;
&lt;p&gt;Running the workflow manually first surfaces which decisions are genuinely stable and which ones need ongoing judgment. A workable progression:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stage 1: Manual execution.&lt;/strong&gt; The team runs every step deliberately and documents what actually happens.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stage 2: Standardization.&lt;/strong&gt; Templates, checklists, roles, required fields, and approval rules get defined.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stage 3: Assisted execution.&lt;/strong&gt; AI and automation support research, drafting, classification, and distribution while people stay closely involved.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stage 4: Selective automation.&lt;/strong&gt; Stable, low-risk steps run automatically with human review at defined checkpoints.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stage 5: Continuous optimization.&lt;/strong&gt; Performance data drives prioritization and the system keeps getting more efficient.&lt;/p&gt;
&lt;p&gt;This feels slower at the start. It&amp;#39;s consistently faster than untangling a badly automated system after the fact.&lt;/p&gt;
&lt;h2&gt;Manual Overrides Belong in the System&lt;/h2&gt;
&lt;p&gt;Trying to remove every manual decision from a content system is rarely the right target for B2B publishing. Automatic related-content recommendations can run correctly most of the time and still need an editor&amp;#39;s override on occasion. A social post can generate automatically while a specific campaign still needs custom messaging written by hand. A standard approval route can cover most articles while a regulated topic routes through additional review.&lt;/p&gt;
&lt;p&gt;A good content operating system automates the predictable parts and keeps editorial judgment intact everywhere it&amp;#39;s actually needed, so people spend their time on the decisions that genuinely require them.&lt;/p&gt;
&lt;h2&gt;Clear Ownership Matters More Than the Tech Stack&lt;/h2&gt;
&lt;p&gt;A company can build a technically excellent workflow and still watch it stall if ownership is vague. Each part of the system needs a named responsible person or role: marketing owns opportunity planning, sales contributes customer questions and commercial context, technical teams validate accuracy, leadership approves strategic positioning where required, compliance reviews regulated claims, a content owner manages publication, a platform owner maintains the CMS and automations, and marketing operations reviews performance.&lt;/p&gt;
&lt;p&gt;In a mid-market company, one person often holds several of these roles at once, which is fine as long as each responsibility is explicit rather than assumed. Vague ownership is what stalls content between departments. Visible ownership is what makes the workflow measurable and improvable over time.&lt;/p&gt;
&lt;h2&gt;A Realistic First Version&lt;/h2&gt;
&lt;p&gt;A content operating system doesn&amp;#39;t need to launch as a company-wide transformation. Pilot it around one commercially important service, product, industry, or problem, and define the demand input (which search queries, sales questions, and market signals get reviewed), the expert source (who holds the most useful practical knowledge), the output (page improvement, article, comparison, FAQ set, case study, or tool), the review step (who validates accuracy and approves publication), the CMS structure (which fields, categories, and relationships are required), the distribution plan (which channels receive adapted versions), and the measurement plan (which outcomes get reviewed after publishing).&lt;/p&gt;
&lt;p&gt;Run that loop several times, document the delays, confusion, and repeated manual work that show up, and refine the workflow before investing heavily in automating any of it.&lt;/p&gt;
&lt;h2&gt;An Example Operating Flow&lt;/h2&gt;
&lt;p&gt;A practical version of this in motion: search and sales data surface a recurring customer question, marketing writes a content brief, a subject-matter expert sits for a recorded interview, AI converts the transcript and existing documents into an outline, marketing produces the first draft, the expert reviews the technical sections, the content owner finalizes the page, required CMS fields and relationships get completed, the approved article publishes, automation prepares LinkedIn and newsletter versions, marketing reviews and releases those adaptations, analytics and search performance get monitored, the team decides whether to update the page or build supporting material, and the underlying knowledge gets retained for future content and internal use.&lt;/p&gt;
&lt;p&gt;Every step in that sequence has an input, an owner, an output, and a decision attached to it, which is what actually makes it an operating system rather than a workflow diagram.&lt;/p&gt;
&lt;h2&gt;The Value Reaches Past Marketing&lt;/h2&gt;
&lt;p&gt;A well-designed content operating system pays off outside the marketing function too. Sales gains reliable resources to send prospects before or after a call. Onboarding gives new employees structured explanations and real project evidence to learn from. Customer support gets consistent answers to recurring questions. Recruitment candidates get a clearer picture of the company&amp;#39;s actual work. Approved company knowledge becomes usable inside internal assistants, search tools, and automated workflows. Institutional knowledge stays accessible after an experienced employee retires or leaves. And the company&amp;#39;s visible digital presence starts to actually reflect its real level of experience, which matters most for companies whose current site understates what they know.&lt;/p&gt;
&lt;h2&gt;When a CMS Project Turns Into an Operating-Model Project&lt;/h2&gt;
&lt;p&gt;Companies often start this conversation asking for a better editor, more flexible pages, reusable components, improved SEO fields, easier publishing, social integration, and AI features. Those are legitimate requirements on their own. Partway through implementation, the deeper questions surface anyway: who owns the content, how should categories work, which content should be reusable, when should related content appear automatically, what requires approval, which system is the source of truth, where should AI be used, how should content move into social or email, and what should get measured.&lt;/p&gt;
&lt;p&gt;At that point the project has moved past the website. It&amp;#39;s really about how the company turns internal knowledge into market-facing assets, with the technology supporting that model rather than accidentally defining it.&lt;/p&gt;
&lt;h2&gt;What This Actually Comes Down To&lt;/h2&gt;
&lt;p&gt;A CMS stores an article. A content operating system explains why the article exists, where its information came from, who approved it, how it connects to everything else the company has published, where it gets distributed, and how the company decides what comes next.&lt;/p&gt;
&lt;p&gt;Most established mid-market B2B companies already have the expertise, the data, and the tools this system needs. What&amp;#39;s usually missing is the connective layer between them. Once that layer exists, the company stops depending on occasional bursts of publishing energy and gains a repeatable way to find useful opportunities, capture what its people know, produce credible material, distribute it efficiently, and learn from what happens next.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Do we need new software to build a content operating system?&lt;/strong&gt;
Usually not right away. Most of the seven components run on tools you already have. The missing piece is almost always the defined process connecting them, not another platform.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where should a small marketing team start if this feels overwhelming?&lt;/strong&gt;
Pick one component that&amp;#39;s causing the most visible pain, usually knowledge capture or governance, and fix that one piece around a single pilot topic before touching the rest.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How do we handle governance without slowing everything down?&lt;/strong&gt;
Scale review to risk. A blog observation needs a light check. A technical or regulated claim needs a real expert or compliance review. Treating every piece the same is what creates the slowdown.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens to this system when a key subject-matter expert leaves?&lt;/strong&gt;
That&amp;#39;s exactly the failure mode a structured knowledge-capture step protects against. Recorded interviews, structured notes, and a documented CMS reduce how much walks out the door with any one person.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is this workflow only relevant for large enterprises?&lt;/strong&gt;
No. It&amp;#39;s designed for mid-market companies specifically, since they have enough complexity to need structure and rarely have the headcount to run content by adding more people to the problem.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical guides on building content systems that hold up under scrutiny.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/content-operating-system-mid-market-b2b"/>
        </item>
        <item>
            <title><![CDATA[Search-Led Content Workflow for B2B: Prioritize Wins]]></title>
            <description><![CDATA[<p>If your content backlog is full of good ideas and your team still can&#39;t agree on what to publish next, the fix is a prioritization system, not another brainstorm. This guide walks through a workflow that starts from Google Search Console and Keyword Planner data instead of opinion: how to find pages that are already getting impressions but losing clicks, how to use AI to process hundreds of queries into a real publishing plan, how to match the right format to each opportunity, and how to run this as <a href="/blog/content-operating-system-mid-market-b2b">a repeatable monthly process</a> instead of a one-off audit.</p>
<p>I built this workflow running SEO audits and content clustering for established B2B clients, where the challenge was never a shortage of topics. Sales had a list of recurring questions, product teams wanted to explain technical differences, leadership had strategic themes to push, and marketing had a spreadsheet of keywords and half-finished drafts. What was missing was a defensible way to decide what to write first. That&#39;s what this workflow solves.</p>
<h2>The Real Problem Is Prioritization, Not Ideas</h2>
<p>Ask a sales or technical team at a decades-old B2B company to list content topics, and you&#39;ll get a year&#39;s worth of ideas inside one workshop. They know the recurring customer questions, the technical constraints, the purchasing risks, and the misconceptions that trip up less experienced buyers.</p>
<p>Ideas at that volume need a filter, because they carry very different amounts of value. Some questions come up constantly in sales calls but attract almost no search volume. Some keywords pull large audiences that have nothing to do with your actual buyers. Some topics are commercially important but so competitive that a single new article won&#39;t move the needle without a lot of supporting content behind it. Meanwhile, some of the biggest opportunities are already sitting on your own website: a page pulling thousands of impressions, ranked near the bottom of page one, converting almost none of that visibility into clicks.</p>
<p>Without a structured filter, content planning turns into a negotiation. The most senior person in the room picks the topic, marketing chases whatever trend is current, a competitor publishes something and gets copied, or an agency hands over a keyword spreadsheet nobody knows how to turn into a schedule. The output is a calendar full of reasonable ideas with no real order behind it.</p>
<p>Replacing &quot;what should we write about&quot; with &quot;<a href="/blog/b2b-content-strategy-turn-decades-expertise-into-traffic">where is demand already proven</a> and where is our site failing to capture it&quot; turns content planning into a commercial investigation with evidence behind every decision.</p>
<h2>Start From What You Already Own</h2>
<p>Your website is probably already appearing for hundreds or thousands of relevant queries, whether people click through or not. Google Search Console shows exactly that: for any page, how many times it appeared in results, how many clicks it got, the click-through rate, the average position, the specific queries tied to it, and how all of that has moved over time.</p>
<p>That&#39;s demand attached to assets you already control. Google has already connected parts of your site to specific subjects, so the real question becomes whether those pages satisfy the searcher well enough to earn stronger visibility and more clicks. For a mid-market B2B company, improving a page that already has impressions is a more predictable move than publishing something new and waiting to find out whether Google decides it deserves to rank.</p>
<h2>Four Signals That Reveal Real Opportunities</h2>
<p>No single number tells you where to focus. A genuine opportunity shows up through a combination of four signals working together.</p>
<h3>Impressions</h3>
<p>Impressions tell you how often a page or query showed up in search results, which signals that Google already sees a connection between the search and your content. A page can rack up thousands of impressions and still convert almost none of them into visits, and that gap is frequently where the real opportunity sits.</p>
<p>Take a technical product page pulling 12,000 impressions, 24 clicks, a 0.2 percent click-through rate, and an average position of 9.2. Google has already surfaced this page for the right searches. The content, presentation, or positioning is failing to close the loop. Common causes include weak titles, a description that misses the search intent, thin content, a poor match between query and page, tougher competitors, missing supporting material, or a position that&#39;s technically on page one but far enough down to get skipped. The first move here is checking whether the existing page can be fixed before deciding to write something new.</p>
<h3>Average position</h3>
<p>Position tells you how far a page sits from meaningful visibility. A page ranking around 8 through 11 is often a realistic near-term win: a modest improvement can move it several places and meaningfully increase traffic. A page sitting around position 48 is a different kind of project, usually requiring stronger content, more internal and external links, or a broader supporting cluster before it climbs. Both are worth pursuing, they just carry different expected timelines and effort.</p>
<h3>Click-through rate</h3>
<p>Click-through rate shows how often people pick your result once they&#39;ve seen it. A low rate needs context before you act on it: the page might rank too low to attract clicks, the query might only loosely match your content, an AI-generated answer might be sitting above your result, or the search intent might not match what your page delivers.</p>
<p>When a page combines high impressions, a reasonable position, and a very low click-through rate, that combination is worth investigating directly. The fix is sometimes editorial and sometimes technical or presentational: a sharper title, a clearer description, a relevant image, a stronger answer near the top of the page, or wording that matches how people are actually searching.</p>
<h3>Commercial relevance</h3>
<p>Search volume doesn&#39;t know your business priorities. A high-volume keyword can produce very little commercial value, while a lower-volume one can signal a buyer close to a real decision. Sorting queries by intent makes this visible:</p>
<table>
<thead>
<tr>
<th>Intent type</th>
<th>Example queries</th>
<th>Typical role</th>
</tr>
</thead>
<tbody><tr>
<td>Informational</td>
<td>&quot;what is X&quot;, &quot;how does X work&quot;</td>
<td>Builds awareness, supports SEO breadth</td>
</tr>
<tr>
<td>Comparison</td>
<td>&quot;product A vs product B&quot;, &quot;which material is better&quot;</td>
<td>Supports active evaluation</td>
</tr>
<tr>
<td>Commercial investigation</td>
<td>&quot;best solution for X environment&quot;, &quot;typical cost of X&quot;</td>
<td>Signals a buyer close to shortlisting</td>
</tr>
<tr>
<td>Transactional</td>
<td>&quot;request a quote&quot;, &quot;find a provider near me&quot;</td>
<td>Signals immediate purchase intent</td>
</tr>
</tbody></table>
<p>All four categories carry value, but the highest-volume query is rarely the best first investment. A lower-volume comparison or decision-stage topic frequently pulls in more qualified buyers than a broad informational one.</p>
<h2>Go From Site-Level Queries to Page-Level Patterns</h2>
<p>Reviewing your site&#39;s top queries as one giant list gives a useful overview and is too broad to plan content from directly. Established company sites carry a lot of branded traffic: people searching your company name, locations, staff, or existing products. That traffic matters for other reasons and doesn&#39;t represent new market discovery.</p>
<p>Non-branded searches are where the growth signal lives, because those are people who may not know your company yet. The practical steps: open the performance report, switch the view from queries to pages, pull out commercially relevant pages with meaningful impressions, filter out anything dominated by branded or navigational traffic, then open one page at a time and review only the queries tied to it.</p>
<p>That page-level view is where the useful patterns show up. A single service page might be tied to hundreds of query variations covering definitions, product names, misspellings, use cases, comparisons, location terms, price questions, industry-specific phrasing, and a fair amount of noise. That list can look chaotic at first glance, and that&#39;s exactly the point where AI earns its place in the workflow.</p>
<h2>Let AI Process the Volume, Keep the Decisions Human</h2>
<p>Reading through several hundred queries by hand is possible and a poor use of a senior marketer&#39;s or technical expert&#39;s time. Export the page-level queries and metrics to a spreadsheet, hand it to Claude, ChatGPT, or another model, and ask it to strip out irrelevant queries, group similar phrases, separate branded from non-branded terms, flag question-based searches, classify intent, surface recurring themes, highlight high-impression low-click pages, and suggest whether each theme belongs on the existing page or warrants its own asset.</p>
<p>The AI here is interpreting demand Google has already recorded, not inventing it. A generic prompt like &quot;give me blog ideas for an industrial company&quot; produces plausible-sounding topics based on broad patterns. A prompt built from your own page-level queries, impressions, clicks, positions, and commercial context produces something grounded in your actual market presence, which is a meaningfully stronger starting point even though it still needs human review.</p>
<p>A workable prompt structure:</p>
<pre><code class="language-text">// File: prompts/content-opportunity-analysis.txt
Analyze the attached Search Console export for this service page. 
Remove irrelevant and branded queries. Group the remaining queries 
by search intent and recurring subject. Identify opportunities to 
improve the existing page, create FAQs, publish supporting articles, 
build comparison content, or create commercial landing pages. 
Prioritize opportunities using impressions, current position, 
click-through rate, and likely relevance to a mid-market B2B buyer. 
Do not recommend separate pages when they would substantially 
duplicate the existing page.
</code></pre>
<p>Layering in company context sharpens the output further: what you sell, your typical buyer, geographic coverage, average project value, key industries, sales cycle length, technical constraints, existing content, and any topics you deliberately want to avoid targeting.</p>
<h2>Add Keyword Planner for the Market View Search Console Can&#39;t Give You</h2>
<p>Search Console shows how your site currently interacts with demand. Google Ads Keyword Planner shows the market around that demand: average monthly searches, recent trends, year-over-year change, advertiser competition, approximate cost per click, and related terms and phrasing.</p>
<p>That data is useful even for companies with no plans to run paid campaigns, because competition and bid estimates give a rough read on commercial interest. Companies generally bid higher when a query connects to valuable business. A high cost-per-click can point to strong buyer intent, heavy competition, high customer value, a tough organic environment, or a broad term pulling in many different buyer types, so it&#39;s a signal to weigh alongside the rest, not a rule to follow on its own. A low-competition query can point to a genuine gap or simply weak commercial relevance, and the surrounding context usually tells you which.</p>
<table>
<thead>
<tr>
<th>Data source</th>
<th>What it reveals</th>
<th>Best used for</th>
</tr>
</thead>
<tbody><tr>
<td>Search Console</td>
<td>Where your site already ranks, impressions, clicks, position</td>
<td>Prioritizing improvements to existing pages</td>
</tr>
<tr>
<td>Keyword Planner</td>
<td>Broader market demand, trends, competition, CPC</td>
<td>Sizing new topics and validating commercial value</td>
</tr>
</tbody></table>
<p>Combined, these two sources answer sharper questions than either does alone: does this page already have momentum, is the topic growing or shrinking, are companies paying to reach this audience, are buyers using different wording than your site does, is the existing page targeting a phrase that&#39;s too broad, and is there enough demand to justify a dedicated comparison or guide.</p>
<h2>Match the Format to the Intent</h2>
<p>Not every opportunity needs a blog post. Picking the right format is one of the highest-leverage decisions in this workflow.</p>
<p><strong>Improve the existing page</strong> when queries connect closely to a page&#39;s central subject. That can mean a clearer definition, better examples, stronger headings, technical specs, relevant images, cost factors, process explanations, embedded FAQs, internal links, or a case study. Creating a separate article to repeat what belongs on the service page itself just splits authority for no benefit.</p>
<p><strong>Add FAQs</strong> for narrow, direct questions: installation time, cost drivers, upgrade paths, maintenance requirements, climate suitability, or what information an estimate requires. FAQs strengthen the existing page instead of forcing the reader to a second resource.</p>
<p><strong>Write a comparison article</strong> when the data reveals genuine uncertainty between two alternatives. A strong comparison explains where each option performs best, the real trade-offs, cost and maintenance differences, and the conditions under which the recommendation changes, rather than declaring a universal winner.</p>
<p><strong>Publish an educational guide</strong> when the topic needs more depth than a commercial page can reasonably hold: how a system works, how to plan a project, common mistakes, procurement considerations, or questions to ask suppliers. The guide should link naturally to the relevant service instead of functioning as a disguised sales page.</p>
<p><strong>Build an industry page</strong> when one sector has distinct requirements, terminology, or project history worth showing directly: relevant services, specific challenges, applicable standards, project examples, and evidence of experience in that environment.</p>
<p><strong>Build a location page</strong> when the company has real service coverage, local projects, and operational relevance to show, connecting services available in the area, local project evidence, and regional considerations. Producing near-duplicate pages with only the city name swapped creates volume without adding value.</p>
<p><strong>Build a tool</strong> when the query itself signals that the user wants a decision rather than an article: cost estimates, sizing, compatibility checks, or eligibility. A calculator, estimator, or guided questionnaire can outperform a long-form article for both discoverability and lead qualification.</p>
<h2>Validate With Sales and Technical Teams Before You Commit</h2>
<p>Search data shows what people ask. It rarely shows why they&#39;re asking, whether the person asking is a serious buyer, or how the company should actually answer. That gap is where internal experience earns its place in the process.</p>
<p>Before committing real production time to a piece, check with sales or technical staff: do customers actually ask this, at what stage of the buying process, is the wording accurate, does it reflect a common misconception, is it commercially valuable, can the company answer it better than competitors, is there project evidence to draw on, are there claims to avoid, and what qualification would an inexperienced writer likely miss.</p>
<p>This step catches a specific failure mode: content that performs well in search while attracting the wrong audience. For a B2B company running lean on marketing capacity, one article that helps a serious buyer through a complex decision is worth more than a broad piece pulling in thousands of students, hobbyists, or unqualified visitors.</p>
<h2>Score Opportunities Instead of Debating Them</h2>
<p>A simple scoring model keeps this comparison consistent across topics. Rate each candidate from one to five on existing visibility, ranking opportunity, commercial intent, business relevance, expertise advantage, evidence availability, format fit, and conversion path.</p>
<p>The highest total shouldn&#39;t automatically set the publishing order, but it gives marketing, sales, product, and leadership a shared basis to debate priorities from instead of arguing from four different sets of assumptions.</p>
<h2>Watch for Keyword Cannibalization</h2>
<p>A large set of related queries can tempt a team into building a separate page for every variation, which usually backfires: multiple pages compete for the same intent, Google struggles to pick the most relevant one, internal link authority splits across pages instead of concentrating, and the company ends up with more maintenance and less depth per page.</p>
<p>Before adding a new page, check whether the query represents a genuinely different question with a substantially different answer, whether it targets a distinct industry, use case, geography, or buying stage the existing page doesn&#39;t cover, and whether the existing page could simply be expanded instead. A single strong commercial page, a handful of focused FAQs, one comparison article, and relevant case studies frequently outperform ten overlapping articles competing with each other.</p>
<h2>Build Topic Clusters as Proof of Expertise</h2>
<p>A single article rarely establishes strong authority on a competitive B2B subject, because search engines and AI systems weigh the broader body of connected content. A solid cluster typically includes a core service page, a detailed guide, a comparison article, several FAQs, a set of case studies, an industry page, a regional page, and a tool, all linked together.</p>
<p>Each piece plays a distinct role: the service page drives commercial action, the guide explains the subject, the comparison supports evaluation, the case studies prove real experience, the FAQ resolves narrow questions, and the tool helps the buyer self-qualify. The cluster works because the pieces cover the subject from connected angles instead of repeating the same text under different titles.</p>
<h2>Run This as a Monthly Process, Not a One-Time Audit</h2>
<p>A workable monthly cadence for a mid-market B2B team:</p>
<p><strong>Week 1 — Identify the opportunity.</strong> Review pages with high impressions, low click-through rates, positions near page one, and strong commercial relevance. Pick one or two for deeper analysis.</p>
<p><strong>Week 2 — Analyze demand.</strong> Export page-level queries, run them through AI for grouping and intent classification, and layer in Keyword Planner data and competitor observations.</p>
<p><strong>Week 3 — Capture expertise.</strong> Interview the relevant salesperson, engineer, or project lead and collect examples, qualifications, and project evidence.</p>
<p><strong>Week 4 — Publish and connect.</strong> Improve the existing page or publish the new asset, add internal links, FAQs, case studies, metadata, and imagery, and distribute through the right channels before tracking performance over the following months.</p>
<p>That cadence is sustainable for a company aiming for steady progress without building a publishing operation it can&#39;t maintain.</p>
<h2>Measure What Actually Moves the Business</h2>
<p>Rankings are a useful proxy and not the actual goal. A fuller measurement set includes growth in non-branded impressions, movement in average position, improved click-through rate, qualified organic visits, engagement with supporting pages, tool completions, contact requests, sales conversations shaped by the content, questions prospects stop needing sales to explain manually, content sales teams start reusing directly, visibility inside AI-generated answers, and revenue tied back to organic discovery.</p>
<p>Not every article produces a directly attributable lead. Some content lifts a service page&#39;s performance, some helps a prospect validate the company after a referral, some shortens the sales cycle, and some simply gives an AI system enough material to reference the company accurately. The underlying objective across all of it is making the company easier to discover and easier to trust.</p>
<h2>Where Judgment Still Has to Come From People</h2>
<p>Data shows where interest exists. AI processes the volume. Search tools estimate demand and competition. None of that carries knowledge of which customers you want more of, which projects are most profitable, which services are operationally constrained, or which topics drive the strongest sales conversations, so the final call stays with people who understand the business.</p>
<p>The workflow holds together because each piece corrects a weakness in the others: search evidence keeps the plan grounded in real demand instead of internal preference, commercial judgment keeps the plan pointed at relevant buyers instead of raw traffic, subject-matter experts protect accuracy, AI absorbs the repetitive analysis and drafting work, and ongoing measurement shows whether the whole thing produced value.</p>
<h2>FAQ</h2>
<p><strong>How much Search Console data do I need before this workflow is useful?</strong>
A few months of history is enough to start. Three to six months gives a more stable read on trends, but you can run the first pass with whatever data you currently have.</p>
<p><strong>Should I always fix an existing page before writing something new?</strong>
Check the existing page first when a query is closely related to a page you already have. If the existing page targets a different intent or a genuinely different audience, a new asset is the better move.</p>
<p><strong>What if a topic scores well but sales says nobody actually asks about it?</strong>
Trust the validation step. Search volume without a real corresponding buyer conversation is a weak signal on its own, and it&#39;s usually better spent on a topic sales can confirm firsthand.</p>
<p><strong>How many queries does a typical service page carry in Search Console?</strong>
Anywhere from a few dozen to several hundred, depending on the page&#39;s age and how broad its subject is. Older, broader pages tend to accumulate the most variation.</p>
<p><strong>Can this workflow work without an SEO specialist on staff?</strong>
Yes, as long as someone owns pulling the Search Console export monthly and someone technical is available to validate accuracy before publishing. The AI-assisted grouping step removes most of the specialist skill this used to require.</p>
<h2>Publish the Next Most Valuable Thing</h2>
<p>Most established B2B companies already have more content ideas than they can execute. The question worth asking isn&#39;t what to write next. It&#39;s where proven demand, existing visibility, commercial relevance, and a real expertise advantage all line up at once.</p>
<p>That question produces a different kind of content plan: one that favors targeted improvements over endless publishing, connects marketing work to real customer uncertainty, gives sales and technical teams a clear reason to contribute, and uses AI as an analytical assistant instead of an idea generator working from outside the business.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical guides on building content systems that hold up under scrutiny.</p>
<p>Thanks, Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/search-led-content-workflow-b2b-prioritize-wins</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/search-led-content-workflow-b2b-prioritize-wins</guid>
            <category><![CDATA[Tools]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Sun, 26 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;If your content backlog is full of good ideas and your team still can&amp;#39;t agree on what to publish next, the fix is a prioritization system, not another brainstorm. This guide walks through a workflow that starts from Google Search Console and Keyword Planner data instead of opinion: how to find pages that are already getting impressions but losing clicks, how to use AI to process hundreds of queries into a real publishing plan, how to match the right format to each opportunity, and how to run this as &lt;a href=&quot;/blog/content-operating-system-mid-market-b2b&quot;&gt;a repeatable monthly process&lt;/a&gt; instead of a one-off audit.&lt;/p&gt;
&lt;p&gt;I built this workflow running SEO audits and content clustering for established B2B clients, where the challenge was never a shortage of topics. Sales had a list of recurring questions, product teams wanted to explain technical differences, leadership had strategic themes to push, and marketing had a spreadsheet of keywords and half-finished drafts. What was missing was a defensible way to decide what to write first. That&amp;#39;s what this workflow solves.&lt;/p&gt;
&lt;h2&gt;The Real Problem Is Prioritization, Not Ideas&lt;/h2&gt;
&lt;p&gt;Ask a sales or technical team at a decades-old B2B company to list content topics, and you&amp;#39;ll get a year&amp;#39;s worth of ideas inside one workshop. They know the recurring customer questions, the technical constraints, the purchasing risks, and the misconceptions that trip up less experienced buyers.&lt;/p&gt;
&lt;p&gt;Ideas at that volume need a filter, because they carry very different amounts of value. Some questions come up constantly in sales calls but attract almost no search volume. Some keywords pull large audiences that have nothing to do with your actual buyers. Some topics are commercially important but so competitive that a single new article won&amp;#39;t move the needle without a lot of supporting content behind it. Meanwhile, some of the biggest opportunities are already sitting on your own website: a page pulling thousands of impressions, ranked near the bottom of page one, converting almost none of that visibility into clicks.&lt;/p&gt;
&lt;p&gt;Without a structured filter, content planning turns into a negotiation. The most senior person in the room picks the topic, marketing chases whatever trend is current, a competitor publishes something and gets copied, or an agency hands over a keyword spreadsheet nobody knows how to turn into a schedule. The output is a calendar full of reasonable ideas with no real order behind it.&lt;/p&gt;
&lt;p&gt;Replacing &amp;quot;what should we write about&amp;quot; with &amp;quot;&lt;a href=&quot;/blog/b2b-content-strategy-turn-decades-expertise-into-traffic&quot;&gt;where is demand already proven&lt;/a&gt; and where is our site failing to capture it&amp;quot; turns content planning into a commercial investigation with evidence behind every decision.&lt;/p&gt;
&lt;h2&gt;Start From What You Already Own&lt;/h2&gt;
&lt;p&gt;Your website is probably already appearing for hundreds or thousands of relevant queries, whether people click through or not. Google Search Console shows exactly that: for any page, how many times it appeared in results, how many clicks it got, the click-through rate, the average position, the specific queries tied to it, and how all of that has moved over time.&lt;/p&gt;
&lt;p&gt;That&amp;#39;s demand attached to assets you already control. Google has already connected parts of your site to specific subjects, so the real question becomes whether those pages satisfy the searcher well enough to earn stronger visibility and more clicks. For a mid-market B2B company, improving a page that already has impressions is a more predictable move than publishing something new and waiting to find out whether Google decides it deserves to rank.&lt;/p&gt;
&lt;h2&gt;Four Signals That Reveal Real Opportunities&lt;/h2&gt;
&lt;p&gt;No single number tells you where to focus. A genuine opportunity shows up through a combination of four signals working together.&lt;/p&gt;
&lt;h3&gt;Impressions&lt;/h3&gt;
&lt;p&gt;Impressions tell you how often a page or query showed up in search results, which signals that Google already sees a connection between the search and your content. A page can rack up thousands of impressions and still convert almost none of them into visits, and that gap is frequently where the real opportunity sits.&lt;/p&gt;
&lt;p&gt;Take a technical product page pulling 12,000 impressions, 24 clicks, a 0.2 percent click-through rate, and an average position of 9.2. Google has already surfaced this page for the right searches. The content, presentation, or positioning is failing to close the loop. Common causes include weak titles, a description that misses the search intent, thin content, a poor match between query and page, tougher competitors, missing supporting material, or a position that&amp;#39;s technically on page one but far enough down to get skipped. The first move here is checking whether the existing page can be fixed before deciding to write something new.&lt;/p&gt;
&lt;h3&gt;Average position&lt;/h3&gt;
&lt;p&gt;Position tells you how far a page sits from meaningful visibility. A page ranking around 8 through 11 is often a realistic near-term win: a modest improvement can move it several places and meaningfully increase traffic. A page sitting around position 48 is a different kind of project, usually requiring stronger content, more internal and external links, or a broader supporting cluster before it climbs. Both are worth pursuing, they just carry different expected timelines and effort.&lt;/p&gt;
&lt;h3&gt;Click-through rate&lt;/h3&gt;
&lt;p&gt;Click-through rate shows how often people pick your result once they&amp;#39;ve seen it. A low rate needs context before you act on it: the page might rank too low to attract clicks, the query might only loosely match your content, an AI-generated answer might be sitting above your result, or the search intent might not match what your page delivers.&lt;/p&gt;
&lt;p&gt;When a page combines high impressions, a reasonable position, and a very low click-through rate, that combination is worth investigating directly. The fix is sometimes editorial and sometimes technical or presentational: a sharper title, a clearer description, a relevant image, a stronger answer near the top of the page, or wording that matches how people are actually searching.&lt;/p&gt;
&lt;h3&gt;Commercial relevance&lt;/h3&gt;
&lt;p&gt;Search volume doesn&amp;#39;t know your business priorities. A high-volume keyword can produce very little commercial value, while a lower-volume one can signal a buyer close to a real decision. Sorting queries by intent makes this visible:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Intent type&lt;/th&gt;
&lt;th&gt;Example queries&lt;/th&gt;
&lt;th&gt;Typical role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Informational&lt;/td&gt;
&lt;td&gt;&amp;quot;what is X&amp;quot;, &amp;quot;how does X work&amp;quot;&lt;/td&gt;
&lt;td&gt;Builds awareness, supports SEO breadth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comparison&lt;/td&gt;
&lt;td&gt;&amp;quot;product A vs product B&amp;quot;, &amp;quot;which material is better&amp;quot;&lt;/td&gt;
&lt;td&gt;Supports active evaluation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commercial investigation&lt;/td&gt;
&lt;td&gt;&amp;quot;best solution for X environment&amp;quot;, &amp;quot;typical cost of X&amp;quot;&lt;/td&gt;
&lt;td&gt;Signals a buyer close to shortlisting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transactional&lt;/td&gt;
&lt;td&gt;&amp;quot;request a quote&amp;quot;, &amp;quot;find a provider near me&amp;quot;&lt;/td&gt;
&lt;td&gt;Signals immediate purchase intent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;All four categories carry value, but the highest-volume query is rarely the best first investment. A lower-volume comparison or decision-stage topic frequently pulls in more qualified buyers than a broad informational one.&lt;/p&gt;
&lt;h2&gt;Go From Site-Level Queries to Page-Level Patterns&lt;/h2&gt;
&lt;p&gt;Reviewing your site&amp;#39;s top queries as one giant list gives a useful overview and is too broad to plan content from directly. Established company sites carry a lot of branded traffic: people searching your company name, locations, staff, or existing products. That traffic matters for other reasons and doesn&amp;#39;t represent new market discovery.&lt;/p&gt;
&lt;p&gt;Non-branded searches are where the growth signal lives, because those are people who may not know your company yet. The practical steps: open the performance report, switch the view from queries to pages, pull out commercially relevant pages with meaningful impressions, filter out anything dominated by branded or navigational traffic, then open one page at a time and review only the queries tied to it.&lt;/p&gt;
&lt;p&gt;That page-level view is where the useful patterns show up. A single service page might be tied to hundreds of query variations covering definitions, product names, misspellings, use cases, comparisons, location terms, price questions, industry-specific phrasing, and a fair amount of noise. That list can look chaotic at first glance, and that&amp;#39;s exactly the point where AI earns its place in the workflow.&lt;/p&gt;
&lt;h2&gt;Let AI Process the Volume, Keep the Decisions Human&lt;/h2&gt;
&lt;p&gt;Reading through several hundred queries by hand is possible and a poor use of a senior marketer&amp;#39;s or technical expert&amp;#39;s time. Export the page-level queries and metrics to a spreadsheet, hand it to Claude, ChatGPT, or another model, and ask it to strip out irrelevant queries, group similar phrases, separate branded from non-branded terms, flag question-based searches, classify intent, surface recurring themes, highlight high-impression low-click pages, and suggest whether each theme belongs on the existing page or warrants its own asset.&lt;/p&gt;
&lt;p&gt;The AI here is interpreting demand Google has already recorded, not inventing it. A generic prompt like &amp;quot;give me blog ideas for an industrial company&amp;quot; produces plausible-sounding topics based on broad patterns. A prompt built from your own page-level queries, impressions, clicks, positions, and commercial context produces something grounded in your actual market presence, which is a meaningfully stronger starting point even though it still needs human review.&lt;/p&gt;
&lt;p&gt;A workable prompt structure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;// File: prompts/content-opportunity-analysis.txt
Analyze the attached Search Console export for this service page. 
Remove irrelevant and branded queries. Group the remaining queries 
by search intent and recurring subject. Identify opportunities to 
improve the existing page, create FAQs, publish supporting articles, 
build comparison content, or create commercial landing pages. 
Prioritize opportunities using impressions, current position, 
click-through rate, and likely relevance to a mid-market B2B buyer. 
Do not recommend separate pages when they would substantially 
duplicate the existing page.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Layering in company context sharpens the output further: what you sell, your typical buyer, geographic coverage, average project value, key industries, sales cycle length, technical constraints, existing content, and any topics you deliberately want to avoid targeting.&lt;/p&gt;
&lt;h2&gt;Add Keyword Planner for the Market View Search Console Can&amp;#39;t Give You&lt;/h2&gt;
&lt;p&gt;Search Console shows how your site currently interacts with demand. Google Ads Keyword Planner shows the market around that demand: average monthly searches, recent trends, year-over-year change, advertiser competition, approximate cost per click, and related terms and phrasing.&lt;/p&gt;
&lt;p&gt;That data is useful even for companies with no plans to run paid campaigns, because competition and bid estimates give a rough read on commercial interest. Companies generally bid higher when a query connects to valuable business. A high cost-per-click can point to strong buyer intent, heavy competition, high customer value, a tough organic environment, or a broad term pulling in many different buyer types, so it&amp;#39;s a signal to weigh alongside the rest, not a rule to follow on its own. A low-competition query can point to a genuine gap or simply weak commercial relevance, and the surrounding context usually tells you which.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data source&lt;/th&gt;
&lt;th&gt;What it reveals&lt;/th&gt;
&lt;th&gt;Best used for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Search Console&lt;/td&gt;
&lt;td&gt;Where your site already ranks, impressions, clicks, position&lt;/td&gt;
&lt;td&gt;Prioritizing improvements to existing pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyword Planner&lt;/td&gt;
&lt;td&gt;Broader market demand, trends, competition, CPC&lt;/td&gt;
&lt;td&gt;Sizing new topics and validating commercial value&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Combined, these two sources answer sharper questions than either does alone: does this page already have momentum, is the topic growing or shrinking, are companies paying to reach this audience, are buyers using different wording than your site does, is the existing page targeting a phrase that&amp;#39;s too broad, and is there enough demand to justify a dedicated comparison or guide.&lt;/p&gt;
&lt;h2&gt;Match the Format to the Intent&lt;/h2&gt;
&lt;p&gt;Not every opportunity needs a blog post. Picking the right format is one of the highest-leverage decisions in this workflow.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Improve the existing page&lt;/strong&gt; when queries connect closely to a page&amp;#39;s central subject. That can mean a clearer definition, better examples, stronger headings, technical specs, relevant images, cost factors, process explanations, embedded FAQs, internal links, or a case study. Creating a separate article to repeat what belongs on the service page itself just splits authority for no benefit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Add FAQs&lt;/strong&gt; for narrow, direct questions: installation time, cost drivers, upgrade paths, maintenance requirements, climate suitability, or what information an estimate requires. FAQs strengthen the existing page instead of forcing the reader to a second resource.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Write a comparison article&lt;/strong&gt; when the data reveals genuine uncertainty between two alternatives. A strong comparison explains where each option performs best, the real trade-offs, cost and maintenance differences, and the conditions under which the recommendation changes, rather than declaring a universal winner.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Publish an educational guide&lt;/strong&gt; when the topic needs more depth than a commercial page can reasonably hold: how a system works, how to plan a project, common mistakes, procurement considerations, or questions to ask suppliers. The guide should link naturally to the relevant service instead of functioning as a disguised sales page.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Build an industry page&lt;/strong&gt; when one sector has distinct requirements, terminology, or project history worth showing directly: relevant services, specific challenges, applicable standards, project examples, and evidence of experience in that environment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Build a location page&lt;/strong&gt; when the company has real service coverage, local projects, and operational relevance to show, connecting services available in the area, local project evidence, and regional considerations. Producing near-duplicate pages with only the city name swapped creates volume without adding value.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Build a tool&lt;/strong&gt; when the query itself signals that the user wants a decision rather than an article: cost estimates, sizing, compatibility checks, or eligibility. A calculator, estimator, or guided questionnaire can outperform a long-form article for both discoverability and lead qualification.&lt;/p&gt;
&lt;h2&gt;Validate With Sales and Technical Teams Before You Commit&lt;/h2&gt;
&lt;p&gt;Search data shows what people ask. It rarely shows why they&amp;#39;re asking, whether the person asking is a serious buyer, or how the company should actually answer. That gap is where internal experience earns its place in the process.&lt;/p&gt;
&lt;p&gt;Before committing real production time to a piece, check with sales or technical staff: do customers actually ask this, at what stage of the buying process, is the wording accurate, does it reflect a common misconception, is it commercially valuable, can the company answer it better than competitors, is there project evidence to draw on, are there claims to avoid, and what qualification would an inexperienced writer likely miss.&lt;/p&gt;
&lt;p&gt;This step catches a specific failure mode: content that performs well in search while attracting the wrong audience. For a B2B company running lean on marketing capacity, one article that helps a serious buyer through a complex decision is worth more than a broad piece pulling in thousands of students, hobbyists, or unqualified visitors.&lt;/p&gt;
&lt;h2&gt;Score Opportunities Instead of Debating Them&lt;/h2&gt;
&lt;p&gt;A simple scoring model keeps this comparison consistent across topics. Rate each candidate from one to five on existing visibility, ranking opportunity, commercial intent, business relevance, expertise advantage, evidence availability, format fit, and conversion path.&lt;/p&gt;
&lt;p&gt;The highest total shouldn&amp;#39;t automatically set the publishing order, but it gives marketing, sales, product, and leadership a shared basis to debate priorities from instead of arguing from four different sets of assumptions.&lt;/p&gt;
&lt;h2&gt;Watch for Keyword Cannibalization&lt;/h2&gt;
&lt;p&gt;A large set of related queries can tempt a team into building a separate page for every variation, which usually backfires: multiple pages compete for the same intent, Google struggles to pick the most relevant one, internal link authority splits across pages instead of concentrating, and the company ends up with more maintenance and less depth per page.&lt;/p&gt;
&lt;p&gt;Before adding a new page, check whether the query represents a genuinely different question with a substantially different answer, whether it targets a distinct industry, use case, geography, or buying stage the existing page doesn&amp;#39;t cover, and whether the existing page could simply be expanded instead. A single strong commercial page, a handful of focused FAQs, one comparison article, and relevant case studies frequently outperform ten overlapping articles competing with each other.&lt;/p&gt;
&lt;h2&gt;Build Topic Clusters as Proof of Expertise&lt;/h2&gt;
&lt;p&gt;A single article rarely establishes strong authority on a competitive B2B subject, because search engines and AI systems weigh the broader body of connected content. A solid cluster typically includes a core service page, a detailed guide, a comparison article, several FAQs, a set of case studies, an industry page, a regional page, and a tool, all linked together.&lt;/p&gt;
&lt;p&gt;Each piece plays a distinct role: the service page drives commercial action, the guide explains the subject, the comparison supports evaluation, the case studies prove real experience, the FAQ resolves narrow questions, and the tool helps the buyer self-qualify. The cluster works because the pieces cover the subject from connected angles instead of repeating the same text under different titles.&lt;/p&gt;
&lt;h2&gt;Run This as a Monthly Process, Not a One-Time Audit&lt;/h2&gt;
&lt;p&gt;A workable monthly cadence for a mid-market B2B team:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Week 1 — Identify the opportunity.&lt;/strong&gt; Review pages with high impressions, low click-through rates, positions near page one, and strong commercial relevance. Pick one or two for deeper analysis.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Week 2 — Analyze demand.&lt;/strong&gt; Export page-level queries, run them through AI for grouping and intent classification, and layer in Keyword Planner data and competitor observations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Week 3 — Capture expertise.&lt;/strong&gt; Interview the relevant salesperson, engineer, or project lead and collect examples, qualifications, and project evidence.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Week 4 — Publish and connect.&lt;/strong&gt; Improve the existing page or publish the new asset, add internal links, FAQs, case studies, metadata, and imagery, and distribute through the right channels before tracking performance over the following months.&lt;/p&gt;
&lt;p&gt;That cadence is sustainable for a company aiming for steady progress without building a publishing operation it can&amp;#39;t maintain.&lt;/p&gt;
&lt;h2&gt;Measure What Actually Moves the Business&lt;/h2&gt;
&lt;p&gt;Rankings are a useful proxy and not the actual goal. A fuller measurement set includes growth in non-branded impressions, movement in average position, improved click-through rate, qualified organic visits, engagement with supporting pages, tool completions, contact requests, sales conversations shaped by the content, questions prospects stop needing sales to explain manually, content sales teams start reusing directly, visibility inside AI-generated answers, and revenue tied back to organic discovery.&lt;/p&gt;
&lt;p&gt;Not every article produces a directly attributable lead. Some content lifts a service page&amp;#39;s performance, some helps a prospect validate the company after a referral, some shortens the sales cycle, and some simply gives an AI system enough material to reference the company accurately. The underlying objective across all of it is making the company easier to discover and easier to trust.&lt;/p&gt;
&lt;h2&gt;Where Judgment Still Has to Come From People&lt;/h2&gt;
&lt;p&gt;Data shows where interest exists. AI processes the volume. Search tools estimate demand and competition. None of that carries knowledge of which customers you want more of, which projects are most profitable, which services are operationally constrained, or which topics drive the strongest sales conversations, so the final call stays with people who understand the business.&lt;/p&gt;
&lt;p&gt;The workflow holds together because each piece corrects a weakness in the others: search evidence keeps the plan grounded in real demand instead of internal preference, commercial judgment keeps the plan pointed at relevant buyers instead of raw traffic, subject-matter experts protect accuracy, AI absorbs the repetitive analysis and drafting work, and ongoing measurement shows whether the whole thing produced value.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;How much Search Console data do I need before this workflow is useful?&lt;/strong&gt;
A few months of history is enough to start. Three to six months gives a more stable read on trends, but you can run the first pass with whatever data you currently have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Should I always fix an existing page before writing something new?&lt;/strong&gt;
Check the existing page first when a query is closely related to a page you already have. If the existing page targets a different intent or a genuinely different audience, a new asset is the better move.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What if a topic scores well but sales says nobody actually asks about it?&lt;/strong&gt;
Trust the validation step. Search volume without a real corresponding buyer conversation is a weak signal on its own, and it&amp;#39;s usually better spent on a topic sales can confirm firsthand.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How many queries does a typical service page carry in Search Console?&lt;/strong&gt;
Anywhere from a few dozen to several hundred, depending on the page&amp;#39;s age and how broad its subject is. Older, broader pages tend to accumulate the most variation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can this workflow work without an SEO specialist on staff?&lt;/strong&gt;
Yes, as long as someone owns pulling the Search Console export monthly and someone technical is available to validate accuracy before publishing. The AI-assisted grouping step removes most of the specialist skill this used to require.&lt;/p&gt;
&lt;h2&gt;Publish the Next Most Valuable Thing&lt;/h2&gt;
&lt;p&gt;Most established B2B companies already have more content ideas than they can execute. The question worth asking isn&amp;#39;t what to write next. It&amp;#39;s where proven demand, existing visibility, commercial relevance, and a real expertise advantage all line up at once.&lt;/p&gt;
&lt;p&gt;That question produces a different kind of content plan: one that favors targeted improvements over endless publishing, connects marketing work to real customer uncertainty, gives sales and technical teams a clear reason to contribute, and uses AI as an analytical assistant instead of an idea generator working from outside the business.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical guides on building content systems that hold up under scrutiny.&lt;/p&gt;
&lt;p&gt;Thanks, Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/search-led-content-workflow-b2b-prioritize-wins"/>
        </item>
        <item>
            <title><![CDATA[B2B Content Strategy: Turn Decades of Expertise Into Traffic]]></title>
            <description><![CDATA[<p>If your company has operated for decades in a specialist industry, your team already holds more real answers than most of what shows up on page one of Google. Getting that knowledge onto your website is a production problem, not a knowledge problem. This guide walks through the system I use with established B2B clients to pull expertise out of people&#39;s heads and turn it into structured, findable, commercially useful content: which questions to start with, how to structure one expert conversation into multiple assets, and where AI fits into the workflow without taking over the judgment calls that matter.</p>
<p>I&#39;ve run this process with manufacturers, signage companies, and specialist suppliers whose sales and technical teams could talk for an hour about installation constraints, regional requirements, and the mistakes newer competitors make, while their website said almost nothing beyond &quot;quality, innovation, and excellent service.&quot; The gap between what a company knows and what a company publishes is the single biggest content opportunity I see in this segment, and it&#39;s fixable with a repeatable workflow rather than a content calendar.</p>
<h2>The Gap Between What You Know and What You Publish</h2>
<p>A company can operate successfully for 20, 30, or 50 years and still be unknown to the people evaluating it right now. A new prospect comparing suppliers has never sat in on a sales call. Google has never read your project files. An AI model summarizing your industry has never talked to your senior engineer.</p>
<p>These evaluators, human or algorithmic, work from what&#39;s actually published: your service pages, your articles, your FAQs, your case studies, your documented project history. They judge how clearly you explain hard problems and how well those explanations are backed by evidence. Everything that lives only in a salesperson&#39;s head or last Tuesday&#39;s client call is invisible to them, no matter how good it is.</p>
<p>The result is a company that feels highly differentiated internally and reads as generic externally.</p>
<h2>Where the Knowledge Actually Lives</h2>
<p>In an established B2B company, the useful knowledge is scattered across sales conversations, technical consultations, project folders, proposals, email threads, installation notes, internal training material, support tickets, and the personal experience of a handful of senior employees. Some of it sits in a CRM or shared drive. Most of it stays informal, in people&#39;s heads and in threads nobody archives properly.</p>
<p>Marketing usually knows this expertise exists and struggles to extract it. Subject-matter experts are busy, uninterested in writing articles, and often unsure which parts of what they know would actually help search visibility. Marketing can write clearly but frequently lacks the technical grounding to judge whether a claim holds up, whether a qualification is missing, or whether two solutions that look similar are actually interchangeable.</p>
<p>That mismatch produces a familiar cycle: marketing asks an expert to write something, the expert delays because client work is more urgent, marketing drafts from general online research, the expert reviews it late and flags it as too generic or technically off, and the piece gets shelved or published in a weakened form. After a few rounds of this, the company quietly decides that producing good technical content is too hard.</p>
<p>The actual fix is a knowledge-capture process the company hasn&#39;t built yet, and that&#39;s a structural problem with a structural solution.</p>
<h2>Start With the Questions Your Company Already Answers</h2>
<p>Skip the broad content calendar and <a href="/blog/search-led-content-workflow-b2b-prioritize-wins">start with questions instead</a>. What do serious prospects ask before requesting a proposal? What does the sales team clarify on nearly every call? Which product differences get misunderstood most often? Where do geography, regulation, climate, scale, or installation conditions change the recommendation?</p>
<p>These questions already live inside the business, in calls, emails, demos, support tickets, and proposal conversations, and they also show up in your search data. Pulling the queries tied to an existing service page and comparing them against what sales and support hear on calls surfaces a small set of overlap questions: things people both search for and ask your team directly. That overlap is the highest-value content territory you have, because it&#39;s simultaneously in demand and commercially relevant.</p>
<p>Typical examples I see in this overlap: the difference between two product categories, expected lifespan, installation time, cost drivers, suitability for a given environment, maintenance requirements, compliance considerations, and what information a prospect needs to gather before requesting an estimate. Each of those is a signal of buyer uncertainty, and answering it well improves search visibility and sales readiness at the same time.</p>
<h2>One Expert Conversation Should Produce More Than One Article</h2>
<p>Treating every expert interview as fuel for a single blog post wastes most of the value in the conversation. A senior technical employee explaining how the company selects a solution for a given environment can support a full article, several standalone FAQs, a comparison page, a buyer&#39;s checklist, a service page section, a case study explanation, a sales enablement doc, a social post sequence, a newsletter, and internal AI knowledge for support or sales tooling.</p>
<p>The goal is to capture the underlying knowledge once, structure it properly, and reuse it across formats suited to different stages of the buying journey: an FAQ for a narrow question, a service page for the commercial offer, an article for context and education, a case study as proof, and a calculator or assessment tool to help the prospect self-qualify. Assembled together, these assets represent expertise far more convincingly than a stack of unconnected blog posts.</p>
<h2>Why Real Authority Is Hard to Copy</h2>
<p>AI-generated content has made high-volume publishing available to almost any competitor, including ones with far less practical experience than you have. That shift raises the value of first-hand detail: real project constraints, documented failure modes, judgment calls under specific operating conditions, and outcomes you can actually stand behind.</p>
<p>An article earns credibility when it explains when a recommendation changes, which variables matter most, what commonly goes wrong, what inexperienced buyers tend to miss, how your team weighs trade-offs, and what happened on a real project. Established companies already hold this material. The work is exposing it, not inventing it.</p>
<h2>Your Project Archive Is a Knowledge Base, Not a Photo Gallery</h2>
<p>Most companies treat project pages as portfolios: a photo, a client name, a short paragraph describing what was delivered. That confirms the work happened without explaining what it demonstrates.</p>
<p>A useful B2B case study captures what the customer needed, what made the project difficult, which constraints shaped the decision, which alternatives were considered, why the chosen approach fit, how delivery actually went, what the team learned, and which similar organizations face the same issue. That level of detail turns a project from a gallery entry into commercial evidence, and it lets you connect expertise to geography, industry, product type, and scale. Several projects from one region can anchor a regional service page; several from one sector can anchor an industry page; a recurring technical decision across projects can anchor a detailed guide.</p>
<h2>Structure the Website Around How Prospects Actually Think</h2>
<p>Organizing content purely by what you sell covers the basics and leaves value on the table. Prospects think in terms of problems, risks, decisions, industries, environments, and outcomes, and an experienced company already understands those dimensions well enough to structure content around them: by service, product, industry, application, customer type, geography, technical question, project type, or decision stage.</p>
<p>This isn&#39;t about building a page for every possible combination. It&#39;s about designing a content model where a product page links to relevant projects, an FAQ points to a detailed article, an industry page pulls together services and case studies for that sector, and a regional page shows credible local work instead of a city name inserted into generic copy. That kind of structure helps prospects navigate your expertise, and it helps search engines and AI systems understand how your content connects.</p>
<h2>Where AI Fits, and Where It Doesn&#39;t</h2>
<p>AI is genuinely useful for processing hundreds of search queries, grouping similar customer questions, turning meeting transcripts into structured notes, drafting outlines, producing first drafts from approved source material, adapting a finished article for other channels, generating metadata, and suggesting internal links. That&#39;s real time saved on repetitive work.</p>
<p>The judgment calls stay with people. An AI-generated explanation can sound convincing while quietly overlooking a constraint, generalizing advice that only applies in specific circumstances, or blending information from unrelated contexts. For expertise-heavy B2B companies, the model that holds up is human-in-the-loop: AI handles processing, organization, and first drafts; experienced people validate the substance; marketing shapes it for clarity and distribution; the company stays accountable for the final result. This is the same split I build into the SEO and content pipelines I run for clients, and it&#39;s what keeps AI-assisted production from drifting into generic-sounding output.</p>
<table>
<thead>
<tr>
<th>Task</th>
<th>Who owns it</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Processing search data, grouping questions, drafting outlines</td>
<td>AI</td>
<td>Fast, repetitive, low judgment risk</td>
</tr>
<tr>
<td>Confirming technical accuracy and missing qualifications</td>
<td>Subject-matter expert</td>
<td>Requires real experience with the specifics</td>
</tr>
<tr>
<td>Shaping tone, positioning, and distribution</td>
<td>Marketing</td>
<td>Requires audience and channel judgment</td>
</tr>
<tr>
<td>Final sign-off before publishing</td>
<td>Company / accountable owner</td>
<td>Someone has to stand behind the claim</td>
</tr>
</tbody></table>
<h2>Don&#39;t Automate a Process You Haven&#39;t Defined Yet</h2>
<p>It&#39;s tempting to ask whether the whole workflow can run on autopilot once the potential is clear. Search data collection, topic identification, draft generation, CMS publishing, and social distribution can all be automated at a technical level. Automating a weak process just makes it fail faster.</p>
<p>Run the workflow manually a few times first. Use those runs to figure out which data is genuinely useful, which questions are commercially relevant, which decisions need expert judgment, which formats actually produce value, how much review is necessary, who owns final approval, and what your quality bar looks like in practice. Once those answers stabilize, automate the repetitive steps around them. That order produces a system that fits how the company actually works, rather than a company forced to fit a system.</p>
<h2>A Practical Starting Point</h2>
<p>Pick one commercially important service or product and run this pilot before touching the rest of the site:</p>
<ol>
<li>Review the existing page and its search performance.</li>
<li>Export the queries associated with it.</li>
<li>Ask sales and technical teams what prospects repeatedly ask.</li>
<li>Identify the gap between the website and what the company actually knows.</li>
<li>Run one structured expert interview.</li>
<li>Turn that interview into a core article or guide.</li>
<li>Extract several FAQs from the same material.</li>
<li>Connect the new content to existing services and project evidence.</li>
<li>Publish through the company&#39;s CMS.</li>
<li>Adapt versions for other channels.</li>
<li>Track whether visibility, engagement, and qualified inquiries move.</li>
<li>Document what worked before expanding to the next service.</li>
</ol>
<p>This is the first working version of a repeatable knowledge-to-content pipeline, and everything after this pilot is scaling something you&#39;ve already proven works once.</p>
<h2>FAQ</h2>
<p><strong>How is this different from a normal content calendar?</strong>
A content calendar starts from topics marketing thinks sound good. This process starts from questions your sales and technical teams already answer every week, then checks those questions against real search data before anything gets written.</p>
<p><strong>Do subject-matter experts need to write the articles themselves?</strong>
No. They need to talk through their reasoning in an interview. Turning that conversation into a structured article is a separate skill, and it&#39;s the part AI and a writer can handle once the substance is validated.</p>
<p><strong>How much content can one expert interview realistically produce?</strong>
A solid 45-60 minute interview on one recurring decision can support a full article, three to five FAQs, a comparison page, and a case study section. The limiting factor is usually structuring time, not raw material.</p>
<p><strong>Where should a company with limited resources start?</strong>
One page, one interview, one pilot run through all twelve steps above. Prove the workflow on a single service before building out a full content operation around it.</p>
<p><strong>Does AI-generated content hurt or help in this model?</strong>
It helps when it&#39;s confined to processing, drafting, and formatting, with an expert validating the substance before publishing. It hurts when it&#39;s left to generate claims unsupervised, because it can sound confident while missing a constraint that only someone with real project experience would catch.</p>
<h2>The Real Opportunity</h2>
<p>Established B2B companies are sitting on years of customer questions, project experience, and technical judgment that newer competitors can&#39;t fabricate. Most of that value currently lives outside anything a prospect, a search engine, or an AI model can see.</p>
<p>Turning that internal knowledge into a website that actually reflects it takes <a href="/blog/content-operating-system-mid-market-b2b">a system: internal knowledge capture, market demand data, expert review, content structure, publishing, distribution, and measurement, all connected</a>. Once that system runs, the website stops being a brochure and becomes a real record of what the company has spent decades learning.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical guides on building content systems that hold up under scrutiny.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/b2b-content-strategy-turn-decades-expertise-into-traffic</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/b2b-content-strategy-turn-decades-expertise-into-traffic</guid>
            <category><![CDATA[AI]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Sat, 25 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;If your company has operated for decades in a specialist industry, your team already holds more real answers than most of what shows up on page one of Google. Getting that knowledge onto your website is a production problem, not a knowledge problem. This guide walks through the system I use with established B2B clients to pull expertise out of people&amp;#39;s heads and turn it into structured, findable, commercially useful content: which questions to start with, how to structure one expert conversation into multiple assets, and where AI fits into the workflow without taking over the judgment calls that matter.&lt;/p&gt;
&lt;p&gt;I&amp;#39;ve run this process with manufacturers, signage companies, and specialist suppliers whose sales and technical teams could talk for an hour about installation constraints, regional requirements, and the mistakes newer competitors make, while their website said almost nothing beyond &amp;quot;quality, innovation, and excellent service.&amp;quot; The gap between what a company knows and what a company publishes is the single biggest content opportunity I see in this segment, and it&amp;#39;s fixable with a repeatable workflow rather than a content calendar.&lt;/p&gt;
&lt;h2&gt;The Gap Between What You Know and What You Publish&lt;/h2&gt;
&lt;p&gt;A company can operate successfully for 20, 30, or 50 years and still be unknown to the people evaluating it right now. A new prospect comparing suppliers has never sat in on a sales call. Google has never read your project files. An AI model summarizing your industry has never talked to your senior engineer.&lt;/p&gt;
&lt;p&gt;These evaluators, human or algorithmic, work from what&amp;#39;s actually published: your service pages, your articles, your FAQs, your case studies, your documented project history. They judge how clearly you explain hard problems and how well those explanations are backed by evidence. Everything that lives only in a salesperson&amp;#39;s head or last Tuesday&amp;#39;s client call is invisible to them, no matter how good it is.&lt;/p&gt;
&lt;p&gt;The result is a company that feels highly differentiated internally and reads as generic externally.&lt;/p&gt;
&lt;h2&gt;Where the Knowledge Actually Lives&lt;/h2&gt;
&lt;p&gt;In an established B2B company, the useful knowledge is scattered across sales conversations, technical consultations, project folders, proposals, email threads, installation notes, internal training material, support tickets, and the personal experience of a handful of senior employees. Some of it sits in a CRM or shared drive. Most of it stays informal, in people&amp;#39;s heads and in threads nobody archives properly.&lt;/p&gt;
&lt;p&gt;Marketing usually knows this expertise exists and struggles to extract it. Subject-matter experts are busy, uninterested in writing articles, and often unsure which parts of what they know would actually help search visibility. Marketing can write clearly but frequently lacks the technical grounding to judge whether a claim holds up, whether a qualification is missing, or whether two solutions that look similar are actually interchangeable.&lt;/p&gt;
&lt;p&gt;That mismatch produces a familiar cycle: marketing asks an expert to write something, the expert delays because client work is more urgent, marketing drafts from general online research, the expert reviews it late and flags it as too generic or technically off, and the piece gets shelved or published in a weakened form. After a few rounds of this, the company quietly decides that producing good technical content is too hard.&lt;/p&gt;
&lt;p&gt;The actual fix is a knowledge-capture process the company hasn&amp;#39;t built yet, and that&amp;#39;s a structural problem with a structural solution.&lt;/p&gt;
&lt;h2&gt;Start With the Questions Your Company Already Answers&lt;/h2&gt;
&lt;p&gt;Skip the broad content calendar and &lt;a href=&quot;/blog/search-led-content-workflow-b2b-prioritize-wins&quot;&gt;start with questions instead&lt;/a&gt;. What do serious prospects ask before requesting a proposal? What does the sales team clarify on nearly every call? Which product differences get misunderstood most often? Where do geography, regulation, climate, scale, or installation conditions change the recommendation?&lt;/p&gt;
&lt;p&gt;These questions already live inside the business, in calls, emails, demos, support tickets, and proposal conversations, and they also show up in your search data. Pulling the queries tied to an existing service page and comparing them against what sales and support hear on calls surfaces a small set of overlap questions: things people both search for and ask your team directly. That overlap is the highest-value content territory you have, because it&amp;#39;s simultaneously in demand and commercially relevant.&lt;/p&gt;
&lt;p&gt;Typical examples I see in this overlap: the difference between two product categories, expected lifespan, installation time, cost drivers, suitability for a given environment, maintenance requirements, compliance considerations, and what information a prospect needs to gather before requesting an estimate. Each of those is a signal of buyer uncertainty, and answering it well improves search visibility and sales readiness at the same time.&lt;/p&gt;
&lt;h2&gt;One Expert Conversation Should Produce More Than One Article&lt;/h2&gt;
&lt;p&gt;Treating every expert interview as fuel for a single blog post wastes most of the value in the conversation. A senior technical employee explaining how the company selects a solution for a given environment can support a full article, several standalone FAQs, a comparison page, a buyer&amp;#39;s checklist, a service page section, a case study explanation, a sales enablement doc, a social post sequence, a newsletter, and internal AI knowledge for support or sales tooling.&lt;/p&gt;
&lt;p&gt;The goal is to capture the underlying knowledge once, structure it properly, and reuse it across formats suited to different stages of the buying journey: an FAQ for a narrow question, a service page for the commercial offer, an article for context and education, a case study as proof, and a calculator or assessment tool to help the prospect self-qualify. Assembled together, these assets represent expertise far more convincingly than a stack of unconnected blog posts.&lt;/p&gt;
&lt;h2&gt;Why Real Authority Is Hard to Copy&lt;/h2&gt;
&lt;p&gt;AI-generated content has made high-volume publishing available to almost any competitor, including ones with far less practical experience than you have. That shift raises the value of first-hand detail: real project constraints, documented failure modes, judgment calls under specific operating conditions, and outcomes you can actually stand behind.&lt;/p&gt;
&lt;p&gt;An article earns credibility when it explains when a recommendation changes, which variables matter most, what commonly goes wrong, what inexperienced buyers tend to miss, how your team weighs trade-offs, and what happened on a real project. Established companies already hold this material. The work is exposing it, not inventing it.&lt;/p&gt;
&lt;h2&gt;Your Project Archive Is a Knowledge Base, Not a Photo Gallery&lt;/h2&gt;
&lt;p&gt;Most companies treat project pages as portfolios: a photo, a client name, a short paragraph describing what was delivered. That confirms the work happened without explaining what it demonstrates.&lt;/p&gt;
&lt;p&gt;A useful B2B case study captures what the customer needed, what made the project difficult, which constraints shaped the decision, which alternatives were considered, why the chosen approach fit, how delivery actually went, what the team learned, and which similar organizations face the same issue. That level of detail turns a project from a gallery entry into commercial evidence, and it lets you connect expertise to geography, industry, product type, and scale. Several projects from one region can anchor a regional service page; several from one sector can anchor an industry page; a recurring technical decision across projects can anchor a detailed guide.&lt;/p&gt;
&lt;h2&gt;Structure the Website Around How Prospects Actually Think&lt;/h2&gt;
&lt;p&gt;Organizing content purely by what you sell covers the basics and leaves value on the table. Prospects think in terms of problems, risks, decisions, industries, environments, and outcomes, and an experienced company already understands those dimensions well enough to structure content around them: by service, product, industry, application, customer type, geography, technical question, project type, or decision stage.&lt;/p&gt;
&lt;p&gt;This isn&amp;#39;t about building a page for every possible combination. It&amp;#39;s about designing a content model where a product page links to relevant projects, an FAQ points to a detailed article, an industry page pulls together services and case studies for that sector, and a regional page shows credible local work instead of a city name inserted into generic copy. That kind of structure helps prospects navigate your expertise, and it helps search engines and AI systems understand how your content connects.&lt;/p&gt;
&lt;h2&gt;Where AI Fits, and Where It Doesn&amp;#39;t&lt;/h2&gt;
&lt;p&gt;AI is genuinely useful for processing hundreds of search queries, grouping similar customer questions, turning meeting transcripts into structured notes, drafting outlines, producing first drafts from approved source material, adapting a finished article for other channels, generating metadata, and suggesting internal links. That&amp;#39;s real time saved on repetitive work.&lt;/p&gt;
&lt;p&gt;The judgment calls stay with people. An AI-generated explanation can sound convincing while quietly overlooking a constraint, generalizing advice that only applies in specific circumstances, or blending information from unrelated contexts. For expertise-heavy B2B companies, the model that holds up is human-in-the-loop: AI handles processing, organization, and first drafts; experienced people validate the substance; marketing shapes it for clarity and distribution; the company stays accountable for the final result. This is the same split I build into the SEO and content pipelines I run for clients, and it&amp;#39;s what keeps AI-assisted production from drifting into generic-sounding output.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Who owns it&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Processing search data, grouping questions, drafting outlines&lt;/td&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;td&gt;Fast, repetitive, low judgment risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confirming technical accuracy and missing qualifications&lt;/td&gt;
&lt;td&gt;Subject-matter expert&lt;/td&gt;
&lt;td&gt;Requires real experience with the specifics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shaping tone, positioning, and distribution&lt;/td&gt;
&lt;td&gt;Marketing&lt;/td&gt;
&lt;td&gt;Requires audience and channel judgment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final sign-off before publishing&lt;/td&gt;
&lt;td&gt;Company / accountable owner&lt;/td&gt;
&lt;td&gt;Someone has to stand behind the claim&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;Don&amp;#39;t Automate a Process You Haven&amp;#39;t Defined Yet&lt;/h2&gt;
&lt;p&gt;It&amp;#39;s tempting to ask whether the whole workflow can run on autopilot once the potential is clear. Search data collection, topic identification, draft generation, CMS publishing, and social distribution can all be automated at a technical level. Automating a weak process just makes it fail faster.&lt;/p&gt;
&lt;p&gt;Run the workflow manually a few times first. Use those runs to figure out which data is genuinely useful, which questions are commercially relevant, which decisions need expert judgment, which formats actually produce value, how much review is necessary, who owns final approval, and what your quality bar looks like in practice. Once those answers stabilize, automate the repetitive steps around them. That order produces a system that fits how the company actually works, rather than a company forced to fit a system.&lt;/p&gt;
&lt;h2&gt;A Practical Starting Point&lt;/h2&gt;
&lt;p&gt;Pick one commercially important service or product and run this pilot before touching the rest of the site:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Review the existing page and its search performance.&lt;/li&gt;
&lt;li&gt;Export the queries associated with it.&lt;/li&gt;
&lt;li&gt;Ask sales and technical teams what prospects repeatedly ask.&lt;/li&gt;
&lt;li&gt;Identify the gap between the website and what the company actually knows.&lt;/li&gt;
&lt;li&gt;Run one structured expert interview.&lt;/li&gt;
&lt;li&gt;Turn that interview into a core article or guide.&lt;/li&gt;
&lt;li&gt;Extract several FAQs from the same material.&lt;/li&gt;
&lt;li&gt;Connect the new content to existing services and project evidence.&lt;/li&gt;
&lt;li&gt;Publish through the company&amp;#39;s CMS.&lt;/li&gt;
&lt;li&gt;Adapt versions for other channels.&lt;/li&gt;
&lt;li&gt;Track whether visibility, engagement, and qualified inquiries move.&lt;/li&gt;
&lt;li&gt;Document what worked before expanding to the next service.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is the first working version of a repeatable knowledge-to-content pipeline, and everything after this pilot is scaling something you&amp;#39;ve already proven works once.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;How is this different from a normal content calendar?&lt;/strong&gt;
A content calendar starts from topics marketing thinks sound good. This process starts from questions your sales and technical teams already answer every week, then checks those questions against real search data before anything gets written.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do subject-matter experts need to write the articles themselves?&lt;/strong&gt;
No. They need to talk through their reasoning in an interview. Turning that conversation into a structured article is a separate skill, and it&amp;#39;s the part AI and a writer can handle once the substance is validated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How much content can one expert interview realistically produce?&lt;/strong&gt;
A solid 45-60 minute interview on one recurring decision can support a full article, three to five FAQs, a comparison page, and a case study section. The limiting factor is usually structuring time, not raw material.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where should a company with limited resources start?&lt;/strong&gt;
One page, one interview, one pilot run through all twelve steps above. Prove the workflow on a single service before building out a full content operation around it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does AI-generated content hurt or help in this model?&lt;/strong&gt;
It helps when it&amp;#39;s confined to processing, drafting, and formatting, with an expert validating the substance before publishing. It hurts when it&amp;#39;s left to generate claims unsupervised, because it can sound confident while missing a constraint that only someone with real project experience would catch.&lt;/p&gt;
&lt;h2&gt;The Real Opportunity&lt;/h2&gt;
&lt;p&gt;Established B2B companies are sitting on years of customer questions, project experience, and technical judgment that newer competitors can&amp;#39;t fabricate. Most of that value currently lives outside anything a prospect, a search engine, or an AI model can see.&lt;/p&gt;
&lt;p&gt;Turning that internal knowledge into a website that actually reflects it takes &lt;a href=&quot;/blog/content-operating-system-mid-market-b2b&quot;&gt;a system: internal knowledge capture, market demand data, expert review, content structure, publishing, distribution, and measurement, all connected&lt;/a&gt;. Once that system runs, the website stops being a brochure and becomes a real record of what the company has spent decades learning.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical guides on building content systems that hold up under scrutiny.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/b2b-content-strategy-turn-decades-expertise-into-traffic"/>
        </item>
        <item>
            <title><![CDATA[Master n8n CLI: Debug and Edit Workflows Without UI]]></title>
            <description><![CDATA[<p>If you&#39;re using Claude Code or Codex to write application code, point it at <code>@n8n/cli</code> too. Authenticate with <code>N8N_URL</code> and <code>N8N_API_KEY</code> as environment variables, and your agent can pull a live workflow as JSON, script structural edits with a throwaway Python pass, validate the graph before pushing, and — this is the part that actually changes how you work — pull real execution data with <code>execution get --includeData</code> to see exactly which node failed and why, instead of guessing from the outside. Editing n8n workflows stops being a canvas you click through and becomes an artifact you can read, diff, and debug like any other code.</p>
<p>I got here the ordinary way: iterating on an n8n workflow built around an AI Agent node, and hitting a wall that had nothing to do with my prompt engineering. The workflow would occasionally return a broken reply in production, and the only way to find out why was to open the n8n UI, click into the executions tab, and manually expand node outputs one at a time. That&#39;s fine for one bug. It stops being fine once you&#39;re iterating fast and the agent itself is misbehaving in ways that don&#39;t show up as an obvious crash. So I stopped treating n8n as a UI-only tool and started treating it as something Claude Code could read and write directly, the same way it reads and writes a repo.</p>
<p>This isn&#39;t a Claude Code vs. Codex piece — the pattern is identical for either. What matters is that your coding agent already knows how to run a CLI, read JSON, and reason about structured output. <code>@n8n/cli</code> gives it exactly that surface for a system that&#39;s normally locked behind a browser.</p>
<h2>Authenticate with environment variables, not <code>login</code></h2>
<p><code>@n8n/cli</code> ships an interactive <code>login</code> command that saves your instance URL and API key to a local config file. Skip it. Every subcommand also reads <code>N8N_URL</code> and <code>N8N_API_KEY</code> directly from the environment:</p>
<pre><code class="language-bash"># File: (shell session, not a repo file)
export N8N_URL=&quot;https://your-n8n-instance.example.com&quot;
export N8N_API_KEY=&quot;your-api-key&quot;
npx @n8n/cli workflow list --format=json
</code></pre>
<p>Using env vars instead of <code>login</code> keeps the credential scoped to the current shell session rather than persisted to disk in a CLI config file your agent (or you, six months later) might forget is there. If your n8n API key is instance-wide rather than scoped to one project — common on a shared or self-hosted instance — this also means you&#39;re not leaving a standing credential file behind after the session ends.</p>
<h2>Look at the whole instance before you touch anything</h2>
<p>The first command I run in a fresh session is always the same, and it&#39;s not the one that touches the workflow I actually care about:</p>
<pre><code class="language-bash">npx @n8n/cli workflow list --format=json
</code></pre>
<p>On a shared n8n instance, this single call tells you something a lot of people skip checking: how many other workflows exist, who they belong to, and whether your API key&#39;s reach extends beyond the one project you&#39;re supposed to be working on. I&#39;ve worked on instances where one API key genuinely could see and edit every client&#39;s workflows. That&#39;s not a reason to avoid the CLI — it&#39;s a reason to run <code>workflow list</code> first, confirm the workflow ID you&#39;re about to touch, and keep every subsequent command scoped to that ID explicitly. An agent that only ever calls <code>workflow get &lt;id&gt;</code> and <code>workflow update &lt;id&gt;</code> for one confirmed ID is safer than one that goes hunting.</p>
<h2>Pull the workflow as JSON and back it up first</h2>
<pre><code class="language-bash">npx @n8n/cli workflow get &lt;workflow-id&gt; --format=json &gt; backups/workflow-id.$(date +%Y%m%d-%H%M%S).json
</code></pre>
<p>This is the step people skip because it feels like ceremony. It isn&#39;t. A live n8n workflow isn&#39;t a file in your repo with git history behind it — it&#39;s a row in n8n&#39;s own database, and n8n&#39;s built-in version history is not something I&#39;d rely on as the only safety net when an agent is scripting structural changes to it. A plain JSON snapshot, timestamped, sitting next to your other project files, costs one command and gives you an actual rollback path if an edit goes wrong. Do this before every structural change, not just the first one.</p>
<h2>Script the edit — don&#39;t hand-edit the JSON</h2>
<p>Once you have the workflow JSON, the temptation is to open it and start editing fields by hand. Don&#39;t. A single n8n workflow can easily have ten or more nodes, each with its own <code>parameters</code>, <code>credentials</code>, <code>typeVersion</code>, and a <code>connections</code> graph that has to reference node names exactly. Hand-editing that JSON is how you end up with a dangling connection to a node you just renamed.</p>
<p>Instead, have your agent write a short Python script that loads the JSON, makes the specific structural change, and writes a new file:</p>
<pre><code class="language-python"># File: scripts/edit-workflow.py
import json

with open(&quot;backups/workflow-id.latest.json&quot;) as f:
    workflow = json.load(f)

for node in workflow[&quot;nodes&quot;]:
    if node[&quot;name&quot;] == &quot;AI Agent&quot;:
        node[&quot;parameters&quot;][&quot;options&quot;][&quot;systemMessage&quot;] += (
            &quot;\n\nNever call the same tool twice with identical parameters &quot;
            &quot;— the result will be identical. Vary at least one real parameter.&quot;
        )

with open(&quot;edited-workflow.json&quot;, &quot;w&quot;, encoding=&quot;utf-8&quot;) as f:
    json.dump(workflow, f, ensure_ascii=False, indent=2)
</code></pre>
<p>Before pushing anything, validate the graph structurally — every node referenced in <code>connections</code> should actually exist in <code>nodes</code>:</p>
<pre><code class="language-python"># File: scripts/validate-workflow.py
import json

with open(&quot;edited-workflow.json&quot;) as f:
    workflow = json.load(f)

names = {n[&quot;name&quot;] for n in workflow[&quot;nodes&quot;]}
errors = []
for source, by_type in workflow[&quot;connections&quot;].items():
    if source not in names:
        errors.append(f&quot;unknown source: {source}&quot;)
    for connections in by_type.values():
        for group in connections:
            for target in group:
                if target[&quot;node&quot;] not in names:
                    errors.append(f&quot;unknown target: {target[&#39;node&#39;]}&quot;)

print(&quot;errors:&quot;, errors or &quot;none&quot;)
</code></pre>
<p>This one check catches the most common mistake an agent (or a human) makes when editing a node graph by script: renaming or removing a node and forgetting one of the connections that pointed at it. It costs nothing to run and it runs before the workflow ever reaches n8n.</p>
<h2>Push safely: deactivate, update, reactivate — as three separate calls</h2>
<pre><code class="language-bash">npx @n8n/cli workflow deactivate &lt;workflow-id&gt;
npx @n8n/cli workflow update &lt;workflow-id&gt; --file=edited-workflow.json --format=json
npx @n8n/cli workflow activate &lt;workflow-id&gt;
</code></pre>
<p>If the workflow you&#39;re editing is active, structural changes — adding or removing nodes, rewiring connections — are safer applied while it&#39;s briefly deactivated, then reactivated once the update succeeds. Doing this as three separate CLI calls, rather than assuming <code>workflow update</code> alone is sufficient, gives you a clear point to stop and inspect the result before flipping the workflow back on. After the update, fetch it fresh with <code>workflow get</code> and confirm the node count and connections match what you expected — don&#39;t trust the update command&#39;s own echoed response as the only confirmation.</p>
<h2>The actual payoff: debugging with live execution data</h2>
<p>Everything above is table stakes. This is the part that changes how you work once you&#39;ve done it a few times.</p>
<p>n8n keeps a full execution history, and <code>@n8n/cli</code> can pull it with node-level detail:</p>
<pre><code class="language-bash">npx @n8n/cli execution list --workflow=&lt;workflow-id&gt; --status=error --format=json --limit=5
npx @n8n/cli execution get &lt;execution-id&gt; --includeData --format=json
</code></pre>
<p>The second command returns the complete <code>runData</code> for every node in that execution — every tool call, every input, every output, in order. This is the same information you&#39;d normally have to click through node by node in the n8n UI, except now your agent can grep it, diff it across runs, and reason about it the way it reasons about a stack trace. Three real bugs came out of exactly this workflow in one session:</p>
<p><strong>An AI Agent hit its iteration ceiling by calling the same tool sixteen times with identical arguments.</strong> Pulling the execution data showed every one of those sixteen calls had the exact same parameters — the model wasn&#39;t exploring different scenarios, it was just repeating itself until it ran out of budget. The fix was a one-line addition to the system prompt: never call the same tool twice with identical parameters, since the result will be identical every time. Obvious once you see the actual tool-call sequence; invisible from the outside.</p>
<p><strong>A tool-call parameter format mismatch that sometimes self-corrected and sometimes didn&#39;t.</strong> A custom tool expected a parameter as a JSON-encoded string; the model occasionally sent it as a plain object instead, got a validation error back, and usually retried correctly — except when it didn&#39;t, at which point it burned through its remaining iterations on the same mistake. <code>execution get --includeData</code> surfaced the exact validation error text on the first bad call, which is the only way to know this is happening at all, since a self-correcting model looks completely fine most of the time.</p>
<p><strong>The worst one: a silent empty response marked as success.</strong> When the agent exhausted its iteration budget after the above, n8n didn&#39;t return an error — the execution&#39;s own status field said <code>&quot;success&quot;</code> while the actual output was an empty string. That&#39;s a strictly worse failure mode than a crash, because nothing downstream knows to treat it as a problem; a user just gets a blank reply. The fix was a small Code node placed right after the AI Agent node, checking whether the output is empty and substituting a graceful fallback message before anything gets returned:</p>
<pre><code class="language-javascript">// File: (n8n Code node, placed after the AI Agent node)
const output = ($json.output ?? &#39;&#39;).toString().trim();
if (!output) {
  return [{ json: { output: &#39;Something went wrong on my end — please try again in a moment.&#39; } }];
}
return [{ json: { output } }];
</code></pre>
<p>None of these three bugs were visible from the chat interface sitting in front of the workflow. All three were visible in the first execution&#39;s full <code>runData</code> once I actually pulled it.</p>
<h2>A shared-memory gotcha worth knowing about</h2>
<p>One more failure mode, because it&#39;s easy to reproduce without realizing it: if you build a small classifier sub-agent (say, one whose only job is to extract a structured value from the latest message) and wire it to the same conversation-memory node as your main persona agent, the classifier can start &quot;seeing&quot; the full chat history — including the main agent&#39;s own prior replies — and drift into responding conversationally instead of returning strict structured output. It&#39;s not a prompt problem so much as a wiring problem: a classifier that only needs the current message doesn&#39;t need conversational memory attached at all. Removing that connection fixed it outright. If you have a sub-agent whose job is narrow and mechanical, keep its inputs narrow and mechanical too.</p>
<h2>Prefer a deterministic node over an LLM judgment call</h2>
<p>The last change worth calling out isn&#39;t a bug fix, it&#39;s a design correction. The workflow originally used an LLM call to answer a yes/no question — &quot;is this value over a fixed threshold?&quot; — before branching. That&#39;s outsourcing a comparison a plain <code>IF</code> node can do exactly right, every time, for free, with no latency.</p>
<table>
<tr><th>Approach</th><th>When it's tempting</th><th>Trade-off</th></tr>
<tr><td>LLM judgment call (yes/no or classification)</td><td>The condition seems to need "understanding" the message</td><td>Adds latency and cost, and a small but real chance of a wrong answer on a question that has one correct answer</td></tr>
<tr><td>LLM extracts a value, plain node decides</td><td>You need natural-language understanding <em>and</em> a reliable branch</td><td>Slightly more setup (a parser/schema for the extracted value), but the branching itself is deterministic and reviewable</td></tr>
</table><p>The fix: have the LLM do the part only an LLM can do — pull a normalized number out of a sentence like &quot;my budget is around forty-five thousand&quot; — and hand that number to a plain n8n <code>IF</code> node for the actual comparison. Same outcome, less surface area for the model to get wrong, and a branch condition you can read and reason about without wondering what the model was thinking.</p>
<h2>FAQ</h2>
<p><strong>Do I need n8n&#39;s paid/enterprise tier to use <code>@n8n/cli</code>?</strong>
No — the CLI talks to any n8n instance&#39;s REST API using an API key, which is available on self-hosted and cloud instances alike. What matters is that the API key has access to the workflows you want to manage.</p>
<p><strong>Is it safe to let an agent edit a workflow that&#39;s actively handling production traffic?</strong>
Treat it the way you&#39;d treat a production database migration: back up first, deactivate before structural changes, reactivate only after you&#39;ve verified the pushed version with a fresh <code>workflow get</code>. Non-structural changes (like a system-prompt tweak) are lower risk, but the deactivate-update-reactivate sequence costs almost nothing and removes an entire category of &quot;half-applied edit while live&quot; failure.</p>
<p><strong>Why not just use n8n&#39;s REST API directly instead of the CLI wrapper?</strong>
You can — <code>@n8n/cli</code> is a thin wrapper around the same REST API. The CLI is worth using because it gives your agent a stable, documented command surface (<code>workflow get</code>, <code>execution list</code>, and so on) instead of hand-rolling HTTP requests and reading n8n&#39;s OpenAPI spec from scratch every session.</p>
<p><strong>What if the agent introduces a bug during editing — how do I roll back?</strong>
This is exactly why you back up the JSON before editing. <code>workflow update &lt;id&gt; --file=&lt;backup&gt;.json</code> restores the previous state as directly as the original edit was applied. Keep every backup file, not just the most recent one, since you may need to go back more than one step.</p>
<p><strong>Does this replace testing the workflow manually?</strong>
No. Execution data tells you what happened in a run that already occurred — it doesn&#39;t replace deliberately sending test messages through the workflow and checking the result. Use both: script the edits, then exercise the workflow with real inputs before you trust it.</p>
<h2>Where this leaves you</h2>
<p>The n8n UI is still where I&#39;d go to eyeball a workflow&#39;s shape or drag a new node in for the first time. But once a workflow exists and you&#39;re iterating on it — tuning a system prompt, fixing a branch condition, chasing down why an AI Agent occasionally does something strange — <code>@n8n/cli</code> plus an agent that can script JSON edits and read execution data is a faster, more reviewable loop than clicking through the canvas and the executions tab by hand. The debugging technique alone — pulling full <code>runData</code> for a failed execution instead of guessing from the chat transcript — is worth adopting even if you never script a single structural edit.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/master-n8n-cli-debug-edit-workflows-without-ui</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/master-n8n-cli-debug-edit-workflows-without-ui</guid>
            <category><![CDATA[AI]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Fri, 24 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;If you&amp;#39;re using Claude Code or Codex to write application code, point it at &lt;code&gt;@n8n/cli&lt;/code&gt; too. Authenticate with &lt;code&gt;N8N_URL&lt;/code&gt; and &lt;code&gt;N8N_API_KEY&lt;/code&gt; as environment variables, and your agent can pull a live workflow as JSON, script structural edits with a throwaway Python pass, validate the graph before pushing, and — this is the part that actually changes how you work — pull real execution data with &lt;code&gt;execution get --includeData&lt;/code&gt; to see exactly which node failed and why, instead of guessing from the outside. Editing n8n workflows stops being a canvas you click through and becomes an artifact you can read, diff, and debug like any other code.&lt;/p&gt;
&lt;p&gt;I got here the ordinary way: iterating on an n8n workflow built around an AI Agent node, and hitting a wall that had nothing to do with my prompt engineering. The workflow would occasionally return a broken reply in production, and the only way to find out why was to open the n8n UI, click into the executions tab, and manually expand node outputs one at a time. That&amp;#39;s fine for one bug. It stops being fine once you&amp;#39;re iterating fast and the agent itself is misbehaving in ways that don&amp;#39;t show up as an obvious crash. So I stopped treating n8n as a UI-only tool and started treating it as something Claude Code could read and write directly, the same way it reads and writes a repo.&lt;/p&gt;
&lt;p&gt;This isn&amp;#39;t a Claude Code vs. Codex piece — the pattern is identical for either. What matters is that your coding agent already knows how to run a CLI, read JSON, and reason about structured output. &lt;code&gt;@n8n/cli&lt;/code&gt; gives it exactly that surface for a system that&amp;#39;s normally locked behind a browser.&lt;/p&gt;
&lt;h2&gt;Authenticate with environment variables, not &lt;code&gt;login&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;@n8n/cli&lt;/code&gt; ships an interactive &lt;code&gt;login&lt;/code&gt; command that saves your instance URL and API key to a local config file. Skip it. Every subcommand also reads &lt;code&gt;N8N_URL&lt;/code&gt; and &lt;code&gt;N8N_API_KEY&lt;/code&gt; directly from the environment:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# File: (shell session, not a repo file)
export N8N_URL=&amp;quot;https://your-n8n-instance.example.com&amp;quot;
export N8N_API_KEY=&amp;quot;your-api-key&amp;quot;
npx @n8n/cli workflow list --format=json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Using env vars instead of &lt;code&gt;login&lt;/code&gt; keeps the credential scoped to the current shell session rather than persisted to disk in a CLI config file your agent (or you, six months later) might forget is there. If your n8n API key is instance-wide rather than scoped to one project — common on a shared or self-hosted instance — this also means you&amp;#39;re not leaving a standing credential file behind after the session ends.&lt;/p&gt;
&lt;h2&gt;Look at the whole instance before you touch anything&lt;/h2&gt;
&lt;p&gt;The first command I run in a fresh session is always the same, and it&amp;#39;s not the one that touches the workflow I actually care about:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx @n8n/cli workflow list --format=json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On a shared n8n instance, this single call tells you something a lot of people skip checking: how many other workflows exist, who they belong to, and whether your API key&amp;#39;s reach extends beyond the one project you&amp;#39;re supposed to be working on. I&amp;#39;ve worked on instances where one API key genuinely could see and edit every client&amp;#39;s workflows. That&amp;#39;s not a reason to avoid the CLI — it&amp;#39;s a reason to run &lt;code&gt;workflow list&lt;/code&gt; first, confirm the workflow ID you&amp;#39;re about to touch, and keep every subsequent command scoped to that ID explicitly. An agent that only ever calls &lt;code&gt;workflow get &amp;lt;id&amp;gt;&lt;/code&gt; and &lt;code&gt;workflow update &amp;lt;id&amp;gt;&lt;/code&gt; for one confirmed ID is safer than one that goes hunting.&lt;/p&gt;
&lt;h2&gt;Pull the workflow as JSON and back it up first&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx @n8n/cli workflow get &amp;lt;workflow-id&amp;gt; --format=json &amp;gt; backups/workflow-id.$(date +%Y%m%d-%H%M%S).json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the step people skip because it feels like ceremony. It isn&amp;#39;t. A live n8n workflow isn&amp;#39;t a file in your repo with git history behind it — it&amp;#39;s a row in n8n&amp;#39;s own database, and n8n&amp;#39;s built-in version history is not something I&amp;#39;d rely on as the only safety net when an agent is scripting structural changes to it. A plain JSON snapshot, timestamped, sitting next to your other project files, costs one command and gives you an actual rollback path if an edit goes wrong. Do this before every structural change, not just the first one.&lt;/p&gt;
&lt;h2&gt;Script the edit — don&amp;#39;t hand-edit the JSON&lt;/h2&gt;
&lt;p&gt;Once you have the workflow JSON, the temptation is to open it and start editing fields by hand. Don&amp;#39;t. A single n8n workflow can easily have ten or more nodes, each with its own &lt;code&gt;parameters&lt;/code&gt;, &lt;code&gt;credentials&lt;/code&gt;, &lt;code&gt;typeVersion&lt;/code&gt;, and a &lt;code&gt;connections&lt;/code&gt; graph that has to reference node names exactly. Hand-editing that JSON is how you end up with a dangling connection to a node you just renamed.&lt;/p&gt;
&lt;p&gt;Instead, have your agent write a short Python script that loads the JSON, makes the specific structural change, and writes a new file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# File: scripts/edit-workflow.py
import json

with open(&amp;quot;backups/workflow-id.latest.json&amp;quot;) as f:
    workflow = json.load(f)

for node in workflow[&amp;quot;nodes&amp;quot;]:
    if node[&amp;quot;name&amp;quot;] == &amp;quot;AI Agent&amp;quot;:
        node[&amp;quot;parameters&amp;quot;][&amp;quot;options&amp;quot;][&amp;quot;systemMessage&amp;quot;] += (
            &amp;quot;\n\nNever call the same tool twice with identical parameters &amp;quot;
            &amp;quot;— the result will be identical. Vary at least one real parameter.&amp;quot;
        )

with open(&amp;quot;edited-workflow.json&amp;quot;, &amp;quot;w&amp;quot;, encoding=&amp;quot;utf-8&amp;quot;) as f:
    json.dump(workflow, f, ensure_ascii=False, indent=2)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before pushing anything, validate the graph structurally — every node referenced in &lt;code&gt;connections&lt;/code&gt; should actually exist in &lt;code&gt;nodes&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# File: scripts/validate-workflow.py
import json

with open(&amp;quot;edited-workflow.json&amp;quot;) as f:
    workflow = json.load(f)

names = {n[&amp;quot;name&amp;quot;] for n in workflow[&amp;quot;nodes&amp;quot;]}
errors = []
for source, by_type in workflow[&amp;quot;connections&amp;quot;].items():
    if source not in names:
        errors.append(f&amp;quot;unknown source: {source}&amp;quot;)
    for connections in by_type.values():
        for group in connections:
            for target in group:
                if target[&amp;quot;node&amp;quot;] not in names:
                    errors.append(f&amp;quot;unknown target: {target[&amp;#39;node&amp;#39;]}&amp;quot;)

print(&amp;quot;errors:&amp;quot;, errors or &amp;quot;none&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This one check catches the most common mistake an agent (or a human) makes when editing a node graph by script: renaming or removing a node and forgetting one of the connections that pointed at it. It costs nothing to run and it runs before the workflow ever reaches n8n.&lt;/p&gt;
&lt;h2&gt;Push safely: deactivate, update, reactivate — as three separate calls&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx @n8n/cli workflow deactivate &amp;lt;workflow-id&amp;gt;
npx @n8n/cli workflow update &amp;lt;workflow-id&amp;gt; --file=edited-workflow.json --format=json
npx @n8n/cli workflow activate &amp;lt;workflow-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the workflow you&amp;#39;re editing is active, structural changes — adding or removing nodes, rewiring connections — are safer applied while it&amp;#39;s briefly deactivated, then reactivated once the update succeeds. Doing this as three separate CLI calls, rather than assuming &lt;code&gt;workflow update&lt;/code&gt; alone is sufficient, gives you a clear point to stop and inspect the result before flipping the workflow back on. After the update, fetch it fresh with &lt;code&gt;workflow get&lt;/code&gt; and confirm the node count and connections match what you expected — don&amp;#39;t trust the update command&amp;#39;s own echoed response as the only confirmation.&lt;/p&gt;
&lt;h2&gt;The actual payoff: debugging with live execution data&lt;/h2&gt;
&lt;p&gt;Everything above is table stakes. This is the part that changes how you work once you&amp;#39;ve done it a few times.&lt;/p&gt;
&lt;p&gt;n8n keeps a full execution history, and &lt;code&gt;@n8n/cli&lt;/code&gt; can pull it with node-level detail:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx @n8n/cli execution list --workflow=&amp;lt;workflow-id&amp;gt; --status=error --format=json --limit=5
npx @n8n/cli execution get &amp;lt;execution-id&amp;gt; --includeData --format=json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second command returns the complete &lt;code&gt;runData&lt;/code&gt; for every node in that execution — every tool call, every input, every output, in order. This is the same information you&amp;#39;d normally have to click through node by node in the n8n UI, except now your agent can grep it, diff it across runs, and reason about it the way it reasons about a stack trace. Three real bugs came out of exactly this workflow in one session:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;An AI Agent hit its iteration ceiling by calling the same tool sixteen times with identical arguments.&lt;/strong&gt; Pulling the execution data showed every one of those sixteen calls had the exact same parameters — the model wasn&amp;#39;t exploring different scenarios, it was just repeating itself until it ran out of budget. The fix was a one-line addition to the system prompt: never call the same tool twice with identical parameters, since the result will be identical every time. Obvious once you see the actual tool-call sequence; invisible from the outside.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A tool-call parameter format mismatch that sometimes self-corrected and sometimes didn&amp;#39;t.&lt;/strong&gt; A custom tool expected a parameter as a JSON-encoded string; the model occasionally sent it as a plain object instead, got a validation error back, and usually retried correctly — except when it didn&amp;#39;t, at which point it burned through its remaining iterations on the same mistake. &lt;code&gt;execution get --includeData&lt;/code&gt; surfaced the exact validation error text on the first bad call, which is the only way to know this is happening at all, since a self-correcting model looks completely fine most of the time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The worst one: a silent empty response marked as success.&lt;/strong&gt; When the agent exhausted its iteration budget after the above, n8n didn&amp;#39;t return an error — the execution&amp;#39;s own status field said &lt;code&gt;&amp;quot;success&amp;quot;&lt;/code&gt; while the actual output was an empty string. That&amp;#39;s a strictly worse failure mode than a crash, because nothing downstream knows to treat it as a problem; a user just gets a blank reply. The fix was a small Code node placed right after the AI Agent node, checking whether the output is empty and substituting a graceful fallback message before anything gets returned:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// File: (n8n Code node, placed after the AI Agent node)
const output = ($json.output ?? &amp;#39;&amp;#39;).toString().trim();
if (!output) {
  return [{ json: { output: &amp;#39;Something went wrong on my end — please try again in a moment.&amp;#39; } }];
}
return [{ json: { output } }];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;None of these three bugs were visible from the chat interface sitting in front of the workflow. All three were visible in the first execution&amp;#39;s full &lt;code&gt;runData&lt;/code&gt; once I actually pulled it.&lt;/p&gt;
&lt;h2&gt;A shared-memory gotcha worth knowing about&lt;/h2&gt;
&lt;p&gt;One more failure mode, because it&amp;#39;s easy to reproduce without realizing it: if you build a small classifier sub-agent (say, one whose only job is to extract a structured value from the latest message) and wire it to the same conversation-memory node as your main persona agent, the classifier can start &amp;quot;seeing&amp;quot; the full chat history — including the main agent&amp;#39;s own prior replies — and drift into responding conversationally instead of returning strict structured output. It&amp;#39;s not a prompt problem so much as a wiring problem: a classifier that only needs the current message doesn&amp;#39;t need conversational memory attached at all. Removing that connection fixed it outright. If you have a sub-agent whose job is narrow and mechanical, keep its inputs narrow and mechanical too.&lt;/p&gt;
&lt;h2&gt;Prefer a deterministic node over an LLM judgment call&lt;/h2&gt;
&lt;p&gt;The last change worth calling out isn&amp;#39;t a bug fix, it&amp;#39;s a design correction. The workflow originally used an LLM call to answer a yes/no question — &amp;quot;is this value over a fixed threshold?&amp;quot; — before branching. That&amp;#39;s outsourcing a comparison a plain &lt;code&gt;IF&lt;/code&gt; node can do exactly right, every time, for free, with no latency.&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Approach&lt;/th&gt;&lt;th&gt;When it&apos;s tempting&lt;/th&gt;&lt;th&gt;Trade-off&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;LLM judgment call (yes/no or classification)&lt;/td&gt;&lt;td&gt;The condition seems to need &quot;understanding&quot; the message&lt;/td&gt;&lt;td&gt;Adds latency and cost, and a small but real chance of a wrong answer on a question that has one correct answer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;LLM extracts a value, plain node decides&lt;/td&gt;&lt;td&gt;You need natural-language understanding &lt;em&gt;and&lt;/em&gt; a reliable branch&lt;/td&gt;&lt;td&gt;Slightly more setup (a parser/schema for the extracted value), but the branching itself is deterministic and reviewable&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;&lt;p&gt;The fix: have the LLM do the part only an LLM can do — pull a normalized number out of a sentence like &amp;quot;my budget is around forty-five thousand&amp;quot; — and hand that number to a plain n8n &lt;code&gt;IF&lt;/code&gt; node for the actual comparison. Same outcome, less surface area for the model to get wrong, and a branch condition you can read and reason about without wondering what the model was thinking.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Do I need n8n&amp;#39;s paid/enterprise tier to use &lt;code&gt;@n8n/cli&lt;/code&gt;?&lt;/strong&gt;
No — the CLI talks to any n8n instance&amp;#39;s REST API using an API key, which is available on self-hosted and cloud instances alike. What matters is that the API key has access to the workflows you want to manage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is it safe to let an agent edit a workflow that&amp;#39;s actively handling production traffic?&lt;/strong&gt;
Treat it the way you&amp;#39;d treat a production database migration: back up first, deactivate before structural changes, reactivate only after you&amp;#39;ve verified the pushed version with a fresh &lt;code&gt;workflow get&lt;/code&gt;. Non-structural changes (like a system-prompt tweak) are lower risk, but the deactivate-update-reactivate sequence costs almost nothing and removes an entire category of &amp;quot;half-applied edit while live&amp;quot; failure.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why not just use n8n&amp;#39;s REST API directly instead of the CLI wrapper?&lt;/strong&gt;
You can — &lt;code&gt;@n8n/cli&lt;/code&gt; is a thin wrapper around the same REST API. The CLI is worth using because it gives your agent a stable, documented command surface (&lt;code&gt;workflow get&lt;/code&gt;, &lt;code&gt;execution list&lt;/code&gt;, and so on) instead of hand-rolling HTTP requests and reading n8n&amp;#39;s OpenAPI spec from scratch every session.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What if the agent introduces a bug during editing — how do I roll back?&lt;/strong&gt;
This is exactly why you back up the JSON before editing. &lt;code&gt;workflow update &amp;lt;id&amp;gt; --file=&amp;lt;backup&amp;gt;.json&lt;/code&gt; restores the previous state as directly as the original edit was applied. Keep every backup file, not just the most recent one, since you may need to go back more than one step.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does this replace testing the workflow manually?&lt;/strong&gt;
No. Execution data tells you what happened in a run that already occurred — it doesn&amp;#39;t replace deliberately sending test messages through the workflow and checking the result. Use both: script the edits, then exercise the workflow with real inputs before you trust it.&lt;/p&gt;
&lt;h2&gt;Where this leaves you&lt;/h2&gt;
&lt;p&gt;The n8n UI is still where I&amp;#39;d go to eyeball a workflow&amp;#39;s shape or drag a new node in for the first time. But once a workflow exists and you&amp;#39;re iterating on it — tuning a system prompt, fixing a branch condition, chasing down why an AI Agent occasionally does something strange — &lt;code&gt;@n8n/cli&lt;/code&gt; plus an agent that can script JSON edits and read execution data is a faster, more reviewable loop than clicking through the canvas and the executions tab by hand. The debugging technique alone — pulling full &lt;code&gt;runData&lt;/code&gt; for a failed execution instead of guessing from the chat transcript — is worth adopting even if you never script a single structural edit.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/master-n8n-cli-debug-edit-workflows-without-ui"/>
        </item>
        <item>
            <title><![CDATA[Fix a Site Broken in Chrome: 6 Proven Cache Steps Now]]></title>
            <description><![CDATA[<p>A page that misbehaves only in Chrome, while working fine in another browser, on another device, or through an external testing tool, usually points to something Chrome is holding onto rather than a bug in the page itself: corrupted cache data, an outdated redirect, stale DNS information, a leftover service worker, or a cached response from a proxy or CDN. The fastest way to confirm this is a cache-busting query parameter on the URL, and if that fixes the page, the rest of the fix is about clearing the right cache rather than touching code.</p>
<p>I run into this constantly across client sites with CDNs, service workers, and aggressive edge caching, and it always starts the same way: a client reports a broken page, I open it fine in Firefox or an incognito window, and Chrome alone shows the stale version. Here&#39;s the exact sequence I use to confirm it&#39;s a caching issue and clear it.</p>
<h2>Confirm the Cache With a Query Parameter</h2>
<p>Add a temporary query parameter to the affected URL:</p>
<pre><code class="language-text">https://example.com/problem-page/?test=1
</code></pre>
<p>This changes the URL enough that Chrome treats it as a new request instead of serving a cached response. If the page suddenly renders correctly, stale or corrupted cache data is almost certainly the cause, and you can move straight to clearing it instead of digging through the page&#39;s code.</p>
<h2>Clear Chrome&#39;s Cache for the Site</h2>
<p>A handful of fixes work through most cases, roughly in order of how disruptive they are:</p>
<ol>
<li>Open the page in Incognito mode.</li>
<li>Clear site data for the affected domain.</li>
<li>Use Chrome&#39;s Empty Cache and Hard Reload option.</li>
<li>Clear Chrome&#39;s DNS cache and socket pools.</li>
<li>Test on a different network or on mobile data.</li>
<li>Check whether a proxy, CDN, service worker, or redirect rule is caching a bad response.</li>
</ol>
<p>To clear data for one site specifically, open Chrome settings, search for the domain under stored site data, and remove it. Chrome DevTools offers a faster path for this during active debugging: open the Application panel, go to Storage, and select Clear site data.</p>
<h2>Check for Trailing-Slash and Route-Level Caching</h2>
<p>If the issue only shows up on one route, test both versions of the URL, since Chrome and some CDNs can cache them separately:</p>
<pre><code class="language-text">/example-page
/example-page/
</code></pre>
<p>A cache-busting parameter works here too:</p>
<pre><code class="language-text">/example-page/?refresh=1
</code></pre>
<p>If that query parameter clears the problem, the next step is usually clearing the relevant browser cache or purging the cached page directly from your CDN or hosting platform, since the stale copy is often sitting at that layer rather than in Chrome alone.</p>
<h2>Rule Out the Browser Before Debugging the Code</h2>
<p>Before assuming the website itself is broken, compare the page across normal Chrome, Incognito mode, another browser, another device, another network, and the same URL with a cache-busting query parameter. Six quick checks like this can save hours of debugging code that was never the problem.</p>
<h2>FAQ</h2>
<h3>Why does this happen in Chrome specifically and not other browsers?</h3>
<p>Each browser keeps its own cache, service worker registrations, and DNS resolver cache. Chrome tends to cache aggressively and hold onto service workers longer than some other browsers, so a stale response can persist in Chrome well after a CDN or origin server has already updated.</p>
<h3>Does Incognito mode guarantee a clean test?</h3>
<p>Mostly, but not completely. Incognito skips your existing cache and cookies, but it can still hit a cached response sitting at a CDN or proxy layer, so a correct result in Incognito narrows the problem to Chrome&#39;s local cache rather than ruling out caching entirely.</p>
<h3>What&#39;s the difference between &quot;Empty Cache and Hard Reload&quot; and clearing site data?</h3>
<p>A hard reload clears the cache for that page load only. Clearing site data through Chrome settings or DevTools removes cookies, local storage, service workers, and cached responses for the entire domain, which matters when a service worker is the actual culprit.</p>
<h3>How do I know if the problem is a CDN cache instead of a Chrome cache?</h3>
<p>If the cache-busting query parameter fixes the page in Chrome but the original URL still fails after clearing Chrome&#39;s cache and testing in Incognito, the stale response is most likely sitting at the CDN or proxy layer and needs to be purged there directly.</p>
<h3>Should I add cache-busting parameters permanently to avoid this?</h3>
<p>No. That defeats the purpose of caching and can hurt performance across your whole site. Use the query parameter only as a diagnostic step, then fix the actual cache configuration, service worker logic, or CDN purge behaviour causing the staleness.</p>
<h2>Conclusion</h2>
<p>A page that only breaks in Chrome is rarely a code problem. Confirming it with a cache-busting query parameter, then working through Chrome&#39;s site data, DNS cache, and any CDN or service worker involved, resolves most of these cases without touching a single line of application code. Let me know in the comments if you&#39;ve run into a caching issue this didn&#39;t catch, and subscribe for more practical debugging guides.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/fix-site-broken-in-chrome-cache-checklist</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/fix-site-broken-in-chrome-cache-checklist</guid>
            <category><![CDATA[Tools]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Thu, 23 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;A page that misbehaves only in Chrome, while working fine in another browser, on another device, or through an external testing tool, usually points to something Chrome is holding onto rather than a bug in the page itself: corrupted cache data, an outdated redirect, stale DNS information, a leftover service worker, or a cached response from a proxy or CDN. The fastest way to confirm this is a cache-busting query parameter on the URL, and if that fixes the page, the rest of the fix is about clearing the right cache rather than touching code.&lt;/p&gt;
&lt;p&gt;I run into this constantly across client sites with CDNs, service workers, and aggressive edge caching, and it always starts the same way: a client reports a broken page, I open it fine in Firefox or an incognito window, and Chrome alone shows the stale version. Here&amp;#39;s the exact sequence I use to confirm it&amp;#39;s a caching issue and clear it.&lt;/p&gt;
&lt;h2&gt;Confirm the Cache With a Query Parameter&lt;/h2&gt;
&lt;p&gt;Add a temporary query parameter to the affected URL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;https://example.com/problem-page/?test=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This changes the URL enough that Chrome treats it as a new request instead of serving a cached response. If the page suddenly renders correctly, stale or corrupted cache data is almost certainly the cause, and you can move straight to clearing it instead of digging through the page&amp;#39;s code.&lt;/p&gt;
&lt;h2&gt;Clear Chrome&amp;#39;s Cache for the Site&lt;/h2&gt;
&lt;p&gt;A handful of fixes work through most cases, roughly in order of how disruptive they are:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open the page in Incognito mode.&lt;/li&gt;
&lt;li&gt;Clear site data for the affected domain.&lt;/li&gt;
&lt;li&gt;Use Chrome&amp;#39;s Empty Cache and Hard Reload option.&lt;/li&gt;
&lt;li&gt;Clear Chrome&amp;#39;s DNS cache and socket pools.&lt;/li&gt;
&lt;li&gt;Test on a different network or on mobile data.&lt;/li&gt;
&lt;li&gt;Check whether a proxy, CDN, service worker, or redirect rule is caching a bad response.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;To clear data for one site specifically, open Chrome settings, search for the domain under stored site data, and remove it. Chrome DevTools offers a faster path for this during active debugging: open the Application panel, go to Storage, and select Clear site data.&lt;/p&gt;
&lt;h2&gt;Check for Trailing-Slash and Route-Level Caching&lt;/h2&gt;
&lt;p&gt;If the issue only shows up on one route, test both versions of the URL, since Chrome and some CDNs can cache them separately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/example-page
/example-page/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A cache-busting parameter works here too:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/example-page/?refresh=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If that query parameter clears the problem, the next step is usually clearing the relevant browser cache or purging the cached page directly from your CDN or hosting platform, since the stale copy is often sitting at that layer rather than in Chrome alone.&lt;/p&gt;
&lt;h2&gt;Rule Out the Browser Before Debugging the Code&lt;/h2&gt;
&lt;p&gt;Before assuming the website itself is broken, compare the page across normal Chrome, Incognito mode, another browser, another device, another network, and the same URL with a cache-busting query parameter. Six quick checks like this can save hours of debugging code that was never the problem.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;h3&gt;Why does this happen in Chrome specifically and not other browsers?&lt;/h3&gt;
&lt;p&gt;Each browser keeps its own cache, service worker registrations, and DNS resolver cache. Chrome tends to cache aggressively and hold onto service workers longer than some other browsers, so a stale response can persist in Chrome well after a CDN or origin server has already updated.&lt;/p&gt;
&lt;h3&gt;Does Incognito mode guarantee a clean test?&lt;/h3&gt;
&lt;p&gt;Mostly, but not completely. Incognito skips your existing cache and cookies, but it can still hit a cached response sitting at a CDN or proxy layer, so a correct result in Incognito narrows the problem to Chrome&amp;#39;s local cache rather than ruling out caching entirely.&lt;/p&gt;
&lt;h3&gt;What&amp;#39;s the difference between &amp;quot;Empty Cache and Hard Reload&amp;quot; and clearing site data?&lt;/h3&gt;
&lt;p&gt;A hard reload clears the cache for that page load only. Clearing site data through Chrome settings or DevTools removes cookies, local storage, service workers, and cached responses for the entire domain, which matters when a service worker is the actual culprit.&lt;/p&gt;
&lt;h3&gt;How do I know if the problem is a CDN cache instead of a Chrome cache?&lt;/h3&gt;
&lt;p&gt;If the cache-busting query parameter fixes the page in Chrome but the original URL still fails after clearing Chrome&amp;#39;s cache and testing in Incognito, the stale response is most likely sitting at the CDN or proxy layer and needs to be purged there directly.&lt;/p&gt;
&lt;h3&gt;Should I add cache-busting parameters permanently to avoid this?&lt;/h3&gt;
&lt;p&gt;No. That defeats the purpose of caching and can hurt performance across your whole site. Use the query parameter only as a diagnostic step, then fix the actual cache configuration, service worker logic, or CDN purge behaviour causing the staleness.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;A page that only breaks in Chrome is rarely a code problem. Confirming it with a cache-busting query parameter, then working through Chrome&amp;#39;s site data, DNS cache, and any CDN or service worker involved, resolves most of these cases without touching a single line of application code. Let me know in the comments if you&amp;#39;ve run into a caching issue this didn&amp;#39;t catch, and subscribe for more practical debugging guides.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/fix-site-broken-in-chrome-cache-checklist"/>
        </item>
        <item>
            <title><![CDATA[Enterprise Clients: Readiness Checklist for Small Agencies]]></title>
            <description><![CDATA[<p>Enterprise clients evaluate a freelancer or small agency on a different question than smaller clients do. A small client mostly checks whether the product works: does the site look right, does the form submit, can an admin update the content. An enterprise client checks whether it can safely depend on the provider long after launch, which means more documentation, more security controls, clearer account ownership, defined support levels, and proof that the system keeps running even if a person leaves, a server fails, or the provider relationship ends. This guide walks through each of those shifts and what a small agency should prepare before taking on an enterprise engagement.</p>
<p>Moving from small-agency clients to enterprise clients isn&#39;t straightforward. I&#39;ve been working through this shift directly while running a multi-brand CMS and DAM engagement for CanPrev, where security documentation, SSO, subprocessor tracking, and account ownership all became real deliverables rather than side notes. Here&#39;s what changed, section by section.</p>
<h2>The Product Is Only One Part of the Deliverable</h2>
<p>With smaller clients, the result is judged mainly by what shows up on screen. Does the application work, can users submit the form, can administrators update the content.</p>
<p>An enterprise evaluates a much larger system around the product, and will often expect technical architecture documentation, security documentation, hosting and infrastructure diagrams, role and permission definitions, deployment procedures, backup and recovery procedures, testing reports, user manuals, API documentation, monitoring and alerting, administrator training, a post-launch support model, and a plan for handing the system to another provider.</p>
<p>The code may represent half of the technical work. The other half consists of proving that the code can be operated, audited, maintained, and transferred safely. Documentation belongs in the estimate from the start, as part of the product itself.</p>
<h2>Security Must Be Demonstrable</h2>
<p>A small business may accept &quot;the application follows security best practices&quot; as a full answer. An enterprise will usually ask what those practices are, who owns them, and how they can be verified.</p>
<p>A security discussion at that level covers HTTPS and TLS configuration, Web Application Firewall protection, DDoS protection, coverage against the OWASP Top 10, secure authentication, multifactor authentication, enterprise single sign-on, role-based access control, audit logs, API rate limiting, secret and credential management, dependency updates, vulnerability scanning, encryption at rest, backup protection, data-retention policies, penetration testing, and incident response procedures.</p>
<p>The Avtolibre RFP is a good example of how specific this gets. It requests HTTPS, OWASP protection, a WAF, network-level and application-level DDoS protection, audit logging, and penetration testing, and it requires the contractor to remediate all Critical and High findings as part of the original scope.</p>
<p>That last detail carries commercial weight. A provider can estimate the effort to build the system and still miss the cost of preparing for a penetration test, supporting the test, investigating reported findings, remediating them, running regression testing, and completing a second validation round. Security requirements create engineering work, documentation work, and financial risk, so they belong in the estimate before development starts, not after it finishes.</p>
<h2>Enterprise SSO Is Not Just a Login Button</h2>
<p>Smaller projects commonly run on email-and-password or social login. Enterprise environments usually require integration with the organisation&#39;s identity provider, such as Microsoft Entra ID, Google Workspace, Okta, or another SAML or OpenID Connect provider.</p>
<p>The client typically expects employees to sign in with their existing corporate accounts, automatic access removal when an employee leaves, permissions driven by group membership, multifactor authentication that stays under corporate control, central visibility into sign-in activity, and distinct access policies for employees, partners, and administrators.</p>
<p>The technical integration itself is usually straightforward. The real complexity sits in defining who is allowed to access what: can all employees sign in, or must they be assigned to the application first? Which corporate groups map to which application roles? Are external partners included in the same identity system, and can a partner administrator invite other users? Who removes access when a partnership ends? Is emergency local access needed if the identity provider goes down? Are privileged actions recorded?</p>
<p>Enterprise authentication sits at the intersection of technology, HR process, IT policy, and security governance. A provider&#39;s job here goes beyond promising &quot;SSO.&quot; It means understanding the client&#39;s identity model and defining the complete access lifecycle around it.</p>
<h2>Data Location and Subprocessors Matter</h2>
<p>A freelancer typically picks services based on cost, performance, and developer experience. An enterprise will ask where the data is stored, where backups live, whether support staff outside the EU can access the data, which third-party services receive customer information, whether the AI provider retains prompts or uses customer data for model training, what analytics data gets collected, whether IP addresses count as personal data, which subprocessors are involved, what happens when data must be deleted, and how quickly all customer data can be exported.</p>
<p>In the initial OTP discussion, the client raised GDPR, EU data residency, customer data storage, traceability, and control over AI actions directly. Architecture decisions in this context can&#39;t be made purely on ease of implementation.</p>
<p>A cloud database, an email provider, an analytics platform, an error-tracking service, and an AI API can easily add up to five separate subprocessors, each with its own data region, retention policy, and contractual terms. A responsible provider maintains a basic service register tracking the service name, its purpose, the data it processes, its hosting region, its retention behaviour, the responsible account owner, its contractual or data-processing status, and a replacement or exit strategy. Smaller agencies rarely need this level of tracking, and enterprise clients increasingly expect it.</p>
<h2>Ownership Must Be Defined Before Launch</h2>
<p>Account ownership is one of the most overlooked enterprise issues: who owns the domain, DNS and Cloudflare, the cloud account, the source-code repository, the database, object storage, email delivery accounts, analytics properties, monitoring services, API credentials, signing certificates, mobile application accounts, and backup encryption keys.</p>
<p>A small agency will often create these services under its own account because it&#39;s faster to set up. That convenience can turn into a serious enterprise problem once the client discovers a critical production system running on a freelancer&#39;s personal email address, a credit card belonging to the agency, a private GitHub organisation, a domain account internal IT can&#39;t touch, credentials stored on one developer&#39;s laptop, or an infrastructure account that resists transfer.</p>
<p>A better model has the client own production accounts and grant the provider the access it needs. Where agency-owned accounts are unavoidable, the contract and documentation should spell out what the agency owns, what the client owns, how billing works, how access will transfer, how long that transfer takes, and whether migration costs are included. Enterprise clients care about this because they need to keep operating without becoming permanently locked to one supplier.</p>
<h2>Business Continuity Includes Your Business</h2>
<p>An enterprise also evaluates the provider itself. For a two-person studio, the client is quietly asking what happens if the lead developer becomes unavailable, whether anyone else understands the infrastructure, whether the source code is documented, whether credentials are centrally stored, whether another supplier could take over, whether there&#39;s a backup contact, whether releases depend on one person&#39;s laptop, and whether the provider has capacity for an urgent incident. This is usually called key-person risk.</p>
<p>A small provider doesn&#39;t need a hundred employees to answer these questions well. It needs to show that its size doesn&#39;t translate into operational fragility. Practical controls include shared repositories, password-manager access, documented deployment procedures, infrastructure as code, centralised environment-variable management, a clear backup contact, regular handover documentation, client access to production systems, automated testing and deployments, and a realistic support and escalation process. None of this eliminates the value of a specialist; it just keeps specialist knowledge out of a single person&#39;s head.</p>
<h2>Support Expectations Must Be Specific</h2>
<p>&quot;Post-launch support&quot; means different things to different people. A provider may read it as fixing occasional bugs when time allows. A client may read it as guaranteed response times, business-hours coverage, emergency coverage outside business hours, security patching, infrastructure monitoring, backup monitoring, incident investigation, dependency upgrades, browser compatibility updates, small content or configuration changes, monthly reporting, and a guaranteed amount of development capacity.</p>
<p>Turning these into explicit service levels closes that gap: a critical incident gets a response within one hour, a high-priority incident within four business hours, a normal defect within two business days, planned maintenance gets communicated several days ahead, security updates get assessed and applied by severity, and backups get monitored automatically with restoration tested periodically.</p>
<p>A small agency should think carefully before promising 24/7 support without the people and systems to back it up. A narrower, credible service level holds up better over time than an impressive SLA the team can&#39;t actually fulfil.</p>
<h2>Change Management Becomes More Formal</h2>
<p>In smaller projects, scope evolves through informal messages: could you also add this field, can we slightly change the workflow, this should probably connect to the CRM as well.</p>
<p>In an enterprise engagement, small changes can ripple into security, compliance, data models, integrations, training, testing, documentation, project timelines, and other suppliers. A formal change process doesn&#39;t need to be bureaucratic to handle this. It just needs to record what is changing, why, who requested it, what it affects, the estimated cost, the timeline impact, and who approved it.</p>
<p>Enterprise clients generally accept change requests once the consequences are explained clearly. The friction usually comes from the provider absorbing dozens of small changes quietly and only raising the issue once the project is already late.</p>
<h2>Acceptance Is More Important Than &quot;Finished&quot;</h2>
<p>A freelancer may treat a project as complete once the agreed features are built. An enterprise typically treats it as complete once user acceptance testing has passed, security findings are resolved, performance targets are met, documentation is delivered, administrators are trained, production access is approved, backups are verified, monitoring is operational, and the internal project owner has formally accepted the milestone.</p>
<p>Contracts should define acceptance criteria for each phase. Without that, a provider can believe a milestone is done while the client believes it&#39;s still open, which delays invoices and creates disagreement over whether outstanding requests are defects, missing requirements, or new scope.</p>
<h2>Internal Stakeholders Are Part of the System</h2>
<p>Enterprise buying decisions rarely come from one person. A project can involve a business owner, marketing, IT, security, legal, compliance, procurement, finance, data protection, customer support, external penetration testers, existing technology suppliers, and senior management, and each of them sees a different risk. Marketing weighs speed and flexibility, IT weighs maintainability, security weighs access control, procurement weighs contractual protection, and finance weighs payment terms and predictable cost.</p>
<p>A strong provider treats these stakeholders as a source of requirements rather than an obstacle. Their questions describe exactly what the organisation needs in order to approve and operate the system, and part of the provider&#39;s job is translating between them. That&#39;s why enterprise work tends to include more meetings, written decisions, and approval stages than the technical complexity alone would suggest.</p>
<h2>Evidence Is More Valuable Than Reassurance</h2>
<p>When an enterprise asks whether backups exist, &quot;yes, the hosting provider handles that&quot; is a weak answer on its own. A stronger answer names the backup frequency, retention period, storage location, encryption status, last successful backup, last restoration test, responsible person, and recovery procedure.</p>
<p>The same principle carries over to security, performance, and availability. Security claims land better backed by dependency scan results, security headers, access-control documentation, penetration-test reports, patch procedures, and audit logs. Performance claims land better backed by Lighthouse results, load-test results, API response measurements, and monitoring dashboards. Enterprise trust gets built on evidence like this, not on assurances alone.</p>
<h2>The Commercial Model Must Account for Enterprise Overhead</h2>
<p>A common mistake treats an enterprise project as a bigger version of a normal website, estimating design and development hours while missing procurement calls, security questionnaires, architecture reviews, stakeholder workshops, documentation, training, penetration-test remediation, deployment coordination, status reporting, acceptance support, legal and compliance discussions, delayed decisions, and extended payment terms. All of that work is real and belongs in the price.</p>
<p>Enterprise projects also carry contractual clauses around warranties, intellectual-property ownership, confidentiality, data processing, indemnification, service levels, liability, security incidents, subcontractors, termination assistance, mandatory remediation, and insurance requirements. A small provider should read unlimited liability, broad indemnification obligations, and commitments that exceed the total contract value especially carefully. A larger contract is genuinely attractive, and it&#39;s worth knowing exactly how much risk comes attached to it.</p>
<h2>What a Small Agency Should Prepare</h2>
<p>A small agency doesn&#39;t need a large corporate structure to approach enterprise clients. It needs a professional operating baseline, which at minimum looks like: a standard architecture document, a security and hosting overview, a list of subprocessors and services, a role and access-control model, a backup and recovery description, a deployment and rollback procedure, a support and escalation model, a standard Data Processing Agreement process, clear ownership of infrastructure and accounts, a handover and exit plan, a change-request process, milestone acceptance criteria, a secure credential-management process, evidence of testing and quality assurance, and a realistic explanation of team capacity and continuity.</p>
<p>None of this needs to run hundreds of pages. A concise, accurate document holds up better than an impressive policy copied from a large corporation and never actually followed.</p>
<h2>The Main Shift in Mindset</h2>
<p>Enterprise clients aren&#39;t asking for bureaucracy for its own sake. They&#39;re working through legitimate questions: can this system expose customer data, can it survive an outage, can employees access only what they should, can we prove what happened during an incident, can we replace the provider if necessary, can the system grow without a rebuild, can our legal and security teams approve it, can the provider support it after launch, can senior management trust the project.</p>
<p>A freelancer or small agency can serve enterprise clients well without turning into a traditional large consultancy. A specialist provider often moves faster, makes sharper technical decisions, and gives more senior attention than a larger agency would, and pairing that speed with discipline is what makes the engagement work.</p>
<p>Enterprise readiness isn&#39;t about headcount or slide decks. It&#39;s about delivering a system whose risks, ownership, operation, and future are fully understood. The client isn&#39;t only buying the application built today. They&#39;re buying confidence that it stays secure, operable, and supportable tomorrow.</p>
<h2>FAQ</h2>
<h3>How is estimating an enterprise project different from estimating a normal website?</h3>
<p>The build hours look similar at first glance. The difference comes from the surrounding work: documentation, security reviews, penetration-test remediation, stakeholder workshops, and acceptance support all need their own line items, or they quietly eat the margin.</p>
<h3>Does a small agency need enterprise-grade tooling to take on these clients?</h3>
<p>No. It needs a professional operating baseline: documented architecture, clear account ownership, a defined support model, and evidence of testing. These can be built incrementally and don&#39;t require a large team.</p>
<h3>What&#39;s the single most common mistake small providers make with enterprise clients?</h3>
<p>Treating account ownership and documentation as afterthoughts. Services created under a personal account or left undocumented become the hardest things to unwind once the client asks for a transfer plan.</p>
<h3>Is enterprise SSO harder to implement than social login?</h3>
<p>The integration itself is usually not the hard part. The hard part is defining the access lifecycle: who gets in, which groups map to which roles, and who removes access when someone leaves.</p>
<h3>Should a small agency promise 24/7 support to win enterprise deals?</h3>
<p>Only if it can actually deliver it. A narrower, honest service level performs better over the life of the contract than an SLA the team can&#39;t consistently meet.</p>
<h2>Conclusion</h2>
<p>Enterprise clients aren&#39;t buying a bigger version of the same website project. They&#39;re buying documented architecture, demonstrable security, defined account ownership, specific support levels, and evidence that the system keeps running without becoming dependent on one person or one supplier. A small agency that builds this operating baseline can serve enterprise clients well, often with more speed and senior attention than a larger consultancy provides. Let me know in the comments if you have questions, and subscribe for more practical guides on running a technical agency.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/enterprise-clients-readiness-checklist</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/enterprise-clients-readiness-checklist</guid>
            <category><![CDATA[Entrepreneurship]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Wed, 22 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;Enterprise clients evaluate a freelancer or small agency on a different question than smaller clients do. A small client mostly checks whether the product works: does the site look right, does the form submit, can an admin update the content. An enterprise client checks whether it can safely depend on the provider long after launch, which means more documentation, more security controls, clearer account ownership, defined support levels, and proof that the system keeps running even if a person leaves, a server fails, or the provider relationship ends. This guide walks through each of those shifts and what a small agency should prepare before taking on an enterprise engagement.&lt;/p&gt;
&lt;p&gt;Moving from small-agency clients to enterprise clients isn&amp;#39;t straightforward. I&amp;#39;ve been working through this shift directly while running a multi-brand CMS and DAM engagement for CanPrev, where security documentation, SSO, subprocessor tracking, and account ownership all became real deliverables rather than side notes. Here&amp;#39;s what changed, section by section.&lt;/p&gt;
&lt;h2&gt;The Product Is Only One Part of the Deliverable&lt;/h2&gt;
&lt;p&gt;With smaller clients, the result is judged mainly by what shows up on screen. Does the application work, can users submit the form, can administrators update the content.&lt;/p&gt;
&lt;p&gt;An enterprise evaluates a much larger system around the product, and will often expect technical architecture documentation, security documentation, hosting and infrastructure diagrams, role and permission definitions, deployment procedures, backup and recovery procedures, testing reports, user manuals, API documentation, monitoring and alerting, administrator training, a post-launch support model, and a plan for handing the system to another provider.&lt;/p&gt;
&lt;p&gt;The code may represent half of the technical work. The other half consists of proving that the code can be operated, audited, maintained, and transferred safely. Documentation belongs in the estimate from the start, as part of the product itself.&lt;/p&gt;
&lt;h2&gt;Security Must Be Demonstrable&lt;/h2&gt;
&lt;p&gt;A small business may accept &amp;quot;the application follows security best practices&amp;quot; as a full answer. An enterprise will usually ask what those practices are, who owns them, and how they can be verified.&lt;/p&gt;
&lt;p&gt;A security discussion at that level covers HTTPS and TLS configuration, Web Application Firewall protection, DDoS protection, coverage against the OWASP Top 10, secure authentication, multifactor authentication, enterprise single sign-on, role-based access control, audit logs, API rate limiting, secret and credential management, dependency updates, vulnerability scanning, encryption at rest, backup protection, data-retention policies, penetration testing, and incident response procedures.&lt;/p&gt;
&lt;p&gt;The Avtolibre RFP is a good example of how specific this gets. It requests HTTPS, OWASP protection, a WAF, network-level and application-level DDoS protection, audit logging, and penetration testing, and it requires the contractor to remediate all Critical and High findings as part of the original scope.&lt;/p&gt;
&lt;p&gt;That last detail carries commercial weight. A provider can estimate the effort to build the system and still miss the cost of preparing for a penetration test, supporting the test, investigating reported findings, remediating them, running regression testing, and completing a second validation round. Security requirements create engineering work, documentation work, and financial risk, so they belong in the estimate before development starts, not after it finishes.&lt;/p&gt;
&lt;h2&gt;Enterprise SSO Is Not Just a Login Button&lt;/h2&gt;
&lt;p&gt;Smaller projects commonly run on email-and-password or social login. Enterprise environments usually require integration with the organisation&amp;#39;s identity provider, such as Microsoft Entra ID, Google Workspace, Okta, or another SAML or OpenID Connect provider.&lt;/p&gt;
&lt;p&gt;The client typically expects employees to sign in with their existing corporate accounts, automatic access removal when an employee leaves, permissions driven by group membership, multifactor authentication that stays under corporate control, central visibility into sign-in activity, and distinct access policies for employees, partners, and administrators.&lt;/p&gt;
&lt;p&gt;The technical integration itself is usually straightforward. The real complexity sits in defining who is allowed to access what: can all employees sign in, or must they be assigned to the application first? Which corporate groups map to which application roles? Are external partners included in the same identity system, and can a partner administrator invite other users? Who removes access when a partnership ends? Is emergency local access needed if the identity provider goes down? Are privileged actions recorded?&lt;/p&gt;
&lt;p&gt;Enterprise authentication sits at the intersection of technology, HR process, IT policy, and security governance. A provider&amp;#39;s job here goes beyond promising &amp;quot;SSO.&amp;quot; It means understanding the client&amp;#39;s identity model and defining the complete access lifecycle around it.&lt;/p&gt;
&lt;h2&gt;Data Location and Subprocessors Matter&lt;/h2&gt;
&lt;p&gt;A freelancer typically picks services based on cost, performance, and developer experience. An enterprise will ask where the data is stored, where backups live, whether support staff outside the EU can access the data, which third-party services receive customer information, whether the AI provider retains prompts or uses customer data for model training, what analytics data gets collected, whether IP addresses count as personal data, which subprocessors are involved, what happens when data must be deleted, and how quickly all customer data can be exported.&lt;/p&gt;
&lt;p&gt;In the initial OTP discussion, the client raised GDPR, EU data residency, customer data storage, traceability, and control over AI actions directly. Architecture decisions in this context can&amp;#39;t be made purely on ease of implementation.&lt;/p&gt;
&lt;p&gt;A cloud database, an email provider, an analytics platform, an error-tracking service, and an AI API can easily add up to five separate subprocessors, each with its own data region, retention policy, and contractual terms. A responsible provider maintains a basic service register tracking the service name, its purpose, the data it processes, its hosting region, its retention behaviour, the responsible account owner, its contractual or data-processing status, and a replacement or exit strategy. Smaller agencies rarely need this level of tracking, and enterprise clients increasingly expect it.&lt;/p&gt;
&lt;h2&gt;Ownership Must Be Defined Before Launch&lt;/h2&gt;
&lt;p&gt;Account ownership is one of the most overlooked enterprise issues: who owns the domain, DNS and Cloudflare, the cloud account, the source-code repository, the database, object storage, email delivery accounts, analytics properties, monitoring services, API credentials, signing certificates, mobile application accounts, and backup encryption keys.&lt;/p&gt;
&lt;p&gt;A small agency will often create these services under its own account because it&amp;#39;s faster to set up. That convenience can turn into a serious enterprise problem once the client discovers a critical production system running on a freelancer&amp;#39;s personal email address, a credit card belonging to the agency, a private GitHub organisation, a domain account internal IT can&amp;#39;t touch, credentials stored on one developer&amp;#39;s laptop, or an infrastructure account that resists transfer.&lt;/p&gt;
&lt;p&gt;A better model has the client own production accounts and grant the provider the access it needs. Where agency-owned accounts are unavoidable, the contract and documentation should spell out what the agency owns, what the client owns, how billing works, how access will transfer, how long that transfer takes, and whether migration costs are included. Enterprise clients care about this because they need to keep operating without becoming permanently locked to one supplier.&lt;/p&gt;
&lt;h2&gt;Business Continuity Includes Your Business&lt;/h2&gt;
&lt;p&gt;An enterprise also evaluates the provider itself. For a two-person studio, the client is quietly asking what happens if the lead developer becomes unavailable, whether anyone else understands the infrastructure, whether the source code is documented, whether credentials are centrally stored, whether another supplier could take over, whether there&amp;#39;s a backup contact, whether releases depend on one person&amp;#39;s laptop, and whether the provider has capacity for an urgent incident. This is usually called key-person risk.&lt;/p&gt;
&lt;p&gt;A small provider doesn&amp;#39;t need a hundred employees to answer these questions well. It needs to show that its size doesn&amp;#39;t translate into operational fragility. Practical controls include shared repositories, password-manager access, documented deployment procedures, infrastructure as code, centralised environment-variable management, a clear backup contact, regular handover documentation, client access to production systems, automated testing and deployments, and a realistic support and escalation process. None of this eliminates the value of a specialist; it just keeps specialist knowledge out of a single person&amp;#39;s head.&lt;/p&gt;
&lt;h2&gt;Support Expectations Must Be Specific&lt;/h2&gt;
&lt;p&gt;&amp;quot;Post-launch support&amp;quot; means different things to different people. A provider may read it as fixing occasional bugs when time allows. A client may read it as guaranteed response times, business-hours coverage, emergency coverage outside business hours, security patching, infrastructure monitoring, backup monitoring, incident investigation, dependency upgrades, browser compatibility updates, small content or configuration changes, monthly reporting, and a guaranteed amount of development capacity.&lt;/p&gt;
&lt;p&gt;Turning these into explicit service levels closes that gap: a critical incident gets a response within one hour, a high-priority incident within four business hours, a normal defect within two business days, planned maintenance gets communicated several days ahead, security updates get assessed and applied by severity, and backups get monitored automatically with restoration tested periodically.&lt;/p&gt;
&lt;p&gt;A small agency should think carefully before promising 24/7 support without the people and systems to back it up. A narrower, credible service level holds up better over time than an impressive SLA the team can&amp;#39;t actually fulfil.&lt;/p&gt;
&lt;h2&gt;Change Management Becomes More Formal&lt;/h2&gt;
&lt;p&gt;In smaller projects, scope evolves through informal messages: could you also add this field, can we slightly change the workflow, this should probably connect to the CRM as well.&lt;/p&gt;
&lt;p&gt;In an enterprise engagement, small changes can ripple into security, compliance, data models, integrations, training, testing, documentation, project timelines, and other suppliers. A formal change process doesn&amp;#39;t need to be bureaucratic to handle this. It just needs to record what is changing, why, who requested it, what it affects, the estimated cost, the timeline impact, and who approved it.&lt;/p&gt;
&lt;p&gt;Enterprise clients generally accept change requests once the consequences are explained clearly. The friction usually comes from the provider absorbing dozens of small changes quietly and only raising the issue once the project is already late.&lt;/p&gt;
&lt;h2&gt;Acceptance Is More Important Than &amp;quot;Finished&amp;quot;&lt;/h2&gt;
&lt;p&gt;A freelancer may treat a project as complete once the agreed features are built. An enterprise typically treats it as complete once user acceptance testing has passed, security findings are resolved, performance targets are met, documentation is delivered, administrators are trained, production access is approved, backups are verified, monitoring is operational, and the internal project owner has formally accepted the milestone.&lt;/p&gt;
&lt;p&gt;Contracts should define acceptance criteria for each phase. Without that, a provider can believe a milestone is done while the client believes it&amp;#39;s still open, which delays invoices and creates disagreement over whether outstanding requests are defects, missing requirements, or new scope.&lt;/p&gt;
&lt;h2&gt;Internal Stakeholders Are Part of the System&lt;/h2&gt;
&lt;p&gt;Enterprise buying decisions rarely come from one person. A project can involve a business owner, marketing, IT, security, legal, compliance, procurement, finance, data protection, customer support, external penetration testers, existing technology suppliers, and senior management, and each of them sees a different risk. Marketing weighs speed and flexibility, IT weighs maintainability, security weighs access control, procurement weighs contractual protection, and finance weighs payment terms and predictable cost.&lt;/p&gt;
&lt;p&gt;A strong provider treats these stakeholders as a source of requirements rather than an obstacle. Their questions describe exactly what the organisation needs in order to approve and operate the system, and part of the provider&amp;#39;s job is translating between them. That&amp;#39;s why enterprise work tends to include more meetings, written decisions, and approval stages than the technical complexity alone would suggest.&lt;/p&gt;
&lt;h2&gt;Evidence Is More Valuable Than Reassurance&lt;/h2&gt;
&lt;p&gt;When an enterprise asks whether backups exist, &amp;quot;yes, the hosting provider handles that&amp;quot; is a weak answer on its own. A stronger answer names the backup frequency, retention period, storage location, encryption status, last successful backup, last restoration test, responsible person, and recovery procedure.&lt;/p&gt;
&lt;p&gt;The same principle carries over to security, performance, and availability. Security claims land better backed by dependency scan results, security headers, access-control documentation, penetration-test reports, patch procedures, and audit logs. Performance claims land better backed by Lighthouse results, load-test results, API response measurements, and monitoring dashboards. Enterprise trust gets built on evidence like this, not on assurances alone.&lt;/p&gt;
&lt;h2&gt;The Commercial Model Must Account for Enterprise Overhead&lt;/h2&gt;
&lt;p&gt;A common mistake treats an enterprise project as a bigger version of a normal website, estimating design and development hours while missing procurement calls, security questionnaires, architecture reviews, stakeholder workshops, documentation, training, penetration-test remediation, deployment coordination, status reporting, acceptance support, legal and compliance discussions, delayed decisions, and extended payment terms. All of that work is real and belongs in the price.&lt;/p&gt;
&lt;p&gt;Enterprise projects also carry contractual clauses around warranties, intellectual-property ownership, confidentiality, data processing, indemnification, service levels, liability, security incidents, subcontractors, termination assistance, mandatory remediation, and insurance requirements. A small provider should read unlimited liability, broad indemnification obligations, and commitments that exceed the total contract value especially carefully. A larger contract is genuinely attractive, and it&amp;#39;s worth knowing exactly how much risk comes attached to it.&lt;/p&gt;
&lt;h2&gt;What a Small Agency Should Prepare&lt;/h2&gt;
&lt;p&gt;A small agency doesn&amp;#39;t need a large corporate structure to approach enterprise clients. It needs a professional operating baseline, which at minimum looks like: a standard architecture document, a security and hosting overview, a list of subprocessors and services, a role and access-control model, a backup and recovery description, a deployment and rollback procedure, a support and escalation model, a standard Data Processing Agreement process, clear ownership of infrastructure and accounts, a handover and exit plan, a change-request process, milestone acceptance criteria, a secure credential-management process, evidence of testing and quality assurance, and a realistic explanation of team capacity and continuity.&lt;/p&gt;
&lt;p&gt;None of this needs to run hundreds of pages. A concise, accurate document holds up better than an impressive policy copied from a large corporation and never actually followed.&lt;/p&gt;
&lt;h2&gt;The Main Shift in Mindset&lt;/h2&gt;
&lt;p&gt;Enterprise clients aren&amp;#39;t asking for bureaucracy for its own sake. They&amp;#39;re working through legitimate questions: can this system expose customer data, can it survive an outage, can employees access only what they should, can we prove what happened during an incident, can we replace the provider if necessary, can the system grow without a rebuild, can our legal and security teams approve it, can the provider support it after launch, can senior management trust the project.&lt;/p&gt;
&lt;p&gt;A freelancer or small agency can serve enterprise clients well without turning into a traditional large consultancy. A specialist provider often moves faster, makes sharper technical decisions, and gives more senior attention than a larger agency would, and pairing that speed with discipline is what makes the engagement work.&lt;/p&gt;
&lt;p&gt;Enterprise readiness isn&amp;#39;t about headcount or slide decks. It&amp;#39;s about delivering a system whose risks, ownership, operation, and future are fully understood. The client isn&amp;#39;t only buying the application built today. They&amp;#39;re buying confidence that it stays secure, operable, and supportable tomorrow.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;h3&gt;How is estimating an enterprise project different from estimating a normal website?&lt;/h3&gt;
&lt;p&gt;The build hours look similar at first glance. The difference comes from the surrounding work: documentation, security reviews, penetration-test remediation, stakeholder workshops, and acceptance support all need their own line items, or they quietly eat the margin.&lt;/p&gt;
&lt;h3&gt;Does a small agency need enterprise-grade tooling to take on these clients?&lt;/h3&gt;
&lt;p&gt;No. It needs a professional operating baseline: documented architecture, clear account ownership, a defined support model, and evidence of testing. These can be built incrementally and don&amp;#39;t require a large team.&lt;/p&gt;
&lt;h3&gt;What&amp;#39;s the single most common mistake small providers make with enterprise clients?&lt;/h3&gt;
&lt;p&gt;Treating account ownership and documentation as afterthoughts. Services created under a personal account or left undocumented become the hardest things to unwind once the client asks for a transfer plan.&lt;/p&gt;
&lt;h3&gt;Is enterprise SSO harder to implement than social login?&lt;/h3&gt;
&lt;p&gt;The integration itself is usually not the hard part. The hard part is defining the access lifecycle: who gets in, which groups map to which roles, and who removes access when someone leaves.&lt;/p&gt;
&lt;h3&gt;Should a small agency promise 24/7 support to win enterprise deals?&lt;/h3&gt;
&lt;p&gt;Only if it can actually deliver it. A narrower, honest service level performs better over the life of the contract than an SLA the team can&amp;#39;t consistently meet.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Enterprise clients aren&amp;#39;t buying a bigger version of the same website project. They&amp;#39;re buying documented architecture, demonstrable security, defined account ownership, specific support levels, and evidence that the system keeps running without becoming dependent on one person or one supplier. A small agency that builds this operating baseline can serve enterprise clients well, often with more speed and senior attention than a larger consultancy provides. Let me know in the comments if you have questions, and subscribe for more practical guides on running a technical agency.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/enterprise-clients-readiness-checklist"/>
        </item>
        <item>
            <title><![CDATA[Single Sign-On for Business Apps: Google & Entra ID]]></title>
            <description><![CDATA[<p>Single sign-on lets employees open an internal application with the same company account they already use for email, documents, and meetings. A company running Google Workspace can offer &quot;Continue with Google.&quot; A company running Microsoft 365 can use Microsoft Entra ID, formerly Azure Active Directory. Google or Microsoft verifies the employee&#39;s identity, and the application keeps its own local user record for roles, permissions, brands, departments, and audit history, then decides what that verified employee can actually do inside the app.</p>
<p>I ran into this exact requirement while designing a multi-brand CMS and digital asset management platform. The client already managed every employee through Google Workspace, so this guide walks through how the identity check and the application&#39;s own access rules stay cleanly separated, and how to wire that separation into a Payload CMS user model.</p>
<h2>The problem with separate application accounts</h2>
<p>A new internal application often ships with its own users table, registration flow, password policy, reset process, and account-removal step. Employees end up remembering another password. Managers end up requesting accounts for new hires. Administrators end up disabling accounts by hand when people leave, and application owners end up fielding password-reset requests while maintaining their own authentication security. This workload multiplies once the application serves multiple brands, departments, agencies, and external partners.</p>
<p>Single sign-on moves the initial identity check to the company&#39;s existing identity provider:</p>
<table>
<thead>
<tr>
<th>Company environment</th>
<th>Identity provider</th>
</tr>
</thead>
<tbody><tr>
<td>Google Workspace</td>
<td>Google Identity</td>
</tr>
<tr>
<td>Microsoft 365</td>
<td>Microsoft Entra ID</td>
</tr>
<tr>
<td>Okta-managed environment</td>
<td>Okta</td>
</tr>
<tr>
<td>Self-hosted enterprise identity</td>
<td>Keycloak or another OIDC provider</td>
</tr>
</tbody></table>
<p>Google supports authentication through OpenID Connect and Google Identity Services. Microsoft Entra ID implements OpenID Connect on top of OAuth 2.0. Both systems issue a signed identity response that an application can validate before creating a local session.</p>
<h2>The basic SSO flow</h2>
<p>The login process runs through six steps. The employee opens the company application and selects &quot;Continue with Google&quot; or &quot;Continue with Microsoft.&quot; Google or Microsoft authenticates the employee and returns a signed identity response. The application looks up or creates the corresponding local user, then starts its own session and applies its own access rules.</p>
<p>The employee never creates a separate application password, and the application never receives the employee&#39;s Google or Microsoft password. The identity provider handles that exchange and hands back a limited set of verified identity claims: a stable provider identifier, email address, name, profile image, and Workspace domain or Microsoft tenant information.</p>
<p>The application should store the stable provider identifier rather than relying on email alone. Email addresses get renamed or reused, while the provider identifier stays fixed to the account.</p>
<h2>Authentication and authorization remain separate</h2>
<p>SSO handles authentication: confirming this is a verified employee from the approved company account. The application handles authorization on top of that: deciding this employee can edit Brand A content, review Brand B assets, and view reports.</p>
<p>A CMS or DAM might store a user record like this:</p>
<pre><code class="language-ts">{
  email: &#39;employee@company.com&#39;,
  identityProvider: &#39;google&#39;,
  identityKey: &#39;google:provider-user-id&#39;,
  roles: [&#39;editor&#39;],
  brands: [&#39;brand-a&#39;],
  departments: [&#39;marketing&#39;],
  active: true
}
</code></pre>
<p>Google or Microsoft handles the employee&#39;s credentials, multifactor authentication, and company account status. The CMS or DAM handles admin access, editorial roles, asset permissions, brand access, approval rights, field-level restrictions, and audit relationships.</p>
<p>Payload CMS supports authentication-enabled collections, custom authentication strategies, collection access control, field access control, and Admin Panel restrictions based on the authenticated user.</p>
<h2>Automatic user creation</h2>
<p>Many business applications use just-in-time provisioning, where the first successful company login triggers creation of the local application user:</p>
<pre><code class="language-text">Google or Microsoft verifies employee
                ↓
Application searches for identity key
                ↓
No application user exists
                ↓
Application creates a user with safe defaults
                ↓
Administrator assigns additional access
</code></pre>
<p>The default role should stay restrictive, with a new employee starting as a viewer or inactive user until an administrator assigns the correct brands and permissions. This removes the registration step while keeping access controlled from day one.</p>
<h2>Google Workspace and Microsoft Entra ID compared</h2>
<p>Both platforms support modern SSO, and their enterprise administration models differ in the details:</p>
<table>
<thead>
<tr>
<th>Area</th>
<th>Google Workspace</th>
<th>Microsoft Entra ID</th>
</tr>
</thead>
<tbody><tr>
<td>Modern login protocol</td>
<td>OpenID Connect</td>
<td>OpenID Connect</td>
</tr>
<tr>
<td>Application registration</td>
<td>Google Cloud Console</td>
<td>Entra App Registration</td>
</tr>
<tr>
<td>Organisation restriction</td>
<td>Workspace domain and internal app settings</td>
<td>Tenant-specific application</td>
</tr>
<tr>
<td>User administration</td>
<td>Google Admin console</td>
<td>Entra admin center</td>
</tr>
<tr>
<td>Group-based access</td>
<td>Google Groups and Directory integration</td>
<td>Entra groups and application roles</td>
</tr>
<tr>
<td>External collaborators</td>
<td>Separate account or Workspace policies</td>
<td>Entra guest users</td>
</tr>
<tr>
<td>Advanced provisioning</td>
<td>Directory integration</td>
<td>SCIM and provisioning services</td>
</tr>
</tbody></table>
<p>Google Workspace administrators can trust, restrict, or block which internal and third-party OAuth applications employees may access, and limit the Google data scopes those applications request. Microsoft Entra registers an application against a single tenant, so a tenant-specific authority ensures only members and permitted guests from the selected organisation can authenticate.</p>
<h2>SSO does not require access to company documents</h2>
<p>A login integration does not automatically grant access to Gmail, Google Drive, Outlook, SharePoint, or Microsoft Graph. The application can request only basic identity scopes:</p>
<pre><code class="language-text">openid
email
profile
</code></pre>
<p>Additional services require additional OAuth scopes and explicit approval. This separation matters during security review, since an internal CMS can confirm who the employee is without reading any Workspace documents or mail. Google allows administrators to configure access according to the exact OAuth scopes an application requests.</p>
<h2>Offboarding and account removal</h2>
<p>SSO improves offboarding because the employee&#39;s central company account controls their ability to authenticate again. Disabling a Google Workspace or Microsoft Entra account prevents the employee from completing a new login.</p>
<p>The application still has to manage any session that already exists. A production design should include a limited session duration, an active flag on the local user, session revalidation, immediate application-level suspension, audit records, and optional directory synchronization.</p>
<p>Larger organisations can automate this further with SCIM, which creates, updates, and deactivates application users based on the company directory. Basic SSO confirms identity at login, and directory provisioning keeps the local application user synchronized for the rest of the employee lifecycle.</p>
<h2>SSO for a CMS or DAM</h2>
<p>SSO earns its keep on internal content and asset platforms serving marketing teams, product teams, legal reviewers, designers, external agencies, regional offices, and administrators. The organisation already knows who these people are through Workspace or Entra ID, so the DAM can reuse that identity and layer application-specific access on top.</p>
<p>A Google Workspace login could produce this mapping:</p>
<pre><code class="language-text">Verified Google Workspace employee
                 ↓
Payload user
                 ↓
Brand: CanPrev
Role: Editor
Department: Marketing
</code></pre>
<p>A Microsoft Entra environment could map an internal group to application access:</p>
<pre><code class="language-text">Entra group: Legal Reviewers
                 ↓
Payload role: Approver
Brand access: All brands
</code></pre>
<p>This keeps credentials centralized in one place while preserving granular application authorization.</p>
<h2>Custom implementation or enterprise SSO product</h2>
<p>Two implementation routes handle this well, depending on scale:</p>
<table>
<thead>
<tr>
<th>Route</th>
<th>Best fit</th>
</tr>
</thead>
<tbody><tr>
<td>Custom OIDC integration</td>
<td>One or two known providers, controlled internal application, custom access model</td>
</tr>
<tr>
<td>Vendor-supported enterprise SSO</td>
<td>Multiple providers, SAML requirements, formal support, strict procurement requirements</td>
</tr>
</tbody></table>
<p>Payload sells an Enterprise SSO feature supporting SAML and OAuth 2.0 identity providers, including Google, Microsoft, and Okta, with automatic profile generation and permission mapping. The open-source version supports custom authentication strategies, so a development team can implement Google Workspace or Microsoft Entra authentication directly, taking on security, testing, upgrades, and long-term maintenance as part of the build.</p>
<h2>When SSO creates the most value</h2>
<p>SSO pays off once an application has more than a small number of internal users, regular employee onboarding and offboarding, multiple departments or brands, sensitive business information, company-wide multifactor authentication, external agencies or guests, formal security review, or granular roles and approvals. A public customer application may still need separate customer authentication, while an internal CMS, DAM, portal, knowledge system, or workflow application is usually a strong SSO candidate.</p>
<h2>FAQ</h2>
<h3>Does SSO remove the application&#39;s users collection?</h3>
<p>No. The application still needs local users for roles, permissions, relationships, preferences, and audit history. SSO replaces the separate application password.</p>
<h3>Does the application receive the employee&#39;s Google or Microsoft password?</h3>
<p>No. The employee authenticates directly with Google or Microsoft, and the application receives a signed identity response.</p>
<h3>Can any personal Google or Microsoft account log in?</h3>
<p>The application should restrict access to the approved Workspace domain or Microsoft Entra tenant. Company administrators can also restrict which applications employees are allowed to use.</p>
<h3>Does SSO automatically remove a user when they leave?</h3>
<p>It prevents future authentication once the company account is disabled. Existing application sessions and local user status still need explicit handling, which SCIM or directory synchronization can automate.</p>
<h3>Is SSO only available to enterprise companies?</h3>
<p>The protocols are open and broadly available. Enterprise products package provider support, configuration, maintenance, and vendor accountability on top of that. Smaller teams can build a focused OpenID Connect integration for their own applications.</p>
<h2>Conclusion</h2>
<p>Single sign-on creates a cleaner employee experience and a stronger access-control model for business applications. Google Workspace and Microsoft Entra ID already manage employee identities, and a CMS, DAM, internal portal, or workflow application can reuse that identity while keeping its own roles and permissions:</p>
<pre><code class="language-text">Google or Microsoft
Identity and company authentication

Payload CMS or business application
Roles, permissions, brands and access control
</code></pre>
<p>This split removes duplicated passwords, improves onboarding, centralizes authentication policy, and gives internal applications a more credible enterprise security model.</p>
<p>I design Next.js and Payload CMS platforms for organisations managing multiple brands, teams, workflows, and business systems. Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/single-sign-on-google-workspace-entra-id</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/single-sign-on-google-workspace-entra-id</guid>
            <category><![CDATA[Payload]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Tue, 21 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;Single sign-on lets employees open an internal application with the same company account they already use for email, documents, and meetings. A company running Google Workspace can offer &amp;quot;Continue with Google.&amp;quot; A company running Microsoft 365 can use Microsoft Entra ID, formerly Azure Active Directory. Google or Microsoft verifies the employee&amp;#39;s identity, and the application keeps its own local user record for roles, permissions, brands, departments, and audit history, then decides what that verified employee can actually do inside the app.&lt;/p&gt;
&lt;p&gt;I ran into this exact requirement while designing a multi-brand CMS and digital asset management platform. The client already managed every employee through Google Workspace, so this guide walks through how the identity check and the application&amp;#39;s own access rules stay cleanly separated, and how to wire that separation into a Payload CMS user model.&lt;/p&gt;
&lt;h2&gt;The problem with separate application accounts&lt;/h2&gt;
&lt;p&gt;A new internal application often ships with its own users table, registration flow, password policy, reset process, and account-removal step. Employees end up remembering another password. Managers end up requesting accounts for new hires. Administrators end up disabling accounts by hand when people leave, and application owners end up fielding password-reset requests while maintaining their own authentication security. This workload multiplies once the application serves multiple brands, departments, agencies, and external partners.&lt;/p&gt;
&lt;p&gt;Single sign-on moves the initial identity check to the company&amp;#39;s existing identity provider:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Company environment&lt;/th&gt;
&lt;th&gt;Identity provider&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Google Workspace&lt;/td&gt;
&lt;td&gt;Google Identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft 365&lt;/td&gt;
&lt;td&gt;Microsoft Entra ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Okta-managed environment&lt;/td&gt;
&lt;td&gt;Okta&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted enterprise identity&lt;/td&gt;
&lt;td&gt;Keycloak or another OIDC provider&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Google supports authentication through OpenID Connect and Google Identity Services. Microsoft Entra ID implements OpenID Connect on top of OAuth 2.0. Both systems issue a signed identity response that an application can validate before creating a local session.&lt;/p&gt;
&lt;h2&gt;The basic SSO flow&lt;/h2&gt;
&lt;p&gt;The login process runs through six steps. The employee opens the company application and selects &amp;quot;Continue with Google&amp;quot; or &amp;quot;Continue with Microsoft.&amp;quot; Google or Microsoft authenticates the employee and returns a signed identity response. The application looks up or creates the corresponding local user, then starts its own session and applies its own access rules.&lt;/p&gt;
&lt;p&gt;The employee never creates a separate application password, and the application never receives the employee&amp;#39;s Google or Microsoft password. The identity provider handles that exchange and hands back a limited set of verified identity claims: a stable provider identifier, email address, name, profile image, and Workspace domain or Microsoft tenant information.&lt;/p&gt;
&lt;p&gt;The application should store the stable provider identifier rather than relying on email alone. Email addresses get renamed or reused, while the provider identifier stays fixed to the account.&lt;/p&gt;
&lt;h2&gt;Authentication and authorization remain separate&lt;/h2&gt;
&lt;p&gt;SSO handles authentication: confirming this is a verified employee from the approved company account. The application handles authorization on top of that: deciding this employee can edit Brand A content, review Brand B assets, and view reports.&lt;/p&gt;
&lt;p&gt;A CMS or DAM might store a user record like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;{
  email: &amp;#39;employee@company.com&amp;#39;,
  identityProvider: &amp;#39;google&amp;#39;,
  identityKey: &amp;#39;google:provider-user-id&amp;#39;,
  roles: [&amp;#39;editor&amp;#39;],
  brands: [&amp;#39;brand-a&amp;#39;],
  departments: [&amp;#39;marketing&amp;#39;],
  active: true
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Google or Microsoft handles the employee&amp;#39;s credentials, multifactor authentication, and company account status. The CMS or DAM handles admin access, editorial roles, asset permissions, brand access, approval rights, field-level restrictions, and audit relationships.&lt;/p&gt;
&lt;p&gt;Payload CMS supports authentication-enabled collections, custom authentication strategies, collection access control, field access control, and Admin Panel restrictions based on the authenticated user.&lt;/p&gt;
&lt;h2&gt;Automatic user creation&lt;/h2&gt;
&lt;p&gt;Many business applications use just-in-time provisioning, where the first successful company login triggers creation of the local application user:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Google or Microsoft verifies employee
                ↓
Application searches for identity key
                ↓
No application user exists
                ↓
Application creates a user with safe defaults
                ↓
Administrator assigns additional access
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The default role should stay restrictive, with a new employee starting as a viewer or inactive user until an administrator assigns the correct brands and permissions. This removes the registration step while keeping access controlled from day one.&lt;/p&gt;
&lt;h2&gt;Google Workspace and Microsoft Entra ID compared&lt;/h2&gt;
&lt;p&gt;Both platforms support modern SSO, and their enterprise administration models differ in the details:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Google Workspace&lt;/th&gt;
&lt;th&gt;Microsoft Entra ID&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Modern login protocol&lt;/td&gt;
&lt;td&gt;OpenID Connect&lt;/td&gt;
&lt;td&gt;OpenID Connect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application registration&lt;/td&gt;
&lt;td&gt;Google Cloud Console&lt;/td&gt;
&lt;td&gt;Entra App Registration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Organisation restriction&lt;/td&gt;
&lt;td&gt;Workspace domain and internal app settings&lt;/td&gt;
&lt;td&gt;Tenant-specific application&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User administration&lt;/td&gt;
&lt;td&gt;Google Admin console&lt;/td&gt;
&lt;td&gt;Entra admin center&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Group-based access&lt;/td&gt;
&lt;td&gt;Google Groups and Directory integration&lt;/td&gt;
&lt;td&gt;Entra groups and application roles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External collaborators&lt;/td&gt;
&lt;td&gt;Separate account or Workspace policies&lt;/td&gt;
&lt;td&gt;Entra guest users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Advanced provisioning&lt;/td&gt;
&lt;td&gt;Directory integration&lt;/td&gt;
&lt;td&gt;SCIM and provisioning services&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Google Workspace administrators can trust, restrict, or block which internal and third-party OAuth applications employees may access, and limit the Google data scopes those applications request. Microsoft Entra registers an application against a single tenant, so a tenant-specific authority ensures only members and permitted guests from the selected organisation can authenticate.&lt;/p&gt;
&lt;h2&gt;SSO does not require access to company documents&lt;/h2&gt;
&lt;p&gt;A login integration does not automatically grant access to Gmail, Google Drive, Outlook, SharePoint, or Microsoft Graph. The application can request only basic identity scopes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;openid
email
profile
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Additional services require additional OAuth scopes and explicit approval. This separation matters during security review, since an internal CMS can confirm who the employee is without reading any Workspace documents or mail. Google allows administrators to configure access according to the exact OAuth scopes an application requests.&lt;/p&gt;
&lt;h2&gt;Offboarding and account removal&lt;/h2&gt;
&lt;p&gt;SSO improves offboarding because the employee&amp;#39;s central company account controls their ability to authenticate again. Disabling a Google Workspace or Microsoft Entra account prevents the employee from completing a new login.&lt;/p&gt;
&lt;p&gt;The application still has to manage any session that already exists. A production design should include a limited session duration, an active flag on the local user, session revalidation, immediate application-level suspension, audit records, and optional directory synchronization.&lt;/p&gt;
&lt;p&gt;Larger organisations can automate this further with SCIM, which creates, updates, and deactivates application users based on the company directory. Basic SSO confirms identity at login, and directory provisioning keeps the local application user synchronized for the rest of the employee lifecycle.&lt;/p&gt;
&lt;h2&gt;SSO for a CMS or DAM&lt;/h2&gt;
&lt;p&gt;SSO earns its keep on internal content and asset platforms serving marketing teams, product teams, legal reviewers, designers, external agencies, regional offices, and administrators. The organisation already knows who these people are through Workspace or Entra ID, so the DAM can reuse that identity and layer application-specific access on top.&lt;/p&gt;
&lt;p&gt;A Google Workspace login could produce this mapping:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Verified Google Workspace employee
                 ↓
Payload user
                 ↓
Brand: CanPrev
Role: Editor
Department: Marketing
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A Microsoft Entra environment could map an internal group to application access:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Entra group: Legal Reviewers
                 ↓
Payload role: Approver
Brand access: All brands
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This keeps credentials centralized in one place while preserving granular application authorization.&lt;/p&gt;
&lt;h2&gt;Custom implementation or enterprise SSO product&lt;/h2&gt;
&lt;p&gt;Two implementation routes handle this well, depending on scale:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Custom OIDC integration&lt;/td&gt;
&lt;td&gt;One or two known providers, controlled internal application, custom access model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vendor-supported enterprise SSO&lt;/td&gt;
&lt;td&gt;Multiple providers, SAML requirements, formal support, strict procurement requirements&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Payload sells an Enterprise SSO feature supporting SAML and OAuth 2.0 identity providers, including Google, Microsoft, and Okta, with automatic profile generation and permission mapping. The open-source version supports custom authentication strategies, so a development team can implement Google Workspace or Microsoft Entra authentication directly, taking on security, testing, upgrades, and long-term maintenance as part of the build.&lt;/p&gt;
&lt;h2&gt;When SSO creates the most value&lt;/h2&gt;
&lt;p&gt;SSO pays off once an application has more than a small number of internal users, regular employee onboarding and offboarding, multiple departments or brands, sensitive business information, company-wide multifactor authentication, external agencies or guests, formal security review, or granular roles and approvals. A public customer application may still need separate customer authentication, while an internal CMS, DAM, portal, knowledge system, or workflow application is usually a strong SSO candidate.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;h3&gt;Does SSO remove the application&amp;#39;s users collection?&lt;/h3&gt;
&lt;p&gt;No. The application still needs local users for roles, permissions, relationships, preferences, and audit history. SSO replaces the separate application password.&lt;/p&gt;
&lt;h3&gt;Does the application receive the employee&amp;#39;s Google or Microsoft password?&lt;/h3&gt;
&lt;p&gt;No. The employee authenticates directly with Google or Microsoft, and the application receives a signed identity response.&lt;/p&gt;
&lt;h3&gt;Can any personal Google or Microsoft account log in?&lt;/h3&gt;
&lt;p&gt;The application should restrict access to the approved Workspace domain or Microsoft Entra tenant. Company administrators can also restrict which applications employees are allowed to use.&lt;/p&gt;
&lt;h3&gt;Does SSO automatically remove a user when they leave?&lt;/h3&gt;
&lt;p&gt;It prevents future authentication once the company account is disabled. Existing application sessions and local user status still need explicit handling, which SCIM or directory synchronization can automate.&lt;/p&gt;
&lt;h3&gt;Is SSO only available to enterprise companies?&lt;/h3&gt;
&lt;p&gt;The protocols are open and broadly available. Enterprise products package provider support, configuration, maintenance, and vendor accountability on top of that. Smaller teams can build a focused OpenID Connect integration for their own applications.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Single sign-on creates a cleaner employee experience and a stronger access-control model for business applications. Google Workspace and Microsoft Entra ID already manage employee identities, and a CMS, DAM, internal portal, or workflow application can reuse that identity while keeping its own roles and permissions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Google or Microsoft
Identity and company authentication

Payload CMS or business application
Roles, permissions, brands and access control
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This split removes duplicated passwords, improves onboarding, centralizes authentication policy, and gives internal applications a more credible enterprise security model.&lt;/p&gt;
&lt;p&gt;I design Next.js and Payload CMS platforms for organisations managing multiple brands, teams, workflows, and business systems. Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/single-sign-on-google-workspace-entra-id"/>
        </item>
        <item>
            <title><![CDATA[PostHog Next.js Setup: GDPR-Compliant Analytics Guide]]></title>
            <description><![CDATA[<p>If you want real product analytics and session replay in a Next.js App Router app — not just pageviews, and not in a way that breaks GDPR the moment you ship it — the pattern is: install <code>posthog-js</code> directly (not the snippet loader, not <code>@posthog/next</code>), initialize it opted out by default, gate capture behind your existing cookie consent, mask sensitive input before session replay ever records it, and route every event through one typed function instead of scattering <code>posthog.capture()</code> calls through your components. That&#39;s the shape of it. The part nobody&#39;s tutorial warns you about is what happens after you deploy it — and that part cost me a few hours I didn&#39;t expect to spend.</p>
<p>I just finished wiring this up for a client&#39;s Next.js 16 marketplace app, and shipped it all the way through to a real production deployment behind Docker and a self-hosted CI pipeline. The SDK setup took twenty minutes. Getting it to actually fire in production took a lot longer, because of a failure mode that doesn&#39;t show up in local development, doesn&#39;t show up in a &quot;successful&quot; deploy log, and doesn&#39;t show up unless you specifically go check the network tab. This guide covers both halves: the integration itself, and the deployment trap that will silently disable it if you don&#39;t know to look for it.</p>
<h2>Why posthog-js directly, not the snippet or @posthog/next</h2>
<p>PostHog gives you three ways to get their SDK into a page, and they&#39;re not interchangeable once session replay and privacy are in scope.</p>
<p>The classic <code>&lt;script&gt;</code> snippet — the one PostHog&#39;s own onboarding hands you by default — loads the SDK from a CDN and self-assigns to <code>window.posthog</code>. It works, but it&#39;s a black box you can&#39;t type-check, and it initializes unconditionally the moment the script tag executes, which makes consent-gating clumsy: you either avoid injecting the tag until consent is granted (delaying the CDN fetch on every page load) or you load it and then try to bolt opt-out behavior on afterward.</p>
<p><code>@posthog/next</code> wraps the SDK for Next.js specifically, but at the time of writing PostHog itself labels it pre-release. I don&#39;t put pre-release packages in front of a paying client&#39;s production traffic, and you probably shouldn&#39;t either.</p>
<p><code>posthog-js</code> installed as a normal npm dependency gives you the same SDK, minus the CDN round trip, with full TypeScript types, and — critically for this guide — direct control over exactly when <code>.init()</code> runs. That control is what makes consent-gating and privacy masking actually reliable instead of best-effort.</p>
<pre><code class="language-bash">pnpm add posthog-js
</code></pre>
<h2>Client init in instrumentation-client.ts</h2>
<p>Next.js has a dedicated convention for client-side instrumentation: a file named <code>instrumentation-client.ts</code>, sitting next to your <code>app</code> directory (not inside it), that runs once in the browser before your app hydrates. It&#39;s the right place for anything that needs to exist before React takes over — error tracking, and analytics init both belong here.</p>
<pre><code class="language-ts">// File: src/instrumentation-client.ts
import posthog from &#39;posthog-js&#39;;

const projectToken = process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN;
const apiHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
const analyticsEnabled = process.env.NEXT_PUBLIC_ANALYTICS_ENABLED === &#39;true&#39;;

if (analyticsEnabled &amp;&amp; projectToken &amp;&amp; apiHost) {
  posthog.init(projectToken, {
    api_host: apiHost,
    defaults: &#39;2025-05-24&#39;,
    opt_out_capturing_by_default: true,
    capture_pageview: false,
    session_recording: {
      maskAllInputs: true,
      maskTextSelector: &#39;.ph-mask, [data-private=&quot;true&quot;]&#39;,
    },
  });
}
</code></pre>
<p>Three things in that config matter more than the rest. <code>opt_out_capturing_by_default: true</code> means the SDK loads but captures nothing until something explicitly opts it in — this is what makes consent-gating actually safe, versus loading the SDK and hoping you remembered to guard every capture call. The <code>analyticsEnabled</code> check means a missing or false <code>NEXT_PUBLIC_ANALYTICS_ENABLED</code> skips initialization entirely — local development and any environment that hasn&#39;t been given real PostHog credentials stays completely inert, with zero network calls to PostHog, which is exactly what you want on a laptop that isn&#39;t your production traffic. And <code>capture_pageview: false</code> is deliberate: App Router navigation doesn&#39;t match the classic multi-page-load model PostHog&#39;s autocapture assumes, so pageviews get fired explicitly later instead of relying on the default heuristic.</p>
<h2>Wiring into your existing cookie consent</h2>
<p>If your site already has a cookie consent banner — and if you&#39;ve read <a href="https://www.buildwithmatija.com/blog/gdpr-compliant-vercel-analytics">the GDPR-compliant Vercel Analytics guide</a>, you already know why it should — the mistake to avoid here is building PostHog a second, parallel consent store. It should hook into the one you already have.</p>
<pre><code class="language-ts">// File: src/lib/analytics/consent.ts
import posthog from &#39;posthog-js&#39;;

export function applyAnalyticsConsent(granted: boolean): void {
  if (granted) {
    posthog.opt_in_capturing();
  } else {
    posthog.opt_out_capturing();
  }
}
</code></pre>
<p>Call <code>applyAnalyticsConsent</code> from the exact same accept/decline handlers your consent banner already calls for whatever else it gates — Google Tag Manager, a marketing pixel, whatever. Don&#39;t introduce a second cookie key or a second localStorage flag just for PostHog; one source of truth for consent means one place to audit when you need to prove GDPR compliance later, and one place to fix if the logic is ever wrong. On mount, also check whatever your existing consent storage already holds — if the user accepted on a previous visit, apply that immediately rather than waiting for them to click the banner again, since it won&#39;t show a second time.</p>
<h2>Session replay privacy: masking inputs and sensitive text</h2>
<p>Session replay is the single biggest reason to reach for PostHog over Vercel Analytics or GA4 — watching an actual recording of where someone hesitated in your checkout flow tells you things a funnel chart never will. It&#39;s also the single easiest way to accidentally record someone&#39;s password, email, or payment details if you don&#39;t configure masking before you ship it, not after.</p>
<p><code>maskAllInputs: true</code> in the config above is the blanket default — every <code>&lt;input&gt;</code>, <code>&lt;textarea&gt;</code>, and <code>&lt;select&gt;</code> gets its value replaced with asterisks in the recording, full stop. That covers form fields. It doesn&#39;t cover rendered text: if your app ever prints a customer&#39;s email address or name back onto the page — a confirmation screen, an account settings page — that text is visible in the recording unless you mark it explicitly.</p>
<pre><code class="language-tsx">// File: src/components/account/ProfileSummary.tsx
&lt;span className=&quot;ph-mask&quot;&gt;{customer.email}&lt;/span&gt;
</code></pre>
<p>For an entire section that should never appear in a recording at all — a payment form, an identity verification step, anything with real financial or personal data — go further than masking and exclude it from capture entirely:</p>
<pre><code class="language-tsx">// File: src/components/checkout/PaymentDetails.tsx
&lt;section data-ph-capture-attribute-exclude=&quot;true&quot; className=&quot;ph-no-capture&quot;&gt;
  {/* payment fields never enter the recording, not even masked */}
&lt;/section&gt;
</code></pre>
<p>Don&#39;t reach for <code>.ph-mask</code> on every element as a defensive habit, though — public prices, vehicle names, navigation labels, anything that isn&#39;t personal to the specific visitor should stay visible, or the recording becomes useless for the thing you built it for in the first place.</p>
<h2>A typed trackEvent() wrapper</h2>
<p>Once the SDK is initialized and consent is wired up, resist the urge to sprinkle <code>posthog.capture(&#39;some_event&#39;, {...})</code> directly into every component that needs to fire something. Centralize it, and get compile-time safety on event names and their properties while you&#39;re at it.</p>
<pre><code class="language-ts">// File: src/lib/analytics/events.ts
export type AnalyticsEventMap = {
  vehicle_viewed: { vehicle_id: string; partner_id?: string };
  search_submitted: { search_type: &#39;classic&#39; | &#39;affordability&#39;; result_count?: number };
  application_submitted: { reference: string; product?: string };
};

export function trackEvent&lt;TEvent extends keyof AnalyticsEventMap&gt;(
  event: TEvent,
  properties: AnalyticsEventMap[TEvent],
): void {
  if (typeof window === &#39;undefined&#39;) return;

  posthog.capture(event, properties);

  window.dataLayer = window.dataLayer ?? [];
  window.dataLayer.push({ event, ...properties });
}
</code></pre>
<p>The <code>AnalyticsEventMap</code> type means a typo in an event name or a missing required property fails at build time instead of silently vanishing in production — which matters more than it sounds like it should, because a dashboard built around an event that stopped firing three deploys ago is a very quiet kind of broken. The <code>dataLayer.push</code> is there for teams running Google Tag Manager alongside PostHog rather than instead of it; if you&#39;ve already got GTM wired up per <a href="https://www.buildwithmatija.com/blog/nextjs-google-analytics-tag-manager-guide">the Next.js GTM guide</a>, this lets the same event reach both without duplicating the capture call at every site.</p>
<h2>CSP additions</h2>
<p>If your site runs a Content-Security-Policy — and it should — PostHog needs explicit allowances or its script, its session recording payloads, and its API calls will get silently blocked by the browser with nothing but a CSP violation buried in the console to explain why nothing&#39;s showing up.</p>
<pre><code>script-src &#39;self&#39; https://*.posthog.com;
connect-src &#39;self&#39; https://*.posthog.com;
worker-src &#39;self&#39; blob: data:;
</code></pre>
<p>The <code>worker-src</code> line is the one people forget: PostHog&#39;s session recorder runs part of its work in a web worker, and without <code>blob:</code> and <code>data:</code> allowed there, recording fails quietly rather than throwing anything you&#39;d notice in normal testing.</p>
<h2>The deployment gotcha: env vars that disappear at build time</h2>
<p>Here&#39;s the part that actually cost time. Everything above worked flawlessly in local development the first time I ran it. It worked in a browser-based check against the compiled dev bundle. Then it shipped to production, the deploy pipeline reported success, the health check passed — and PostHog never received a single event.</p>
<p>The root cause has nothing to do with PostHog and everything to do with how Next.js handles <code>NEXT_PUBLIC_*</code> environment variables. They aren&#39;t read at runtime by the server process the way <code>DATABASE_URL</code> or a secret key would be. They&#39;re inlined as literal string values into the client JavaScript bundle at <code>next build</code> time. If a <code>NEXT_PUBLIC_*</code> variable isn&#39;t present in the environment when <code>next build</code> actually runs, Next.js compiles the reference to <code>undefined</code> — permanently, into that build&#39;s output — and no amount of setting the variable correctly in your runtime container afterward will change it. You&#39;d have to rebuild.</p>
<p>That distinction is easy to miss if your deploy pipeline separates &quot;build&quot; from &quot;run&quot; into different steps with different environment sources — which is exactly what a Docker-based pipeline does by design. In my case, the build stage sourced its environment from a secret file assembled by a small shell script, and that script filtered which variables got forwarded into the Docker build through a hardcoded allowlist — a list that had never been updated to include the new PostHog variables. The runtime <code>.env</code> files on the server had the right values. The application code was correct. The deploy succeeded, the container passed its health check, and the client bundle it served had literally never seen the PostHog token at all.</p>
<pre><code class="language-bash"># The gotcha, made concrete: what actually gets read
next build          # NEXT_PUBLIC_* vars get inlined into the client bundle HERE
docker run &lt;image&gt;  # setting NEXT_PUBLIC_* vars here does nothing — too late
</code></pre>
<p>If you&#39;re on a similar split-stage pipeline — anything where <code>docker build</code> and <code>docker run</code> (or their Kubernetes/Nixpacks/whatever equivalents) read from different environment sources — go check whatever mechanism forwards environment variables into your build step specifically, not just your runtime container definition. A hardcoded allowlist, a <code>.env</code> file that only gets copied at deploy time, a CI secret that&#39;s scoped to &quot;deploy&quot; but not &quot;build&quot; — all of these produce the identical symptom: correct code, correct runtime config, a deploy that reports success, and a feature that&#39;s permanently, silently off until someone rebuilds with the variable actually present at build time.</p>
<h2>Verifying it end-to-end</h2>
<p>The lesson from the section above is really about verification method, so make it explicit: a green checkmark on your deploy pipeline proves the container started. It does not prove a <code>NEXT_PUBLIC_*</code>-dependent feature actually works. The only real proof is checking what the browser actually sent.</p>
<p>Open your production site in a real browser, open dev tools, and before accepting the cookie consent banner, confirm the network tab shows zero requests to your PostHog host — that&#39;s <code>opt_out_capturing_by_default</code> doing its job. Accept consent, and confirm you now see a <code>POST</code> request to <code>&lt;your-host&gt;/e/</code> (event capture) with a <code>200</code> response, and — if session recording is enabled — a <code>POST</code> to <code>&lt;your-host&gt;/s/</code> as well. If your PostHog host is on the EU cluster, that&#39;s typically <code>eu.i.posthog.com</code>; use whatever host your project settings actually show you, not a guessed value. Finally, open PostHog&#39;s Live Events view and confirm the same events are landing there in real time, with no personal data sitting in the event properties.</p>
<p>That sequence — no requests before consent, real 200s after consent, events visible in PostHog&#39;s own dashboard — is the only check that actually proves the integration works. Everything before that step is necessary but not sufficient.</p>
<h2>Frequently asked questions</h2>
<p><strong>Does PostHog work with Google Tag Manager, or do I have to choose one?</strong>
You don&#39;t have to choose. Run PostHog directly via <code>posthog-js</code> for its own product analytics and session replay, and separately keep GTM loading whatever it already loads for GA4 or ad platforms. The <code>trackEvent()</code> wrapper above pushes to both <code>posthog.capture()</code> and <code>window.dataLayer</code> from one call site, so GTM can pick up any event you want it to see without a second, duplicate capture call anywhere in your components.</p>
<p><strong>Is PostHog GDPR compliant out of the box?</strong>
PostHog&#39;s EU Cloud region keeps data in EU infrastructure and PostHog itself offers the contractual pieces (a DPA, documented subprocessors) that GDPR compliance requires. But compliance isn&#39;t something a vendor hands you by default — it&#39;s the combination of choosing the EU region, gating capture behind real consent rather than assuming it, masking or excluding personal data from session replay, and not sending PII in event properties. The setup in this guide is what makes that combination actually true for your specific implementation, not something PostHog does automatically regardless of how you wire it up.</p>
<p><strong>Why initialize PostHog in instrumentation-client.ts instead of a React component or provider?</strong>
Because PostHog is a singleton once <code>.init()</code> has run — there&#39;s nothing a React component tree gives you that a plain module-level init doesn&#39;t, and <code>instrumentation-client.ts</code> runs before hydration, which means the SDK exists and is ready before your app&#39;s first render rather than racing a <code>useEffect</code> in some top-level component. A dedicated React context for PostHog is unnecessary complexity for something that&#39;s fundamentally a global, one-time setup step.</p>
<p><strong>What happens to session replay if maskAllInputs is on but I forget to mask rendered text?</strong>
Form field values stay masked correctly, but any personal data your app renders back onto the page as plain text — a name, an email, an address — will appear unmasked in the recording, because <code>maskAllInputs</code> only touches actual <code>&lt;input&gt;</code>/<code>&lt;textarea&gt;</code>/<code>&lt;select&gt;</code> elements, not arbitrary DOM text. That&#39;s exactly the gap <code>.ph-mask</code> and <code>data-private</code> close, and it&#39;s worth an explicit pass through your app looking for anywhere customer data gets echoed back before you turn recording on in production.</p>
<p><strong>How do I know if my deploy pipeline has the same env-var problem described above?</strong>
Check whichever step actually invokes <code>next build</code> — a Dockerfile <code>RUN</code> instruction, a CI build job, a Nixpacks/Vercel build step — and confirm the <code>NEXT_PUBLIC_*</code> variables your code references are genuinely present in that step&#39;s environment, not just in whatever <code>.env</code> file your container reads when it starts running afterward. If your build step gets its environment from a script, a secrets manager scope, or an allowlist rather than directly from your full <code>.env</code> file, that&#39;s the exact place a newly added variable can go missing without anything in your pipeline telling you.</p>
<h2>Wrapping up</h2>
<p>PostHog gets you real product analytics and session replay without the black-box feel of a CDN snippet, but the SDK install is the easy twenty percent of the work. The parts that actually matter for shipping it responsibly are wiring it into consent you already have instead of building a second system, masking session replay before real user data ever touches it, and — the one most guides skip entirely — understanding that <code>NEXT_PUBLIC_*</code> variables live and die at build time, not runtime, which means &quot;the deploy succeeded&quot; and &quot;the feature actually works&quot; are two different claims that need two different checks.</p>
<p>If you&#39;re setting this up and want a second pair of eyes on your deploy pipeline specifically — the build-vs-runtime env var split is the kind of thing that&#39;s obvious once you&#39;ve been bitten by it once and invisible until then — that&#39;s exactly the kind of implementation work I take on for clients. Let me know in the comments if you run into something this guide didn&#39;t cover, and subscribe if you want more practical, real-implementation guides like this one.</p>
<p>Thanks,
Matija</p>
<hr>
<p><em>Tested with Next.js 16 (App Router) and posthog-js 1.409.x, deployed to a Docker-based production pipeline. Last updated 2026-08-02.</em></p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/posthog-nextjs-setup-gdpr-analytics-session-replay</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/posthog-nextjs-setup-gdpr-analytics-session-replay</guid>
            <category><![CDATA[Next.js]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Mon, 20 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;If you want real product analytics and session replay in a Next.js App Router app — not just pageviews, and not in a way that breaks GDPR the moment you ship it — the pattern is: install &lt;code&gt;posthog-js&lt;/code&gt; directly (not the snippet loader, not &lt;code&gt;@posthog/next&lt;/code&gt;), initialize it opted out by default, gate capture behind your existing cookie consent, mask sensitive input before session replay ever records it, and route every event through one typed function instead of scattering &lt;code&gt;posthog.capture()&lt;/code&gt; calls through your components. That&amp;#39;s the shape of it. The part nobody&amp;#39;s tutorial warns you about is what happens after you deploy it — and that part cost me a few hours I didn&amp;#39;t expect to spend.&lt;/p&gt;
&lt;p&gt;I just finished wiring this up for a client&amp;#39;s Next.js 16 marketplace app, and shipped it all the way through to a real production deployment behind Docker and a self-hosted CI pipeline. The SDK setup took twenty minutes. Getting it to actually fire in production took a lot longer, because of a failure mode that doesn&amp;#39;t show up in local development, doesn&amp;#39;t show up in a &amp;quot;successful&amp;quot; deploy log, and doesn&amp;#39;t show up unless you specifically go check the network tab. This guide covers both halves: the integration itself, and the deployment trap that will silently disable it if you don&amp;#39;t know to look for it.&lt;/p&gt;
&lt;h2&gt;Why posthog-js directly, not the snippet or @posthog/next&lt;/h2&gt;
&lt;p&gt;PostHog gives you three ways to get their SDK into a page, and they&amp;#39;re not interchangeable once session replay and privacy are in scope.&lt;/p&gt;
&lt;p&gt;The classic &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; snippet — the one PostHog&amp;#39;s own onboarding hands you by default — loads the SDK from a CDN and self-assigns to &lt;code&gt;window.posthog&lt;/code&gt;. It works, but it&amp;#39;s a black box you can&amp;#39;t type-check, and it initializes unconditionally the moment the script tag executes, which makes consent-gating clumsy: you either avoid injecting the tag until consent is granted (delaying the CDN fetch on every page load) or you load it and then try to bolt opt-out behavior on afterward.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@posthog/next&lt;/code&gt; wraps the SDK for Next.js specifically, but at the time of writing PostHog itself labels it pre-release. I don&amp;#39;t put pre-release packages in front of a paying client&amp;#39;s production traffic, and you probably shouldn&amp;#39;t either.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;posthog-js&lt;/code&gt; installed as a normal npm dependency gives you the same SDK, minus the CDN round trip, with full TypeScript types, and — critically for this guide — direct control over exactly when &lt;code&gt;.init()&lt;/code&gt; runs. That control is what makes consent-gating and privacy masking actually reliable instead of best-effort.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm add posthog-js
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Client init in instrumentation-client.ts&lt;/h2&gt;
&lt;p&gt;Next.js has a dedicated convention for client-side instrumentation: a file named &lt;code&gt;instrumentation-client.ts&lt;/code&gt;, sitting next to your &lt;code&gt;app&lt;/code&gt; directory (not inside it), that runs once in the browser before your app hydrates. It&amp;#39;s the right place for anything that needs to exist before React takes over — error tracking, and analytics init both belong here.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/instrumentation-client.ts
import posthog from &amp;#39;posthog-js&amp;#39;;

const projectToken = process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN;
const apiHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
const analyticsEnabled = process.env.NEXT_PUBLIC_ANALYTICS_ENABLED === &amp;#39;true&amp;#39;;

if (analyticsEnabled &amp;amp;&amp;amp; projectToken &amp;amp;&amp;amp; apiHost) {
  posthog.init(projectToken, {
    api_host: apiHost,
    defaults: &amp;#39;2025-05-24&amp;#39;,
    opt_out_capturing_by_default: true,
    capture_pageview: false,
    session_recording: {
      maskAllInputs: true,
      maskTextSelector: &amp;#39;.ph-mask, [data-private=&amp;quot;true&amp;quot;]&amp;#39;,
    },
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things in that config matter more than the rest. &lt;code&gt;opt_out_capturing_by_default: true&lt;/code&gt; means the SDK loads but captures nothing until something explicitly opts it in — this is what makes consent-gating actually safe, versus loading the SDK and hoping you remembered to guard every capture call. The &lt;code&gt;analyticsEnabled&lt;/code&gt; check means a missing or false &lt;code&gt;NEXT_PUBLIC_ANALYTICS_ENABLED&lt;/code&gt; skips initialization entirely — local development and any environment that hasn&amp;#39;t been given real PostHog credentials stays completely inert, with zero network calls to PostHog, which is exactly what you want on a laptop that isn&amp;#39;t your production traffic. And &lt;code&gt;capture_pageview: false&lt;/code&gt; is deliberate: App Router navigation doesn&amp;#39;t match the classic multi-page-load model PostHog&amp;#39;s autocapture assumes, so pageviews get fired explicitly later instead of relying on the default heuristic.&lt;/p&gt;
&lt;h2&gt;Wiring into your existing cookie consent&lt;/h2&gt;
&lt;p&gt;If your site already has a cookie consent banner — and if you&amp;#39;ve read &lt;a href=&quot;https://www.buildwithmatija.com/blog/gdpr-compliant-vercel-analytics&quot;&gt;the GDPR-compliant Vercel Analytics guide&lt;/a&gt;, you already know why it should — the mistake to avoid here is building PostHog a second, parallel consent store. It should hook into the one you already have.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/lib/analytics/consent.ts
import posthog from &amp;#39;posthog-js&amp;#39;;

export function applyAnalyticsConsent(granted: boolean): void {
  if (granted) {
    posthog.opt_in_capturing();
  } else {
    posthog.opt_out_capturing();
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Call &lt;code&gt;applyAnalyticsConsent&lt;/code&gt; from the exact same accept/decline handlers your consent banner already calls for whatever else it gates — Google Tag Manager, a marketing pixel, whatever. Don&amp;#39;t introduce a second cookie key or a second localStorage flag just for PostHog; one source of truth for consent means one place to audit when you need to prove GDPR compliance later, and one place to fix if the logic is ever wrong. On mount, also check whatever your existing consent storage already holds — if the user accepted on a previous visit, apply that immediately rather than waiting for them to click the banner again, since it won&amp;#39;t show a second time.&lt;/p&gt;
&lt;h2&gt;Session replay privacy: masking inputs and sensitive text&lt;/h2&gt;
&lt;p&gt;Session replay is the single biggest reason to reach for PostHog over Vercel Analytics or GA4 — watching an actual recording of where someone hesitated in your checkout flow tells you things a funnel chart never will. It&amp;#39;s also the single easiest way to accidentally record someone&amp;#39;s password, email, or payment details if you don&amp;#39;t configure masking before you ship it, not after.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;maskAllInputs: true&lt;/code&gt; in the config above is the blanket default — every &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt;, and &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; gets its value replaced with asterisks in the recording, full stop. That covers form fields. It doesn&amp;#39;t cover rendered text: if your app ever prints a customer&amp;#39;s email address or name back onto the page — a confirmation screen, an account settings page — that text is visible in the recording unless you mark it explicitly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// File: src/components/account/ProfileSummary.tsx
&amp;lt;span className=&amp;quot;ph-mask&amp;quot;&amp;gt;{customer.email}&amp;lt;/span&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For an entire section that should never appear in a recording at all — a payment form, an identity verification step, anything with real financial or personal data — go further than masking and exclude it from capture entirely:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// File: src/components/checkout/PaymentDetails.tsx
&amp;lt;section data-ph-capture-attribute-exclude=&amp;quot;true&amp;quot; className=&amp;quot;ph-no-capture&amp;quot;&amp;gt;
  {/* payment fields never enter the recording, not even masked */}
&amp;lt;/section&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Don&amp;#39;t reach for &lt;code&gt;.ph-mask&lt;/code&gt; on every element as a defensive habit, though — public prices, vehicle names, navigation labels, anything that isn&amp;#39;t personal to the specific visitor should stay visible, or the recording becomes useless for the thing you built it for in the first place.&lt;/p&gt;
&lt;h2&gt;A typed trackEvent() wrapper&lt;/h2&gt;
&lt;p&gt;Once the SDK is initialized and consent is wired up, resist the urge to sprinkle &lt;code&gt;posthog.capture(&amp;#39;some_event&amp;#39;, {...})&lt;/code&gt; directly into every component that needs to fire something. Centralize it, and get compile-time safety on event names and their properties while you&amp;#39;re at it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/lib/analytics/events.ts
export type AnalyticsEventMap = {
  vehicle_viewed: { vehicle_id: string; partner_id?: string };
  search_submitted: { search_type: &amp;#39;classic&amp;#39; | &amp;#39;affordability&amp;#39;; result_count?: number };
  application_submitted: { reference: string; product?: string };
};

export function trackEvent&amp;lt;TEvent extends keyof AnalyticsEventMap&amp;gt;(
  event: TEvent,
  properties: AnalyticsEventMap[TEvent],
): void {
  if (typeof window === &amp;#39;undefined&amp;#39;) return;

  posthog.capture(event, properties);

  window.dataLayer = window.dataLayer ?? [];
  window.dataLayer.push({ event, ...properties });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;AnalyticsEventMap&lt;/code&gt; type means a typo in an event name or a missing required property fails at build time instead of silently vanishing in production — which matters more than it sounds like it should, because a dashboard built around an event that stopped firing three deploys ago is a very quiet kind of broken. The &lt;code&gt;dataLayer.push&lt;/code&gt; is there for teams running Google Tag Manager alongside PostHog rather than instead of it; if you&amp;#39;ve already got GTM wired up per &lt;a href=&quot;https://www.buildwithmatija.com/blog/nextjs-google-analytics-tag-manager-guide&quot;&gt;the Next.js GTM guide&lt;/a&gt;, this lets the same event reach both without duplicating the capture call at every site.&lt;/p&gt;
&lt;h2&gt;CSP additions&lt;/h2&gt;
&lt;p&gt;If your site runs a Content-Security-Policy — and it should — PostHog needs explicit allowances or its script, its session recording payloads, and its API calls will get silently blocked by the browser with nothing but a CSP violation buried in the console to explain why nothing&amp;#39;s showing up.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;script-src &amp;#39;self&amp;#39; https://*.posthog.com;
connect-src &amp;#39;self&amp;#39; https://*.posthog.com;
worker-src &amp;#39;self&amp;#39; blob: data:;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;worker-src&lt;/code&gt; line is the one people forget: PostHog&amp;#39;s session recorder runs part of its work in a web worker, and without &lt;code&gt;blob:&lt;/code&gt; and &lt;code&gt;data:&lt;/code&gt; allowed there, recording fails quietly rather than throwing anything you&amp;#39;d notice in normal testing.&lt;/p&gt;
&lt;h2&gt;The deployment gotcha: env vars that disappear at build time&lt;/h2&gt;
&lt;p&gt;Here&amp;#39;s the part that actually cost time. Everything above worked flawlessly in local development the first time I ran it. It worked in a browser-based check against the compiled dev bundle. Then it shipped to production, the deploy pipeline reported success, the health check passed — and PostHog never received a single event.&lt;/p&gt;
&lt;p&gt;The root cause has nothing to do with PostHog and everything to do with how Next.js handles &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; environment variables. They aren&amp;#39;t read at runtime by the server process the way &lt;code&gt;DATABASE_URL&lt;/code&gt; or a secret key would be. They&amp;#39;re inlined as literal string values into the client JavaScript bundle at &lt;code&gt;next build&lt;/code&gt; time. If a &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; variable isn&amp;#39;t present in the environment when &lt;code&gt;next build&lt;/code&gt; actually runs, Next.js compiles the reference to &lt;code&gt;undefined&lt;/code&gt; — permanently, into that build&amp;#39;s output — and no amount of setting the variable correctly in your runtime container afterward will change it. You&amp;#39;d have to rebuild.&lt;/p&gt;
&lt;p&gt;That distinction is easy to miss if your deploy pipeline separates &amp;quot;build&amp;quot; from &amp;quot;run&amp;quot; into different steps with different environment sources — which is exactly what a Docker-based pipeline does by design. In my case, the build stage sourced its environment from a secret file assembled by a small shell script, and that script filtered which variables got forwarded into the Docker build through a hardcoded allowlist — a list that had never been updated to include the new PostHog variables. The runtime &lt;code&gt;.env&lt;/code&gt; files on the server had the right values. The application code was correct. The deploy succeeded, the container passed its health check, and the client bundle it served had literally never seen the PostHog token at all.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# The gotcha, made concrete: what actually gets read
next build          # NEXT_PUBLIC_* vars get inlined into the client bundle HERE
docker run &amp;lt;image&amp;gt;  # setting NEXT_PUBLIC_* vars here does nothing — too late
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you&amp;#39;re on a similar split-stage pipeline — anything where &lt;code&gt;docker build&lt;/code&gt; and &lt;code&gt;docker run&lt;/code&gt; (or their Kubernetes/Nixpacks/whatever equivalents) read from different environment sources — go check whatever mechanism forwards environment variables into your build step specifically, not just your runtime container definition. A hardcoded allowlist, a &lt;code&gt;.env&lt;/code&gt; file that only gets copied at deploy time, a CI secret that&amp;#39;s scoped to &amp;quot;deploy&amp;quot; but not &amp;quot;build&amp;quot; — all of these produce the identical symptom: correct code, correct runtime config, a deploy that reports success, and a feature that&amp;#39;s permanently, silently off until someone rebuilds with the variable actually present at build time.&lt;/p&gt;
&lt;h2&gt;Verifying it end-to-end&lt;/h2&gt;
&lt;p&gt;The lesson from the section above is really about verification method, so make it explicit: a green checkmark on your deploy pipeline proves the container started. It does not prove a &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt;-dependent feature actually works. The only real proof is checking what the browser actually sent.&lt;/p&gt;
&lt;p&gt;Open your production site in a real browser, open dev tools, and before accepting the cookie consent banner, confirm the network tab shows zero requests to your PostHog host — that&amp;#39;s &lt;code&gt;opt_out_capturing_by_default&lt;/code&gt; doing its job. Accept consent, and confirm you now see a &lt;code&gt;POST&lt;/code&gt; request to &lt;code&gt;&amp;lt;your-host&amp;gt;/e/&lt;/code&gt; (event capture) with a &lt;code&gt;200&lt;/code&gt; response, and — if session recording is enabled — a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;&amp;lt;your-host&amp;gt;/s/&lt;/code&gt; as well. If your PostHog host is on the EU cluster, that&amp;#39;s typically &lt;code&gt;eu.i.posthog.com&lt;/code&gt;; use whatever host your project settings actually show you, not a guessed value. Finally, open PostHog&amp;#39;s Live Events view and confirm the same events are landing there in real time, with no personal data sitting in the event properties.&lt;/p&gt;
&lt;p&gt;That sequence — no requests before consent, real 200s after consent, events visible in PostHog&amp;#39;s own dashboard — is the only check that actually proves the integration works. Everything before that step is necessary but not sufficient.&lt;/p&gt;
&lt;h2&gt;Frequently asked questions&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Does PostHog work with Google Tag Manager, or do I have to choose one?&lt;/strong&gt;
You don&amp;#39;t have to choose. Run PostHog directly via &lt;code&gt;posthog-js&lt;/code&gt; for its own product analytics and session replay, and separately keep GTM loading whatever it already loads for GA4 or ad platforms. The &lt;code&gt;trackEvent()&lt;/code&gt; wrapper above pushes to both &lt;code&gt;posthog.capture()&lt;/code&gt; and &lt;code&gt;window.dataLayer&lt;/code&gt; from one call site, so GTM can pick up any event you want it to see without a second, duplicate capture call anywhere in your components.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is PostHog GDPR compliant out of the box?&lt;/strong&gt;
PostHog&amp;#39;s EU Cloud region keeps data in EU infrastructure and PostHog itself offers the contractual pieces (a DPA, documented subprocessors) that GDPR compliance requires. But compliance isn&amp;#39;t something a vendor hands you by default — it&amp;#39;s the combination of choosing the EU region, gating capture behind real consent rather than assuming it, masking or excluding personal data from session replay, and not sending PII in event properties. The setup in this guide is what makes that combination actually true for your specific implementation, not something PostHog does automatically regardless of how you wire it up.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why initialize PostHog in instrumentation-client.ts instead of a React component or provider?&lt;/strong&gt;
Because PostHog is a singleton once &lt;code&gt;.init()&lt;/code&gt; has run — there&amp;#39;s nothing a React component tree gives you that a plain module-level init doesn&amp;#39;t, and &lt;code&gt;instrumentation-client.ts&lt;/code&gt; runs before hydration, which means the SDK exists and is ready before your app&amp;#39;s first render rather than racing a &lt;code&gt;useEffect&lt;/code&gt; in some top-level component. A dedicated React context for PostHog is unnecessary complexity for something that&amp;#39;s fundamentally a global, one-time setup step.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens to session replay if maskAllInputs is on but I forget to mask rendered text?&lt;/strong&gt;
Form field values stay masked correctly, but any personal data your app renders back onto the page as plain text — a name, an email, an address — will appear unmasked in the recording, because &lt;code&gt;maskAllInputs&lt;/code&gt; only touches actual &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; elements, not arbitrary DOM text. That&amp;#39;s exactly the gap &lt;code&gt;.ph-mask&lt;/code&gt; and &lt;code&gt;data-private&lt;/code&gt; close, and it&amp;#39;s worth an explicit pass through your app looking for anywhere customer data gets echoed back before you turn recording on in production.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How do I know if my deploy pipeline has the same env-var problem described above?&lt;/strong&gt;
Check whichever step actually invokes &lt;code&gt;next build&lt;/code&gt; — a Dockerfile &lt;code&gt;RUN&lt;/code&gt; instruction, a CI build job, a Nixpacks/Vercel build step — and confirm the &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; variables your code references are genuinely present in that step&amp;#39;s environment, not just in whatever &lt;code&gt;.env&lt;/code&gt; file your container reads when it starts running afterward. If your build step gets its environment from a script, a secrets manager scope, or an allowlist rather than directly from your full &lt;code&gt;.env&lt;/code&gt; file, that&amp;#39;s the exact place a newly added variable can go missing without anything in your pipeline telling you.&lt;/p&gt;
&lt;h2&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;PostHog gets you real product analytics and session replay without the black-box feel of a CDN snippet, but the SDK install is the easy twenty percent of the work. The parts that actually matter for shipping it responsibly are wiring it into consent you already have instead of building a second system, masking session replay before real user data ever touches it, and — the one most guides skip entirely — understanding that &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; variables live and die at build time, not runtime, which means &amp;quot;the deploy succeeded&amp;quot; and &amp;quot;the feature actually works&amp;quot; are two different claims that need two different checks.&lt;/p&gt;
&lt;p&gt;If you&amp;#39;re setting this up and want a second pair of eyes on your deploy pipeline specifically — the build-vs-runtime env var split is the kind of thing that&amp;#39;s obvious once you&amp;#39;ve been bitten by it once and invisible until then — that&amp;#39;s exactly the kind of implementation work I take on for clients. Let me know in the comments if you run into something this guide didn&amp;#39;t cover, and subscribe if you want more practical, real-implementation guides like this one.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Tested with Next.js 16 (App Router) and posthog-js 1.409.x, deployed to a Docker-based production pipeline. Last updated 2026-08-02.&lt;/em&gt;&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/posthog-nextjs-setup-gdpr-analytics-session-replay"/>
        </item>
        <item>
            <title><![CDATA[Production-Ready Driver.js Product Tour for Next.js 16]]></title>
            <description><![CDATA[<p>Building a real product tour with Driver.js in Next.js 16 comes down to one architectural choice: keep your pages as Server Components and let a single Client Component own the entire Driver.js lifecycle, including dynamic import, per-route segments, resume state, and cleanup. That one boundary is what lets a tour survive hydration timing, App Router navigation, responsive layouts, localization, reduced motion, and analytics consent without falling apart after the first demo. This guide walks through that exact implementation, built for a live vehicle marketplace, with the storage layer, the provider, the theming, and the Playwright tests that prove it works.</p>
<h2>Why the demo version breaks in production</h2>
<p>I needed a guided tour for Avtolibre, my vehicle marketplace built on Next.js and Payload CMS, covering search, an AI recommendation guide, vehicle filtering, and a leasing calculator across three locale-prefixed routes. The Driver.js quickstart handles a single static page fine: install the package, point a step at an element, call <code>drive()</code>. A tour that spans routes, locales, and device sizes has to also wait for hydration, target different desktop and mobile elements, persist across App Router navigation, respect reduced motion, and only fire analytics after consent. None of that comes from the library out of the box, and getting it right is what separates a demo from something you can ship.</p>
<p>The stack for this implementation:</p>
<ul>
<li>Next.js 16.2.6 and the App Router</li>
<li>React 19.2</li>
<li>Driver.js 1.8.0</li>
<li>TypeScript</li>
<li><code>next-intl</code></li>
<li>Tailwind CSS and shadcn/ui</li>
<li>Google Tag Manager</li>
<li>Playwright</li>
</ul>
<p>The finished tour has eight steps across three routes:</p>
<pre><code class="language-text">/{locale}
  Search
  AI guide

/{locale}/vehicles
  Filters
  Results
  Monthly cost (optional)

/{locale}/leasing-calculator
  Calculator inputs
  Estimated range
  Matching-vehicle search
</code></pre>
<p>It starts automatically once per tour version, can be dismissed and restarted manually, and resumes correctly after client-side navigation.</p>
<h2>The architectural decisions that make this work</h2>
<ol>
<li>Existing layouts and pages stay Server Components.</li>
<li>A small Client Component owns Driver.js.</li>
<li>The JavaScript package is dynamically imported after hydration.</li>
<li>The vendor stylesheet loads once from the shared frontend layout.</li>
<li>Tour targets use stable <code>data-tour</code> attributes.</li>
<li><code>localStorage</code> remembers whether a version was seen or completed.</li>
<li><code>sessionStorage</code> carries the active step across routes.</li>
<li>Every Driver.js instance is destroyed before navigation or unmounting.</li>
<li>Analytics use stable IDs rather than translated copy.</li>
<li>Playwright verifies the complete browser behavior.</li>
</ol>
<p>Here&#39;s the resulting file structure:</p>
<pre><code class="language-text">src/
  app/
    (frontend)/
      layout.tsx
  components/
    product-tour/
      ProductTourProvider.tsx
      ProductTourTrigger.tsx
      product-tour.css
  modules/
    marketplace/
      product-tour/
        analytics.ts
        definition.ts
        storage.ts
        types.ts
        validation.ts
        README.md
messages/
  de.json
  en.json
  sl.json
e2e/
  product-tour.spec.ts
</code></pre>
<h2>Step 1: Install Driver.js with a pinned version</h2>
<p>Use whatever package manager the project already uses. Avtolibre runs pnpm, and I pin Driver.js exactly rather than letting it float:</p>
<pre><code class="language-bash">pnpm add driver.js@1.8.0 --save-exact
</code></pre>
<p>In a pnpm workspace, target the application package when running from the repository root:</p>
<pre><code class="language-bash">pnpm --filter your-web-app add driver.js@1.8.0 --save-exact
</code></pre>
<p>Skip a CDN, <code>next/script</code>, custom Turbopack loaders, <code>transpilePackages</code>, or a Webpack fallback for this. Driver.js ships normal package exports, TypeScript declarations, JavaScript bundles, and a stylesheet, so none of that machinery is necessary.</p>
<h2>Step 2: Load the CSS from the layout, and the JavaScript from the client</h2>
<p>Import the Driver.js stylesheet once in the narrowest layout that covers the whole tour, and import any custom overrides right after it:</p>
<pre><code class="language-tsx">// File: src/app/(frontend)/layout.tsx
import &quot;driver.js/dist/driver.css&quot;;
import &quot;@/components/product-tour/product-tour.css&quot;;

export default function FrontendLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;body&gt;{children}&lt;/body&gt;
    &lt;/html&gt;
  );
}
</code></pre>
<p>The layout stays a Server Component throughout. Driver.js&#39;s JavaScript loads later, from the client runtime, using a dynamic import:</p>
<pre><code class="language-ts">const { driver } = await import(&quot;driver.js&quot;);
</code></pre>
<p>CSS belongs in the shared layout because it needs to be present the moment any tour target renders. Browser-only behavior belongs in a client island because it depends on hydration, <code>window</code>, and user interaction.</p>
<h2>Step 3: Give the tour a stable DOM contract</h2>
<p>Tour selectors are part of your UI contract, the same way a public API is. Target dedicated attributes rather than Tailwind classes, translated text, DOM position, or shadcn/ui internals, since any of those can change during a routine redesign and quietly break every step:</p>
<pre><code class="language-tsx">&lt;form data-tour=&quot;marketplace-search&quot;&gt;
  {/* Search controls */}
&lt;/form&gt;

&lt;section data-tour=&quot;vehicle-results&quot;&gt;
  {/* Result cards */}
&lt;/section&gt;
</code></pre>
<p>For content that only becomes usable after hydration, expose readiness explicitly:</p>
<pre><code class="language-tsx">&quot;use client&quot;;

import { useEffect, useState } from &quot;react&quot;;

export function SearchPanel() {
  const [tourReady, setTourReady] = useState(false);

  useEffect(() =&gt; {
    setTourReady(true);
  }, []);

  return (
    &lt;form
      data-tour=&quot;marketplace-search&quot;
      data-tour-ready={tourReady ? &quot;true&quot; : undefined}
    &gt;
      {/* Search controls */}
    &lt;/form&gt;
  );
}
</code></pre>
<p>The step can then require both identity and readiness:</p>
<pre><code class="language-ts">element:
  &#39;[data-tour=&quot;marketplace-search&quot;][data-tour-ready=&quot;true&quot;]&#39;;
</code></pre>
<p>Driver.js observes the DOM until this target appears, which is far more reliable than a fixed timeout guessing when hydration finished.</p>
<p>Watch for duplicate <code>data-tour</code> values on repeated cards. In this implementation, only the first vehicle with a monthly-payment value gets the optional target:</p>
<pre><code class="language-tsx">const monthlyPaymentTourIndex = vehicles.findIndex(
  (vehicle) =&gt; vehicle.monthlyPaymentFrom != null,
);

{vehicles.map((vehicle, index) =&gt; (
  &lt;VehicleCard
    key={vehicle.id}
    vehicle={vehicle}
    tourTarget={
      index === monthlyPaymentTourIndex
        ? &quot;vehicle-monthly-cost&quot;
        : undefined
    }
  /&gt;
))}
</code></pre>
<h2>Step 4: Define the tour types</h2>
<p>Reuse Driver.js&#39;s exported <code>DriveStep</code> type rather than recreating the library&#39;s API from scratch:</p>
<pre><code class="language-ts">// File: src/modules/marketplace/product-tour/types.ts
import type { DriveStep } from &quot;driver.js&quot;;

export type ProductTourSource =
  | &quot;automatic&quot;
  | &quot;manual&quot;
  | &quot;resume&quot;;

export type ProductTourSegment = {
  id: string;
  pathname: string;
  href?: string;
  steps: DriveStep[];
};

export type ProductTourDefinition = {
  id: string;
  version: number;
  autoStart?: boolean;
  segments: ProductTourSegment[];
};

export type ProductTourResumeState = {
  tourId: string;
  version: number;
  segmentIndex: number;
  stepIndex: number;
  source: ProductTourSource;
  savedAt: number;
};

export type StartProductTourOptions = {
  source?: ProductTourSource;
  restart?: boolean;
};
</code></pre>
<p>A segment maps to one route and one Driver.js instance. That boundary is what makes App Router navigation reliable: destroy the old instance, navigate, and create a fresh one on the destination route.</p>
<h2>Step 5: Persist seen, completed, and resume state</h2>
<p>Version every storage key. Bumping the version is what makes a materially changed tour eligible to run again for returning visitors:</p>
<pre><code class="language-text">product-tour:marketplace-overview:v2:seen
product-tour:marketplace-overview:v2:completed
product-tour:marketplace-overview:v2:resume
</code></pre>
<p><code>localStorage</code> holds durable seen and completed state. <code>sessionStorage</code> carries the short-lived handoff between routes during a single visit:</p>
<pre><code class="language-ts">// File: src/modules/marketplace/product-tour/storage.ts
import type {
  ProductTourDefinition,
  ProductTourResumeState,
} from &quot;./types&quot;;

function storagePrefix(tour: ProductTourDefinition): string {
  return `product-tour:${tour.id}:v${tour.version}`;
}

export function productTourSeenKey(
  tour: ProductTourDefinition,
): string {
  return `${storagePrefix(tour)}:seen`;
}

export function productTourCompletedKey(
  tour: ProductTourDefinition,
): string {
  return `${storagePrefix(tour)}:completed`;
}

export function productTourResumeKey(
  tour: ProductTourDefinition,
): string {
  return `${storagePrefix(tour)}:resume`;
}

export function hasSeenTour(
  tour: ProductTourDefinition,
): boolean {
  try {
    return (
      window.localStorage.getItem(productTourSeenKey(tour)) === &quot;1&quot;
    );
  } catch {
    return false;
  }
}

export function markTourSeen(
  tour: ProductTourDefinition,
): void {
  try {
    window.localStorage.setItem(productTourSeenKey(tour), &quot;1&quot;);
  } catch {
    // Storage can be unavailable in restricted browser contexts.
  }
}

export function markTourCompleted(
  tour: ProductTourDefinition,
): void {
  try {
    window.localStorage.setItem(
      productTourCompletedKey(tour),
      &quot;1&quot;,
    );
  } catch {
    // The tour still works when persistence is unavailable.
  }
}

export function saveTourResumeState(
  tour: ProductTourDefinition,
  state: ProductTourResumeState,
): void {
  try {
    window.sessionStorage.setItem(
      productTourResumeKey(tour),
      JSON.stringify(state),
    );
  } catch {
    // Cross-route resume is unavailable when storage is blocked.
  }
}

function isProductTourSource(
  value: unknown,
): value is ProductTourResumeState[&quot;source&quot;] {
  return (
    value === &quot;automatic&quot; ||
    value === &quot;manual&quot; ||
    value === &quot;resume&quot;
  );
}

export function parseTourResumeState(
  tour: ProductTourDefinition,
  value: string,
): ProductTourResumeState | null {
  try {
    const parsed = JSON.parse(
      value,
    ) as Partial&lt;ProductTourResumeState&gt;;

    if (
      parsed.tourId !== tour.id ||
      parsed.version !== tour.version ||
      !Number.isInteger(parsed.segmentIndex) ||
      (parsed.segmentIndex ?? -1) &lt; 0 ||
      !Number.isInteger(parsed.stepIndex) ||
      (parsed.stepIndex ?? -1) &lt; 0 ||
      !isProductTourSource(parsed.source) ||
      typeof parsed.savedAt !== &quot;number&quot;
    ) {
      return null;
    }

    return parsed as ProductTourResumeState;
  } catch {
    return null;
  }
}

export function readTourResumeState(
  tour: ProductTourDefinition,
): ProductTourResumeState | null {
  try {
    const value = window.sessionStorage.getItem(
      productTourResumeKey(tour),
    );
    if (!value) return null;

    const state = parseTourResumeState(tour, value);
    if (!state) clearTourResumeState(tour);
    return state;
  } catch {
    clearTourResumeState(tour);
    return null;
  }
}

export function clearTourResumeState(
  tour: ProductTourDefinition,
): void {
  try {
    window.sessionStorage.removeItem(productTourResumeKey(tour));
  } catch {
    // No action is required.
  }
}

export function resetTourStorage(
  tour: ProductTourDefinition,
): void {
  try {
    window.localStorage.removeItem(productTourSeenKey(tour));
    window.localStorage.removeItem(productTourCompletedKey(tour));
    window.sessionStorage.removeItem(productTourResumeKey(tour));
  } catch {
    // No action is required.
  }
}
</code></pre>
<p>These functions only touch browser storage when called directly. Keep storage reads out of module scope, and out of anything that produces server-rendered HTML.</p>
<h2>Step 6: Keep analytics stable, consent-aware, and free of personal data</h2>
<p>A translated step title makes a poor analytics identifier, since it changes with every copy edit. Give every step a stable <code>data.id</code> and send that value to GTM instead. Avtolibre already tracks a consent flag, so the analytics helper checks it before pushing anything:</p>
<pre><code class="language-ts">// File: src/modules/marketplace/product-tour/analytics.ts
import type { ProductTourSource } from &quot;./types&quot;;

export type ProductTourEvent =
  | &quot;product_tour_started&quot;
  | &quot;product_tour_segment_started&quot;
  | &quot;product_tour_step_viewed&quot;
  | &quot;product_tour_route_transition&quot;
  | &quot;product_tour_completed&quot;
  | &quot;product_tour_dismissed&quot;
  | &quot;product_tour_error&quot;;

type ProductTourEventProperties = {
  tourId: string;
  tourVersion: number;
  source?: ProductTourSource;
  segmentId?: string;
  segmentIndex?: number;
  stepId?: string;
  stepIndex?: number;
  nextPathname?: string;
  errorMessage?: string;
};

export function trackProductTourEvent(
  event: ProductTourEvent,
  properties: ProductTourEventProperties,
): void {
  if (typeof window === &quot;undefined&quot;) return;

  try {
    if (window.localStorage.getItem(&quot;analytics-consent&quot;) !== &quot;true&quot;) {
      return;
    }
  } catch {
    return;
  }

  const analyticsWindow = window as Window &amp; {
    dataLayer?: unknown[];
  };

  analyticsWindow.dataLayer ??= [];
  analyticsWindow.dataLayer.push({
    event,
    tour_id: properties.tourId,
    tour_version: properties.tourVersion,
    tour_source: properties.source,
    tour_segment_id: properties.segmentId,
    tour_segment_index: properties.segmentIndex,
    tour_step_id: properties.stepId,
    tour_step_index: properties.stepIndex,
    tour_next_pathname: properties.nextPathname,
    tour_error_message: properties.errorMessage,
  });
}
</code></pre>
<p>Swap <code>analytics-consent</code> for your own consent abstraction. Keep search text, vehicle IDs, financing inputs, form values, and any personal information out of tour events entirely.</p>
<h2>Step 7: Build a localized, route-aware definition</h2>
<p>The definition is a function because both the locale-prefixed paths and the translated copy are runtime inputs, not constants:</p>
<pre><code class="language-ts">// File: src/modules/marketplace/product-tour/definition.ts
import type { DriveStep } from &quot;driver.js&quot;;
import type { ProductTourDefinition } from &quot;./types&quot;;

export type ProductTourCopy = {
  next: string;
  previous: string;
  finish: string;
  continue: string;
  progress: string;
  searchTitle: string;
  searchDescription: string;
  aiGuideTitle: string;
  aiGuideDescription: string;
  filtersTitle: string;
  filtersDescription: string;
  resultsTitle: string;
  resultsDescription: string;
  monthlyCostTitle: string;
  monthlyCostDescription: string;
  calculatorInputsTitle: string;
  calculatorInputsDescription: string;
  calculatorEstimateTitle: string;
  calculatorEstimateDescription: string;
  calculatorSearchTitle: string;
  calculatorSearchDescription: string;
};

function visibleVehicleFilters(): Element {
  const targets = [
    document.querySelector(
      &#39;[data-tour=&quot;vehicle-filters-desktop&quot;]&#39;,
    ),
    document.querySelector(
      &#39;[data-tour=&quot;vehicle-filters-mobile&quot;]&#39; +
        &#39;[data-tour-ready=&quot;true&quot;]&#39;,
    ),
  ];

  const visibleTarget = targets.find(
    (target): target is HTMLElement =&gt;
      target instanceof HTMLElement &amp;&amp; target.offsetParent !== null,
  );

  // An empty result remains pending until waitForElement expires.
  return visibleTarget ?? (undefined as unknown as Element);
}

function step(
  id: string,
  element: DriveStep[&quot;element&quot;],
  title: string,
  description: string,
  placement: NonNullable&lt;DriveStep[&quot;popover&quot;]&gt; = {},
): DriveStep {
  return {
    element,
    waitForElement: 5000,
    data: { id },
    popover: { title, description, ...placement },
  };
}

export function createMarketplaceProductTour(
  locale: string,
  copy: ProductTourCopy,
): ProductTourDefinition {
  return {
    id: &quot;marketplace-overview&quot;,
    version: 2,
    autoStart: true,
    segments: [
      {
        id: &quot;discovery&quot;,
        pathname: `/${locale}`,
        steps: [
          step(
            &quot;marketplace-search&quot;,
            &#39;[data-tour=&quot;marketplace-search&quot;]&#39; +
              &#39;[data-tour-ready=&quot;true&quot;]&#39;,
            copy.searchTitle,
            copy.searchDescription,
            { side: &quot;bottom&quot;, align: &quot;center&quot; },
          ),
          step(
            &quot;ai-guide&quot;,
            &#39;[data-tour=&quot;ai-guide&quot;]&#39; +
              &#39;[data-tour-ready=&quot;true&quot;]&#39;,
            copy.aiGuideTitle,
            copy.aiGuideDescription,
            { side: &quot;top&quot;, align: &quot;center&quot; },
          ),
        ],
      },
      {
        id: &quot;vehicle-results&quot;,
        pathname: `/${locale}/vehicles`,
        steps: [
          step(
            &quot;vehicle-filters&quot;,
            visibleVehicleFilters,
            copy.filtersTitle,
            copy.filtersDescription,
            { side: &quot;right&quot;, align: &quot;start&quot; },
          ),
          step(
            &quot;vehicle-results&quot;,
            &#39;[data-tour=&quot;vehicle-results&quot;]&#39;,
            copy.resultsTitle,
            copy.resultsDescription,
            { side: &quot;top&quot;, align: &quot;center&quot; },
          ),
          {
            ...step(
              &quot;vehicle-monthly-cost&quot;,
              &#39;[data-tour=&quot;vehicle-monthly-cost&quot;]&#39;,
              copy.monthlyCostTitle,
              copy.monthlyCostDescription,
              { side: &quot;left&quot;, align: &quot;center&quot; },
            ),
            waitForElement: 1000,
            skipMissingElement: true,
            data: {
              id: &quot;vehicle-monthly-cost&quot;,
              optional: true,
            },
          },
        ],
      },
      {
        id: &quot;leasing-calculator&quot;,
        pathname: `/${locale}/leasing-calculator`,
        steps: [
          step(
            &quot;leasing-inputs&quot;,
            &#39;[data-tour=&quot;leasing-inputs&quot;]&#39; +
              &#39;[data-tour-ready=&quot;true&quot;]&#39;,
            copy.calculatorInputsTitle,
            copy.calculatorInputsDescription,
            { side: &quot;right&quot;, align: &quot;start&quot; },
          ),
          step(
            &quot;leasing-estimate&quot;,
            &#39;[data-tour=&quot;leasing-estimate&quot;]&#39; +
              &#39;[data-tour-ready=&quot;true&quot;]&#39;,
            copy.calculatorEstimateTitle,
            copy.calculatorEstimateDescription,
            { side: &quot;left&quot;, align: &quot;center&quot; },
          ),
          step(
            &quot;leasing-vehicle-search&quot;,
            &#39;[data-tour=&quot;leasing-vehicle-search&quot;]&#39; +
              &#39;[data-tour-ready=&quot;true&quot;]&#39;,
            copy.calculatorSearchTitle,
            copy.calculatorSearchDescription,
            { side: &quot;top&quot;, align: &quot;center&quot; },
          ),
        ],
      },
    ],
  };
}
</code></pre>
<p>Avtolibre ships localized Slovenian path segments (<code>/vozila</code> and <code>/leasing-kalkulator</code>). I used the English paths above to keep the example portable, but your route strings need to match exactly what <code>usePathname()</code> returns in your application.</p>
<p>Two details in this definition are worth calling out. The responsive filter step resolves whichever target is actually visible, because a desktop sidebar can exist in the DOM while hidden with CSS, and checking only for existence would target the wrong one. <code>offsetParent !== null</code> is a practical visibility test for that layout. And only the monthly-cost step is marked optional, on purpose: turning on <code>skipMissingElement</code> globally would silently hide any broken selector across the entire tour, turning a bug into an invisible product defect.</p>
<h2>Step 8: Implement the client provider</h2>
<p>This provider is the runtime. It owns the current Driver.js instance, automatic and manual starts, route-segment transitions, resume state, completion and dismissal, localization, reduced motion, analytics, and cleanup after navigation or unmounting. Here&#39;s the pattern in production, with a few imports shortened to generic module paths:</p>
<pre><code class="language-tsx">// File: src/components/product-tour/ProductTourProvider.tsx
&quot;use client&quot;;

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from &quot;react&quot;;
import { usePathname, useRouter } from &quot;next/navigation&quot;;
import { useTranslations } from &quot;next-intl&quot;;
import type { Driver } from &quot;driver.js&quot;;

import { trackProductTourEvent } from &quot;@/modules/product-tour/analytics&quot;;
import {
  clearTourResumeState,
  hasSeenTour,
  markTourCompleted,
  markTourSeen,
  readTourResumeState,
  resetTourStorage,
  saveTourResumeState,
} from &quot;@/modules/product-tour/storage&quot;;
import {
  createMarketplaceProductTour,
  type ProductTourCopy,
} from &quot;@/modules/product-tour/definition&quot;;
import type {
  ProductTourSource,
  StartProductTourOptions,
} from &quot;@/modules/product-tour/types&quot;;
import { validateProductTourDefinition } from &quot;@/modules/product-tour/validation&quot;;

type ProductTourContextValue = {
  isActive: boolean;
  startTour: (
    options?: StartProductTourOptions,
  ) =&gt; Promise&lt;void&gt;;
  stopTour: () =&gt; void;
  resetTour: () =&gt; void;
};

const ProductTourContext =
  createContext&lt;ProductTourContextValue | null&gt;(null);

export function ProductTourProvider({
  locale,
  children,
}: Readonly&lt;{
  locale: string;
  children: React.ReactNode;
}&gt;) {
  const pathname = usePathname();
  const router = useRouter();
  const t = useTranslations(&quot;common.productTour&quot;);

  const copy = useMemo&lt;ProductTourCopy&gt;(
    () =&gt; ({
      next: t(&quot;next&quot;),
      previous: t(&quot;previous&quot;),
      finish: t(&quot;finish&quot;),
      continue: t(&quot;continue&quot;),
      progress: t.raw(&quot;progress&quot;) as string,
      searchTitle: t(&quot;steps.search.title&quot;),
      searchDescription: t(&quot;steps.search.description&quot;),
      aiGuideTitle: t(&quot;steps.aiGuide.title&quot;),
      aiGuideDescription: t(&quot;steps.aiGuide.description&quot;),
      filtersTitle: t(&quot;steps.filters.title&quot;),
      filtersDescription: t(&quot;steps.filters.description&quot;),
      resultsTitle: t(&quot;steps.results.title&quot;),
      resultsDescription: t(&quot;steps.results.description&quot;),
      monthlyCostTitle: t(&quot;steps.monthlyCost.title&quot;),
      monthlyCostDescription: t(&quot;steps.monthlyCost.description&quot;),
      calculatorInputsTitle: t(&quot;steps.calculatorInputs.title&quot;),
      calculatorInputsDescription: t(
        &quot;steps.calculatorInputs.description&quot;,
      ),
      calculatorEstimateTitle: t(&quot;steps.calculatorEstimate.title&quot;),
      calculatorEstimateDescription: t(
        &quot;steps.calculatorEstimate.description&quot;,
      ),
      calculatorSearchTitle: t(&quot;steps.calculatorSearch.title&quot;),
      calculatorSearchDescription: t(
        &quot;steps.calculatorSearch.description&quot;,
      ),
    }),
    [t],
  );

  const tour = useMemo(
    () =&gt; createMarketplaceProductTour(locale, copy),
    [copy, locale],
  );

  const pathnameRef = useRef(pathname);
  const driverRef = useRef&lt;Driver | null&gt;(null);
  const mountedRef = useRef(false);
  const startingRef = useRef(false);
  const routeTransitionRef = useRef(false);
  const completedRef = useRef(false);
  const dismissedRef = useRef(false);
  const teardownRef = useRef(false);
  const generationRef = useRef(0);
  const [isActive, setIsActive] = useState(false);

  useEffect(() =&gt; {
    pathnameRef.current = pathname;
  }, [pathname]);

  useEffect(() =&gt; {
    if (process.env.NODE_ENV === &quot;production&quot;) return;
    const errors = validateProductTourDefinition(tour);
    if (errors.length &gt; 0) {
      throw new Error(`[ProductTour] ${errors.join(&quot;; &quot;)}`);
    }
  }, [tour]);

  useEffect(() =&gt; {
    mountedRef.current = true;

    return () =&gt; {
      mountedRef.current = false;
      teardownRef.current = true;
      generationRef.current += 1;

      if (driverRef.current?.isActive()) {
        driverRef.current.destroy();
      }

      driverRef.current = null;
    };
  }, []);

  const runSegment = useCallback(
    async (
      segmentIndex: number,
      startIndex: number,
      source: ProductTourSource,
    ): Promise&lt;void&gt; =&gt; {
      const segment = tour.segments[segmentIndex];

      if (!segment || segment.pathname !== pathnameRef.current) return;
      if (startingRef.current || driverRef.current?.isActive()) return;

      startingRef.current = true;
      routeTransitionRef.current = false;
      completedRef.current = false;
      dismissedRef.current = false;
      teardownRef.current = false;
      const generation = ++generationRef.current;

      markTourSeen(tour);

      try {
        const { driver } = await import(&quot;driver.js&quot;);

        if (
          !mountedRef.current ||
          generation !== generationRef.current ||
          segment.pathname !== pathnameRef.current
        ) {
          return;
        }

        const hasNextSegment =
          segmentIndex &lt; tour.segments.length - 1;
        const reducedMotion = window.matchMedia(
          &quot;(prefers-reduced-motion: reduce)&quot;,
        ).matches;

        const instance = driver({
          animate: !reducedMotion,
          duration: reducedMotion ? 0 : 300,
          smoothScroll: !reducedMotion,
          showProgress: true,
          progressText: copy.progress,
          nextBtnText: copy.next,
          prevBtnText: copy.previous,
          doneBtnText: hasNextSegment
            ? copy.continue
            : copy.finish,
          allowClose: true,
          allowScroll: true,
          allowKeyboardControl: true,
          overlayColor: &quot;#000000&quot;,
          overlayOpacity: 0.55,
          stagePadding: 8,
          stageRadius: 12,
          popoverOffset: 12,
          popoverClass: &quot;app-product-tour-popover&quot;,
          waitForElement: 5000,
          skipMissingElement: false,
          steps: segment.steps,

          onHighlightStarted: (
            _element,
            activeStep,
            { index },
          ) =&gt; {
            const stepIndex = index ?? 0;
            const stepId =
              typeof activeStep.data?.id === &quot;string&quot;
                ? activeStep.data.id
                : undefined;

            saveTourResumeState(tour, {
              tourId: tour.id,
              version: tour.version,
              segmentIndex,
              stepIndex,
              source,
              savedAt: Date.now(),
            });

            trackProductTourEvent(&quot;product_tour_step_viewed&quot;, {
              tourId: tour.id,
              tourVersion: tour.version,
              source,
              segmentId: segment.id,
              segmentIndex,
              stepId,
              stepIndex,
            });
          },

          onDoneClick: (
            _element,
            _step,
            { driver: activeDriver },
          ) =&gt; {
            if (hasNextSegment) {
              const nextSegment = tour.segments[segmentIndex + 1];

              if (!nextSegment) {
                activeDriver.destroy();
                return;
              }

              saveTourResumeState(tour, {
                tourId: tour.id,
                version: tour.version,
                segmentIndex: segmentIndex + 1,
                stepIndex: 0,
                source: &quot;resume&quot;,
                savedAt: Date.now(),
              });
              routeTransitionRef.current = true;

              trackProductTourEvent(
                &quot;product_tour_route_transition&quot;,
                {
                  tourId: tour.id,
                  tourVersion: tour.version,
                  source,
                  segmentId: segment.id,
                  segmentIndex,
                  nextPathname: nextSegment.pathname,
                },
              );

              activeDriver.destroy();
              router.push(nextSegment.href ?? nextSegment.pathname);
              return;
            }

            completedRef.current = true;
            markTourCompleted(tour);
            clearTourResumeState(tour);

            trackProductTourEvent(&quot;product_tour_completed&quot;, {
              tourId: tour.id,
              tourVersion: tour.version,
              source,
              segmentId: segment.id,
              segmentIndex,
            });

            activeDriver.destroy();
          },

          onDestroyStarted: (
            _element,
            _step,
            { driver: activeDriver, index },
          ) =&gt; {
            dismissedRef.current = true;
            driverRef.current = null;
            if (mountedRef.current) setIsActive(false);
            clearTourResumeState(tour);

            trackProductTourEvent(&quot;product_tour_dismissed&quot;, {
              tourId: tour.id,
              tourVersion: tour.version,
              source,
              segmentId: segment.id,
              segmentIndex,
              stepIndex: index,
            });

            // A custom onDestroyStarted owns final teardown.
            activeDriver.destroy();
          },

          onDestroyed: (_element, _step, { index }) =&gt; {
            driverRef.current = null;
            if (mountedRef.current) setIsActive(false);

            if (
              routeTransitionRef.current ||
              completedRef.current ||
              dismissedRef.current ||
              teardownRef.current
            ) {
              return;
            }

            clearTourResumeState(tour);
            trackProductTourEvent(&quot;product_tour_dismissed&quot;, {
              tourId: tour.id,
              tourVersion: tour.version,
              source,
              segmentId: segment.id,
              segmentIndex,
              stepIndex: index,
            });
          },
        });

        driverRef.current = instance;
        setIsActive(true);

        trackProductTourEvent(&quot;product_tour_segment_started&quot;, {
          tourId: tour.id,
          tourVersion: tour.version,
          source,
          segmentId: segment.id,
          segmentIndex,
        });

        instance.drive(
          Math.min(startIndex, segment.steps.length - 1),
        );
      } catch (error) {
        const errorMessage =
          error instanceof Error
            ? error.message
            : &quot;Unknown Driver.js error&quot;;

        driverRef.current = null;
        clearTourResumeState(tour);
        if (mountedRef.current) setIsActive(false);

        trackProductTourEvent(&quot;product_tour_error&quot;, {
          tourId: tour.id,
          tourVersion: tour.version,
          source,
          segmentId: segment.id,
          segmentIndex,
          errorMessage,
        });

        console.error(&quot;[ProductTour] Unable to start tour&quot;, error);
      } finally {
        startingRef.current = false;
      }
    },
    [copy, router, tour],
  );

  const startTour = useCallback(
    async (
      options: StartProductTourOptions = {},
    ): Promise&lt;void&gt; =&gt; {
      if (startingRef.current || driverRef.current?.isActive()) return;

      const source = options.source ?? &quot;manual&quot;;
      const firstSegment = tour.segments[0];
      if (!firstSegment) return;

      clearTourResumeState(tour);
      trackProductTourEvent(&quot;product_tour_started&quot;, {
        tourId: tour.id,
        tourVersion: tour.version,
        source,
      });

      if (firstSegment.pathname !== pathnameRef.current) {
        markTourSeen(tour);
        saveTourResumeState(tour, {
          tourId: tour.id,
          version: tour.version,
          segmentIndex: 0,
          stepIndex: 0,
          source: &quot;resume&quot;,
          savedAt: Date.now(),
        });
        router.push(firstSegment.href ?? firstSegment.pathname);
        return;
      }

      await runSegment(0, 0, source);
    },
    [router, runSegment, tour],
  );

  const stopTour = useCallback(() =&gt; {
    generationRef.current += 1;
    routeTransitionRef.current = false;
    clearTourResumeState(tour);
    if (driverRef.current?.isActive()) driverRef.current.destroy();
    driverRef.current = null;
    setIsActive(false);
  }, [tour]);

  const resetTour = useCallback(() =&gt; {
    generationRef.current += 1;
    if (driverRef.current?.isActive()) driverRef.current.destroy();
    driverRef.current = null;
    setIsActive(false);
    resetTourStorage(tour);
  }, [tour]);

  useEffect(() =&gt; {
    const resumeState = readTourResumeState(tour);
    if (!resumeState) return;

    const segment = tour.segments[resumeState.segmentIndex];
    if (!segment || segment.pathname !== pathname) return;

    if (resumeState.stepIndex &gt;= segment.steps.length) {
      clearTourResumeState(tour);
      return;
    }

    clearTourResumeState(tour);
    void runSegment(
      resumeState.segmentIndex,
      resumeState.stepIndex,
      &quot;resume&quot;,
    );
  }, [pathname, runSegment, tour]);

  useEffect(() =&gt; {
    if (!tour.autoStart || hasSeenTour(tour)) return;
    const firstSegment = tour.segments[0];
    if (!firstSegment || firstSegment.pathname !== pathname) return;
    void runSegment(0, 0, &quot;automatic&quot;);
  }, [pathname, runSegment, tour]);

  const value = useMemo(
    () =&gt; ({ isActive, startTour, stopTour, resetTour }),
    [isActive, resetTour, startTour, stopTour],
  );

  return (
    &lt;ProductTourContext.Provider value={value}&gt;
      {children}
    &lt;/ProductTourContext.Provider&gt;
  );
}

export function useProductTour(): ProductTourContextValue {
  const context = useContext(ProductTourContext);
  if (!context) {
    throw new Error(
      &quot;useProductTour must be used within ProductTourProvider&quot;,
    );
  }
  return context;
}
</code></pre>
<h3>What each ref is guarding against</h3>
<p>Every ref in this provider closes a specific race condition or analytics ambiguity:</p>
<table>
<thead>
<tr>
<th>Ref</th>
<th>What it prevents</th>
</tr>
</thead>
<tbody><tr>
<td><code>pathnameRef</code></td>
<td>A delayed dynamic import starting on a route the user already left</td>
</tr>
<tr>
<td><code>startingRef</code></td>
<td>Two effects or two clicks starting two instances at once</td>
</tr>
<tr>
<td><code>routeTransitionRef</code></td>
<td>Navigation teardown being logged as a dismissal</td>
</tr>
<tr>
<td><code>completedRef</code></td>
<td>Completion being logged as a dismissal</td>
</tr>
<tr>
<td><code>dismissedRef</code></td>
<td>The same dismissal firing twice</td>
</tr>
<tr>
<td><code>teardownRef</code></td>
<td>React unmount cleanup being logged as a dismissal</td>
</tr>
<tr>
<td><code>generationRef</code></td>
<td>Stale async work resuming after stop, reset, or unmount</td>
</tr>
<tr>
<td><code>mountedRef</code></td>
<td>State updates running after the component has unmounted</td>
</tr>
</tbody></table>
<p>The generation check earns its place especially in React development mode, where a dynamic import can resolve after the component has already unmounted, or after the user has already stopped the tour. Incrementing the generation counter invalidates that stale continuation before it can touch state.</p>
<p>Explicit <code>destroy()</code> calls matter for the same reason. Once you override Driver.js&#39;s lifecycle hooks, your callback becomes responsible for that behavior. This provider calls <code>destroy()</code> when a route segment ends, when the final step completes, from the custom destroy-start callback, when the user stops or resets the tour, and when the provider unmounts. Skipping any of those leaves a stale overlay or event listener behind after navigation.</p>
<h2>Step 9: Add the manual restart trigger</h2>
<p>Automatic start should never remove the manual path, since a visitor might dismiss the tour, finish it, or want to show it to a colleague later:</p>
<pre><code class="language-tsx">// File: src/components/product-tour/ProductTourTrigger.tsx
&quot;use client&quot;;

import { CircleHelp } from &quot;lucide-react&quot;;
import { useTranslations } from &quot;next-intl&quot;;
import { useEffect, useState } from &quot;react&quot;;
import { Button } from &quot;@/components/ui/button&quot;;
import { useProductTour } from &quot;./ProductTourProvider&quot;;

export function ProductTourTrigger({
  compact = false,
}: {
  compact?: boolean;
}) {
  const t = useTranslations(&quot;common.productTour&quot;);
  const { isActive, startTour } = useProductTour();
  const [ready, setReady] = useState(false);

  useEffect(() =&gt; {
    setReady(true);
  }, []);

  return (
    &lt;Button
      type=&quot;button&quot;
      variant=&quot;outline&quot;
      size={compact ? &quot;icon&quot; : &quot;sm&quot;}
      disabled={!ready || isActive}
      data-tour-ready={ready ? &quot;true&quot; : undefined}
      aria-label={isActive ? t(&quot;active&quot;) : t(&quot;trigger&quot;)}
      title={
        compact
          ? isActive
            ? t(&quot;active&quot;)
            : t(&quot;trigger&quot;)
          : undefined
      }
      onClick={() =&gt;
        void startTour({ source: &quot;manual&quot;, restart: true })
      }
    &gt;
      &lt;CircleHelp aria-hidden=&quot;true&quot; /&gt;
      {compact ? (
        &lt;span className=&quot;sr-only&quot;&gt;{t(&quot;trigger&quot;)}&lt;/span&gt;
      ) : isActive ? (
        t(&quot;active&quot;)
      ) : (
        t(&quot;trigger&quot;)
      )}
    &lt;/Button&gt;
  );
}
</code></pre>
<p>Render the labeled button on larger screens and the compact icon on smaller ones, keeping the accessible name intact either way.</p>
<h2>Step 10: Mount the provider at the narrowest shared boundary</h2>
<p>The provider needs to wrap every route the tour touches, and nothing beyond that. On Avtolibre, the public marketplace gets the provider while the authenticated back office does not:</p>
<pre><code class="language-tsx">// Server Component
export async function LocaleChromeGate({
  locale,
  children,
}: {
  locale: string;
  children: React.ReactNode;
}) {
  const pathname = await readRequestPathname();

  if (isOfficePath(pathname)) {
    return &lt;div&gt;{children}&lt;/div&gt;;
  }

  return (
    &lt;ProductTourProvider locale={locale}&gt;
      &lt;div className=&quot;flex min-h-screen flex-col&quot;&gt;
        &lt;MarketplaceNavbar locale={locale} /&gt;
        &lt;main className=&quot;flex-1&quot;&gt;{children}&lt;/main&gt;
        &lt;Footer /&gt;
      &lt;/div&gt;
      &lt;CookieConsent /&gt;
    &lt;/ProductTourProvider&gt;
  );
}
</code></pre>
<p>Passing Server Component content as <code>children</code> through a Client Component boundary works fine here. The pages underneath never need <code>&quot;use client&quot;</code> themselves just because Driver.js highlights elements inside them.</p>
<h2>Step 11: Theme Driver.js with your own design tokens</h2>
<p>Driver.js adds its own classes to the DOM, which plain CSS can theme against existing shadcn variables:</p>
<pre><code class="language-css">/* File: src/components/product-tour/product-tour.css */
.driver-popover.app-product-tour-popover {
  --driver-popover-font-family:
    var(--font-sans, ui-sans-serif, system-ui, sans-serif);

  /* Driver.js puts the SVG overlay at z-index 10000 inline. */
  z-index: 10001;
  width: min(360px, calc(100vw - 32px));
  max-width: 360px;
  padding: 16px;
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) + 4px);
  background: var(--popover);
  color: var(--popover-foreground);
  box-shadow: var(--shadow-xl);
}

.driver-popover.app-product-tour-popover
  .driver-popover-title {
  color: var(--popover-foreground);
  font-size: 1rem;
  font-weight: 700;
  line-height: 1.4;
}

.driver-popover.app-product-tour-popover
  .driver-popover-description,
.driver-popover.app-product-tour-popover
  .driver-popover-progress-text {
  color: var(--muted-foreground);
}

.driver-popover.app-product-tour-popover
  .driver-popover-description {
  font-size: 0.875rem;
  line-height: 1.55;
}

.driver-popover.app-product-tour-popover
  .driver-popover-progress-text {
  font-size: 0.75rem;
}

.driver-popover.app-product-tour-popover
  .driver-popover-footer-btn {
  min-height: 36px;
  padding-inline: 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--secondary);
  color: var(--secondary-foreground);
  font-family: inherit;
  font-size: 0.875rem;
  font-weight: 600;
  text-shadow: none;
}

.driver-popover.app-product-tour-popover
  .driver-popover-next-btn {
  border-color: var(--primary);
  background: var(--primary);
  color: var(--primary-foreground);
}

.driver-popover.app-product-tour-popover
  .driver-popover-close-btn {
  color: var(--muted-foreground);
}

.driver-popover.app-product-tour-popover
  .driver-popover-close-btn:hover {
  color: var(--foreground);
}

.driver-popover.app-product-tour-popover
  .driver-popover-footer-btn:focus-visible,
.driver-popover.app-product-tour-popover
  .driver-popover-close-btn:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

@media (max-width: 640px) {
  .driver-popover.app-product-tour-popover {
    width: calc(100vw - 24px);
    max-width: none;
    padding: 14px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .driver-popover,
  .driver-overlay,
  .driver-active-element {
    transition-duration: 0s !important;
    animation-duration: 0s !important;
  }
}
</code></pre>
<p>Cross-check this against your full z-index system, and test the tour with sticky headers, dialogs, sheets, cookie banners, chat widgets, toasts, and mobile navigation all present at once.</p>
<h2>Step 12: Validate the definition during development</h2>
<p>A typo in a selector deserves to fail loudly before it reaches a demo. A small structural validator catches mistakes TypeScript can&#39;t:</p>
<pre><code class="language-ts">// File: src/modules/marketplace/product-tour/validation.ts
import type { ProductTourDefinition } from &quot;./types&quot;;

export function validateProductTourDefinition(
  tour: ProductTourDefinition,
): string[] {
  const errors: string[] = [];
  const segmentIds = new Set&lt;string&gt;();
  const pathnames = new Set&lt;string&gt;();
  const stepIds = new Set&lt;string&gt;();

  if (!tour.id.trim()) errors.push(&quot;Tour ID must not be empty&quot;);
  if (!Number.isInteger(tour.version) || tour.version &lt; 1) {
    errors.push(&quot;Tour version must be a positive integer&quot;);
  }
  if (tour.segments.length === 0) {
    errors.push(&quot;Tour must include at least one segment&quot;);
  }

  tour.segments.forEach((segment, segmentIndex) =&gt; {
    if (!segment.id.trim()) {
      errors.push(`Segment ${segmentIndex} must have an ID`);
    }
    if (segmentIds.has(segment.id)) {
      errors.push(`Duplicate segment ID: ${segment.id}`);
    }
    segmentIds.add(segment.id);

    if (!segment.pathname.startsWith(&quot;/&quot;)) {
      errors.push(
        `Segment ${segment.id} pathname must start with /`,
      );
    }
    if (pathnames.has(segment.pathname)) {
      errors.push(
        `Duplicate segment pathname: ${segment.pathname}`,
      );
    }
    pathnames.add(segment.pathname);

    if (segment.steps.length === 0) {
      errors.push(`Segment ${segment.id} must include a step`);
    }

    const selectors = new Set&lt;string&gt;();

    segment.steps.forEach((tourStep, stepIndex) =&gt; {
      const stepId =
        typeof tourStep.data?.id === &quot;string&quot;
          ? tourStep.data.id.trim()
          : &quot;&quot;;

      if (!stepId) {
        errors.push(
          `Segment ${segment.id} step ${stepIndex} must have a data.id`,
        );
      }
      if (stepId &amp;&amp; stepIds.has(stepId)) {
        errors.push(`Duplicate step ID: ${stepId}`);
      }
      if (stepId) stepIds.add(stepId);

      if (typeof tourStep.element === &quot;string&quot;) {
        if (selectors.has(tourStep.element)) {
          errors.push(
            `Duplicate selector in segment ${segment.id}: ` +
              tourStep.element,
          );
        }
        selectors.add(tourStep.element);
      }

      if (
        stepIndex === segment.steps.length - 1 &amp;&amp;
        tourStep.popover?.onDoneClick
      ) {
        errors.push(
          `Segment ${segment.id} final step must not override onDoneClick`,
        );
      }
    });
  });

  return errors;
}
</code></pre>
<p>Run this from the provider outside production only. It checks structure, not actual DOM presence, since Suspense and portals can render a target later than the validator would expect. Browser tests are what confirm real DOM behavior, which is what Step 14 covers.</p>
<h2>Step 13: Add the translation keys</h2>
<p>The provider reads every label and every step&#39;s copy from one namespace. Here&#39;s a shortened English example:</p>
<pre><code class="language-json">{
  &quot;common&quot;: {
    &quot;productTour&quot;: {
      &quot;trigger&quot;: &quot;Guided tour&quot;,
      &quot;active&quot;: &quot;Tour in progress&quot;,
      &quot;next&quot;: &quot;Next&quot;,
      &quot;previous&quot;: &quot;Back&quot;,
      &quot;continue&quot;: &quot;Continue&quot;,
      &quot;finish&quot;: &quot;Finish&quot;,
      &quot;progress&quot;: &quot;{{current}} of {{total}}&quot;,
      &quot;steps&quot;: {
        &quot;search&quot;: {
          &quot;title&quot;: &quot;Find the right vehicle&quot;,
          &quot;description&quot;: &quot;Choose a vehicle type or describe the model you are looking for.&quot;
        },
        &quot;aiGuide&quot;: {
          &quot;title&quot;: &quot;Recommendations for your needs&quot;,
          &quot;description&quot;: &quot;The guide narrows the available vehicles with a few short questions.&quot;
        },
        &quot;filters&quot;: {
          &quot;title&quot;: &quot;Refine your selection&quot;,
          &quot;description&quot;: &quot;Filter by brand, price, monthly payment, and other important details.&quot;
        }
      }
    }
  }
}
</code></pre>
<p>Keep analytics IDs out of the translation files entirely. Copy changes often, and the event contract you rely on for reporting should stay fixed regardless. My guide on <a href="https://www.buildwithmatija.com/blog/nextjs-internationalization-architecture-guide">Next.js internationalization architecture</a> covers the three-layer <code>next-intl</code> setup this tour&#39;s translations sit on top of, if you&#39;re wiring up locales from scratch.</p>
<h2>Step 14: Test the real browser behavior</h2>
<p>Unit tests cover versioned keys, corrupted resume state, and definition validation well. They can&#39;t confirm that the overlay actually lands on the correct responsive element, which is exactly what Playwright is for:</p>
<pre><code class="language-ts">// File: e2e/product-tour.spec.ts
import { expect, test } from &quot;@playwright/test&quot;;

test.describe(&quot;marketplace product tour&quot;, () =&gt; {
  test.beforeEach(async ({ page }) =&gt; {
    await page.addInitScript(() =&gt; {
      window.localStorage.clear();
      window.sessionStorage.clear();
    });
    await page.goto(&quot;/en&quot;, { waitUntil: &quot;domcontentloaded&quot; });
  });

  test(
    &quot;starts automatically, dismisses, and restarts manually&quot;,
    async ({ page }) =&gt; {
      await expect(page.locator(&quot;.driver-popover&quot;)).toBeVisible();
      await expect(
        page.locator(&#39;[data-tour=&quot;marketplace-search&quot;]&#39;),
      ).toHaveClass(/driver-active-element/);

      await page.locator(&quot;.driver-popover-close-btn&quot;).click();
      await expect(page.locator(&quot;.driver-popover&quot;)).toHaveCount(0);

      await page
        .getByRole(&quot;button&quot;, { name: &quot;Guided tour&quot; })
        .click();
      await expect(page.locator(&quot;.driver-popover&quot;)).toBeVisible();
    },
  );

  test(&quot;resumes after App Router navigation&quot;, async ({ page }) =&gt; {
    await page.locator(&quot;.driver-popover-next-btn&quot;).click();
    await page.locator(&quot;.driver-popover-next-btn&quot;).click();

    await expect(page).toHaveURL(/\/en\/vehicles$/);
    await expect(page.locator(&quot;.driver-popover&quot;)).toBeVisible();
  });

  test(&quot;uses the mobile filter target&quot;, async ({ page }) =&gt; {
    await page.setViewportSize({ width: 390, height: 844 });
    await page.evaluate(() =&gt; {
      window.localStorage.clear();
      window.sessionStorage.clear();
    });
    await page.reload({ waitUntil: &quot;domcontentloaded&quot; });

    await expect(page.locator(&quot;.driver-popover&quot;)).toBeVisible();
    await page.locator(&quot;.driver-popover-next-btn&quot;).click();
    await page.locator(&quot;.driver-popover-next-btn&quot;).click();

    await expect(page).toHaveURL(/\/en\/vehicles$/);
    await expect(
      page.locator(&#39;[data-tour=&quot;vehicle-filters-mobile&quot;]&#39;),
    ).toHaveClass(/driver-active-element/);
  });

  test(&quot;respects reduced motion&quot;, async ({ page }) =&gt; {
    await page.emulateMedia({ reducedMotion: &quot;reduce&quot; });
    await page.evaluate(() =&gt; {
      window.localStorage.clear();
      window.sessionStorage.clear();
    });
    await page.reload({ waitUntil: &quot;domcontentloaded&quot; });

    await expect(page.locator(&quot;.driver-popover&quot;)).toBeVisible();
    await expect(page.locator(&quot;body&quot;)).toHaveClass(/driver-simple/);

    const duration = await page
      .locator(&quot;.driver-popover&quot;)
      .evaluate(
        (popover) =&gt;
          window.getComputedStyle(popover).animationDuration,
      );

    expect(duration).toBe(&quot;0s&quot;);
  });
});
</code></pre>
<p>My full Avtolibre suite also covers completion storage and filters console errors for hydration, <code>window is not defined</code>, and <code>document is not defined</code> failures. Run the narrow tests first:</p>
<pre><code class="language-bash">pnpm exec vitest run src/tests/unit/product-tour.test.ts
pnpm exec playwright test product-tour.spec.ts --project=default --workers=1
</code></pre>
<p>Follow up with the application&#39;s normal lint, type-check, and production build commands before merging.</p>
<h2>Common mistakes to check for</h2>
<table>
<thead>
<tr><th>Mistake</th><th>Fix</th></tr>
</thead>
<tbody>
<tr><td>Importing Driver.js at module scope</td><td>Import it dynamically inside an effect or a user action, after hydration.</td></tr>
<tr><td>Turning the entire page into a Client Component</td><td>Keep the client boundary around the provider and trigger; Server Components can render <code>data-tour</code> attributes fine.</td></tr>
<tr><td>Targeting Tailwind classes for tour steps</td><td>Use dedicated <code>data-tour</code> attributes, since styling classes describe appearance and change during redesigns.</td></tr>
<tr><td>Keeping one Driver.js instance alive across routes</td><td>Save resume state, destroy the instance, navigate, and start a fresh segment once <code>usePathname()</code> changes.</td></tr>
<tr><td>Using a fixed timeout for async UI</td><td>Use <code>waitForElement</code>, <code>advanceOnClick</code>, or an explicit <code>data-tour-ready</code> signal instead of guessing hydration timing.</td></tr>
<tr><td>Enabling <code>skipMissingElement</code> globally</td><td>Keep it false by default and opt in per step, only for targets that are genuinely optional.</td></tr>
<tr><td>Overriding Done or Destroy hooks without owning teardown</td><td>Explicitly move or destroy the Driver.js instance inside any custom hook that replaces default behavior.</td></tr>
<tr><td>Treating completion and dismissal as the same event</td><td>Track intent with refs, since <code>destroy()</code> fires for completion, route transitions, unmounting, and dismissal alike.</td></tr>
<tr><td>Replaying auto-start on every visit</td><td>Persist a versioned seen key, auto-start once per version, and leave a manual restart button available permanently.</td></tr>
</tbody>
</table><h2>Accessibility and privacy checklist</h2>
<p>Before shipping a tour like this, confirm:</p>
<ul>
<li>Escape and the close button both dismiss the tour.</li>
<li>Previous, Next, and Finish are all keyboard reachable.</li>
<li>Focus indicators stay visible throughout.</li>
<li>Popovers fit at mobile widths and at 200% zoom.</li>
<li>Reduced-motion users get no animated transition.</li>
<li>The tour never traps a user permanently.</li>
<li>Cookie, privacy, or emergency controls stay accessible while the tour runs.</li>
<li>Translated copy holds sufficient contrast in every supported theme.</li>
<li>Analytics events carry no personal, search, or financial data.</li>
<li>Any CMS or user-provided HTML gets sanitized before it reaches popover copy.</li>
</ul>
<h2>Adding another step later</h2>
<p>Whenever the tour changes, work through this sequence:</p>
<ol>
<li>Add one unique <code>data-tour=&quot;...&quot;</code> attribute to a stable rendered element.</li>
<li>Add the matching step with a globally unique <code>data.id</code>.</li>
<li>Add translated title and description keys for every locale.</li>
<li>Decide whether the target needs <code>data-tour-ready</code> or a longer <code>waitForElement</code>.</li>
<li>Mark it optional only when its absence is genuinely valid product behavior.</li>
<li>Update the validator and the Playwright coverage.</li>
<li>Bump the tour version whenever order or meaning changes materially.</li>
<li>Test desktop, mobile, reduced motion, dismissal, restart, completion, and cross-route resume.</li>
</ol>
<h2>FAQ</h2>
<p><strong>Does Driver.js support React out of the box?</strong>
No. Driver.js is a vanilla JavaScript library with no React bindings, which is exactly why this implementation wraps it in a Client Component that manages its own lifecycle, refs, and cleanup by hand.</p>
<p><strong>Why dynamically import Driver.js instead of importing it normally?</strong>
A normal top-level import pulls the library into your initial client bundle even on routes where the tour never runs. A dynamic import inside an effect or a click handler defers that cost until the tour actually starts.</p>
<p><strong>How do I stop the tour from re-running for every returning visitor?</strong>
Store a seen flag in <code>localStorage</code>, keyed by tour ID and version, and check it before auto-starting. Bump the version number only when the tour changes enough to warrant showing it again.</p>
<p><strong>What happens if a target element never appears?</strong>
Driver.js waits up to the <code>waitForElement</code> duration and then either fails the step or skips it, depending on <code>skipMissingElement</code>. Set that flag per step rather than globally, so a genuinely broken selector still surfaces as an error instead of silently vanishing.</p>
<p><strong>Can the tour run across multiple routes in Next.js App Router?</strong>
Yes, by treating each route as its own segment with its own Driver.js instance. Save the target segment and step index to <code>sessionStorage</code> before navigating, destroy the current instance, and start a new one once <code>usePathname()</code> matches the destination.</p>
<h2>Wrapping up</h2>
<p>This structure keeps the strengths of the App Router intact. Pages stay server-rendered, Driver.js stays a small browser-only enhancement, Next.js keeps owning navigation, and state stays explicit and versioned. Responsive differences get resolved on purpose, analytics respect consent, and the Playwright suite proves the overlay actually appears and survives the full route sequence rather than just working in a local demo.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<h2>Further reading</h2>
<ul>
<li><a href="https://driverjs.com/docs/installation">Driver.js installation</a></li>
<li><a href="https://driverjs.com/docs/configuration">Driver.js configuration</a></li>
<li><a href="https://driverjs.com/docs/theming">Driver.js theming</a></li>
<li><a href="https://nextjs.org/docs/app/getting-started/server-and-client-components">Next.js Server and Client Components</a></li>
<li><a href="https://nextjs.org/docs/app/api-reference/functions/use-router">Next.js <code>useRouter</code></a></li>
<li><a href="https://nextjs.org/docs/app/api-reference/functions/use-pathname">Next.js <code>usePathname</code></a></li>
</ul>
<p>Thanks, Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/build-driverjs-product-tour-nextjs-16</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/build-driverjs-product-tour-nextjs-16</guid>
            <category><![CDATA[Next.js]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Sun, 19 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;Building a real product tour with Driver.js in Next.js 16 comes down to one architectural choice: keep your pages as Server Components and let a single Client Component own the entire Driver.js lifecycle, including dynamic import, per-route segments, resume state, and cleanup. That one boundary is what lets a tour survive hydration timing, App Router navigation, responsive layouts, localization, reduced motion, and analytics consent without falling apart after the first demo. This guide walks through that exact implementation, built for a live vehicle marketplace, with the storage layer, the provider, the theming, and the Playwright tests that prove it works.&lt;/p&gt;
&lt;h2&gt;Why the demo version breaks in production&lt;/h2&gt;
&lt;p&gt;I needed a guided tour for Avtolibre, my vehicle marketplace built on Next.js and Payload CMS, covering search, an AI recommendation guide, vehicle filtering, and a leasing calculator across three locale-prefixed routes. The Driver.js quickstart handles a single static page fine: install the package, point a step at an element, call &lt;code&gt;drive()&lt;/code&gt;. A tour that spans routes, locales, and device sizes has to also wait for hydration, target different desktop and mobile elements, persist across App Router navigation, respect reduced motion, and only fire analytics after consent. None of that comes from the library out of the box, and getting it right is what separates a demo from something you can ship.&lt;/p&gt;
&lt;p&gt;The stack for this implementation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Next.js 16.2.6 and the App Router&lt;/li&gt;
&lt;li&gt;React 19.2&lt;/li&gt;
&lt;li&gt;Driver.js 1.8.0&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;&lt;code&gt;next-intl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Tailwind CSS and shadcn/ui&lt;/li&gt;
&lt;li&gt;Google Tag Manager&lt;/li&gt;
&lt;li&gt;Playwright&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The finished tour has eight steps across three routes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/{locale}
  Search
  AI guide

/{locale}/vehicles
  Filters
  Results
  Monthly cost (optional)

/{locale}/leasing-calculator
  Calculator inputs
  Estimated range
  Matching-vehicle search
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It starts automatically once per tour version, can be dismissed and restarted manually, and resumes correctly after client-side navigation.&lt;/p&gt;
&lt;h2&gt;The architectural decisions that make this work&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Existing layouts and pages stay Server Components.&lt;/li&gt;
&lt;li&gt;A small Client Component owns Driver.js.&lt;/li&gt;
&lt;li&gt;The JavaScript package is dynamically imported after hydration.&lt;/li&gt;
&lt;li&gt;The vendor stylesheet loads once from the shared frontend layout.&lt;/li&gt;
&lt;li&gt;Tour targets use stable &lt;code&gt;data-tour&lt;/code&gt; attributes.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;localStorage&lt;/code&gt; remembers whether a version was seen or completed.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sessionStorage&lt;/code&gt; carries the active step across routes.&lt;/li&gt;
&lt;li&gt;Every Driver.js instance is destroyed before navigation or unmounting.&lt;/li&gt;
&lt;li&gt;Analytics use stable IDs rather than translated copy.&lt;/li&gt;
&lt;li&gt;Playwright verifies the complete browser behavior.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here&amp;#39;s the resulting file structure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;src/
  app/
    (frontend)/
      layout.tsx
  components/
    product-tour/
      ProductTourProvider.tsx
      ProductTourTrigger.tsx
      product-tour.css
  modules/
    marketplace/
      product-tour/
        analytics.ts
        definition.ts
        storage.ts
        types.ts
        validation.ts
        README.md
messages/
  de.json
  en.json
  sl.json
e2e/
  product-tour.spec.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 1: Install Driver.js with a pinned version&lt;/h2&gt;
&lt;p&gt;Use whatever package manager the project already uses. Avtolibre runs pnpm, and I pin Driver.js exactly rather than letting it float:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm add driver.js@1.8.0 --save-exact
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In a pnpm workspace, target the application package when running from the repository root:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm --filter your-web-app add driver.js@1.8.0 --save-exact
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Skip a CDN, &lt;code&gt;next/script&lt;/code&gt;, custom Turbopack loaders, &lt;code&gt;transpilePackages&lt;/code&gt;, or a Webpack fallback for this. Driver.js ships normal package exports, TypeScript declarations, JavaScript bundles, and a stylesheet, so none of that machinery is necessary.&lt;/p&gt;
&lt;h2&gt;Step 2: Load the CSS from the layout, and the JavaScript from the client&lt;/h2&gt;
&lt;p&gt;Import the Driver.js stylesheet once in the narrowest layout that covers the whole tour, and import any custom overrides right after it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// File: src/app/(frontend)/layout.tsx
import &amp;quot;driver.js/dist/driver.css&amp;quot;;
import &amp;quot;@/components/product-tour/product-tour.css&amp;quot;;

export default function FrontendLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    &amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;
      &amp;lt;body&amp;gt;{children}&amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The layout stays a Server Component throughout. Driver.js&amp;#39;s JavaScript loads later, from the client runtime, using a dynamic import:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const { driver } = await import(&amp;quot;driver.js&amp;quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CSS belongs in the shared layout because it needs to be present the moment any tour target renders. Browser-only behavior belongs in a client island because it depends on hydration, &lt;code&gt;window&lt;/code&gt;, and user interaction.&lt;/p&gt;
&lt;h2&gt;Step 3: Give the tour a stable DOM contract&lt;/h2&gt;
&lt;p&gt;Tour selectors are part of your UI contract, the same way a public API is. Target dedicated attributes rather than Tailwind classes, translated text, DOM position, or shadcn/ui internals, since any of those can change during a routine redesign and quietly break every step:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;lt;form data-tour=&amp;quot;marketplace-search&amp;quot;&amp;gt;
  {/* Search controls */}
&amp;lt;/form&amp;gt;

&amp;lt;section data-tour=&amp;quot;vehicle-results&amp;quot;&amp;gt;
  {/* Result cards */}
&amp;lt;/section&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For content that only becomes usable after hydration, expose readiness explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;quot;use client&amp;quot;;

import { useEffect, useState } from &amp;quot;react&amp;quot;;

export function SearchPanel() {
  const [tourReady, setTourReady] = useState(false);

  useEffect(() =&amp;gt; {
    setTourReady(true);
  }, []);

  return (
    &amp;lt;form
      data-tour=&amp;quot;marketplace-search&amp;quot;
      data-tour-ready={tourReady ? &amp;quot;true&amp;quot; : undefined}
    &amp;gt;
      {/* Search controls */}
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The step can then require both identity and readiness:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;element:
  &amp;#39;[data-tour=&amp;quot;marketplace-search&amp;quot;][data-tour-ready=&amp;quot;true&amp;quot;]&amp;#39;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Driver.js observes the DOM until this target appears, which is far more reliable than a fixed timeout guessing when hydration finished.&lt;/p&gt;
&lt;p&gt;Watch for duplicate &lt;code&gt;data-tour&lt;/code&gt; values on repeated cards. In this implementation, only the first vehicle with a monthly-payment value gets the optional target:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const monthlyPaymentTourIndex = vehicles.findIndex(
  (vehicle) =&amp;gt; vehicle.monthlyPaymentFrom != null,
);

{vehicles.map((vehicle, index) =&amp;gt; (
  &amp;lt;VehicleCard
    key={vehicle.id}
    vehicle={vehicle}
    tourTarget={
      index === monthlyPaymentTourIndex
        ? &amp;quot;vehicle-monthly-cost&amp;quot;
        : undefined
    }
  /&amp;gt;
))}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 4: Define the tour types&lt;/h2&gt;
&lt;p&gt;Reuse Driver.js&amp;#39;s exported &lt;code&gt;DriveStep&lt;/code&gt; type rather than recreating the library&amp;#39;s API from scratch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/modules/marketplace/product-tour/types.ts
import type { DriveStep } from &amp;quot;driver.js&amp;quot;;

export type ProductTourSource =
  | &amp;quot;automatic&amp;quot;
  | &amp;quot;manual&amp;quot;
  | &amp;quot;resume&amp;quot;;

export type ProductTourSegment = {
  id: string;
  pathname: string;
  href?: string;
  steps: DriveStep[];
};

export type ProductTourDefinition = {
  id: string;
  version: number;
  autoStart?: boolean;
  segments: ProductTourSegment[];
};

export type ProductTourResumeState = {
  tourId: string;
  version: number;
  segmentIndex: number;
  stepIndex: number;
  source: ProductTourSource;
  savedAt: number;
};

export type StartProductTourOptions = {
  source?: ProductTourSource;
  restart?: boolean;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A segment maps to one route and one Driver.js instance. That boundary is what makes App Router navigation reliable: destroy the old instance, navigate, and create a fresh one on the destination route.&lt;/p&gt;
&lt;h2&gt;Step 5: Persist seen, completed, and resume state&lt;/h2&gt;
&lt;p&gt;Version every storage key. Bumping the version is what makes a materially changed tour eligible to run again for returning visitors:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;product-tour:marketplace-overview:v2:seen
product-tour:marketplace-overview:v2:completed
product-tour:marketplace-overview:v2:resume
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;localStorage&lt;/code&gt; holds durable seen and completed state. &lt;code&gt;sessionStorage&lt;/code&gt; carries the short-lived handoff between routes during a single visit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/modules/marketplace/product-tour/storage.ts
import type {
  ProductTourDefinition,
  ProductTourResumeState,
} from &amp;quot;./types&amp;quot;;

function storagePrefix(tour: ProductTourDefinition): string {
  return `product-tour:${tour.id}:v${tour.version}`;
}

export function productTourSeenKey(
  tour: ProductTourDefinition,
): string {
  return `${storagePrefix(tour)}:seen`;
}

export function productTourCompletedKey(
  tour: ProductTourDefinition,
): string {
  return `${storagePrefix(tour)}:completed`;
}

export function productTourResumeKey(
  tour: ProductTourDefinition,
): string {
  return `${storagePrefix(tour)}:resume`;
}

export function hasSeenTour(
  tour: ProductTourDefinition,
): boolean {
  try {
    return (
      window.localStorage.getItem(productTourSeenKey(tour)) === &amp;quot;1&amp;quot;
    );
  } catch {
    return false;
  }
}

export function markTourSeen(
  tour: ProductTourDefinition,
): void {
  try {
    window.localStorage.setItem(productTourSeenKey(tour), &amp;quot;1&amp;quot;);
  } catch {
    // Storage can be unavailable in restricted browser contexts.
  }
}

export function markTourCompleted(
  tour: ProductTourDefinition,
): void {
  try {
    window.localStorage.setItem(
      productTourCompletedKey(tour),
      &amp;quot;1&amp;quot;,
    );
  } catch {
    // The tour still works when persistence is unavailable.
  }
}

export function saveTourResumeState(
  tour: ProductTourDefinition,
  state: ProductTourResumeState,
): void {
  try {
    window.sessionStorage.setItem(
      productTourResumeKey(tour),
      JSON.stringify(state),
    );
  } catch {
    // Cross-route resume is unavailable when storage is blocked.
  }
}

function isProductTourSource(
  value: unknown,
): value is ProductTourResumeState[&amp;quot;source&amp;quot;] {
  return (
    value === &amp;quot;automatic&amp;quot; ||
    value === &amp;quot;manual&amp;quot; ||
    value === &amp;quot;resume&amp;quot;
  );
}

export function parseTourResumeState(
  tour: ProductTourDefinition,
  value: string,
): ProductTourResumeState | null {
  try {
    const parsed = JSON.parse(
      value,
    ) as Partial&amp;lt;ProductTourResumeState&amp;gt;;

    if (
      parsed.tourId !== tour.id ||
      parsed.version !== tour.version ||
      !Number.isInteger(parsed.segmentIndex) ||
      (parsed.segmentIndex ?? -1) &amp;lt; 0 ||
      !Number.isInteger(parsed.stepIndex) ||
      (parsed.stepIndex ?? -1) &amp;lt; 0 ||
      !isProductTourSource(parsed.source) ||
      typeof parsed.savedAt !== &amp;quot;number&amp;quot;
    ) {
      return null;
    }

    return parsed as ProductTourResumeState;
  } catch {
    return null;
  }
}

export function readTourResumeState(
  tour: ProductTourDefinition,
): ProductTourResumeState | null {
  try {
    const value = window.sessionStorage.getItem(
      productTourResumeKey(tour),
    );
    if (!value) return null;

    const state = parseTourResumeState(tour, value);
    if (!state) clearTourResumeState(tour);
    return state;
  } catch {
    clearTourResumeState(tour);
    return null;
  }
}

export function clearTourResumeState(
  tour: ProductTourDefinition,
): void {
  try {
    window.sessionStorage.removeItem(productTourResumeKey(tour));
  } catch {
    // No action is required.
  }
}

export function resetTourStorage(
  tour: ProductTourDefinition,
): void {
  try {
    window.localStorage.removeItem(productTourSeenKey(tour));
    window.localStorage.removeItem(productTourCompletedKey(tour));
    window.sessionStorage.removeItem(productTourResumeKey(tour));
  } catch {
    // No action is required.
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These functions only touch browser storage when called directly. Keep storage reads out of module scope, and out of anything that produces server-rendered HTML.&lt;/p&gt;
&lt;h2&gt;Step 6: Keep analytics stable, consent-aware, and free of personal data&lt;/h2&gt;
&lt;p&gt;A translated step title makes a poor analytics identifier, since it changes with every copy edit. Give every step a stable &lt;code&gt;data.id&lt;/code&gt; and send that value to GTM instead. Avtolibre already tracks a consent flag, so the analytics helper checks it before pushing anything:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/modules/marketplace/product-tour/analytics.ts
import type { ProductTourSource } from &amp;quot;./types&amp;quot;;

export type ProductTourEvent =
  | &amp;quot;product_tour_started&amp;quot;
  | &amp;quot;product_tour_segment_started&amp;quot;
  | &amp;quot;product_tour_step_viewed&amp;quot;
  | &amp;quot;product_tour_route_transition&amp;quot;
  | &amp;quot;product_tour_completed&amp;quot;
  | &amp;quot;product_tour_dismissed&amp;quot;
  | &amp;quot;product_tour_error&amp;quot;;

type ProductTourEventProperties = {
  tourId: string;
  tourVersion: number;
  source?: ProductTourSource;
  segmentId?: string;
  segmentIndex?: number;
  stepId?: string;
  stepIndex?: number;
  nextPathname?: string;
  errorMessage?: string;
};

export function trackProductTourEvent(
  event: ProductTourEvent,
  properties: ProductTourEventProperties,
): void {
  if (typeof window === &amp;quot;undefined&amp;quot;) return;

  try {
    if (window.localStorage.getItem(&amp;quot;analytics-consent&amp;quot;) !== &amp;quot;true&amp;quot;) {
      return;
    }
  } catch {
    return;
  }

  const analyticsWindow = window as Window &amp;amp; {
    dataLayer?: unknown[];
  };

  analyticsWindow.dataLayer ??= [];
  analyticsWindow.dataLayer.push({
    event,
    tour_id: properties.tourId,
    tour_version: properties.tourVersion,
    tour_source: properties.source,
    tour_segment_id: properties.segmentId,
    tour_segment_index: properties.segmentIndex,
    tour_step_id: properties.stepId,
    tour_step_index: properties.stepIndex,
    tour_next_pathname: properties.nextPathname,
    tour_error_message: properties.errorMessage,
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Swap &lt;code&gt;analytics-consent&lt;/code&gt; for your own consent abstraction. Keep search text, vehicle IDs, financing inputs, form values, and any personal information out of tour events entirely.&lt;/p&gt;
&lt;h2&gt;Step 7: Build a localized, route-aware definition&lt;/h2&gt;
&lt;p&gt;The definition is a function because both the locale-prefixed paths and the translated copy are runtime inputs, not constants:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/modules/marketplace/product-tour/definition.ts
import type { DriveStep } from &amp;quot;driver.js&amp;quot;;
import type { ProductTourDefinition } from &amp;quot;./types&amp;quot;;

export type ProductTourCopy = {
  next: string;
  previous: string;
  finish: string;
  continue: string;
  progress: string;
  searchTitle: string;
  searchDescription: string;
  aiGuideTitle: string;
  aiGuideDescription: string;
  filtersTitle: string;
  filtersDescription: string;
  resultsTitle: string;
  resultsDescription: string;
  monthlyCostTitle: string;
  monthlyCostDescription: string;
  calculatorInputsTitle: string;
  calculatorInputsDescription: string;
  calculatorEstimateTitle: string;
  calculatorEstimateDescription: string;
  calculatorSearchTitle: string;
  calculatorSearchDescription: string;
};

function visibleVehicleFilters(): Element {
  const targets = [
    document.querySelector(
      &amp;#39;[data-tour=&amp;quot;vehicle-filters-desktop&amp;quot;]&amp;#39;,
    ),
    document.querySelector(
      &amp;#39;[data-tour=&amp;quot;vehicle-filters-mobile&amp;quot;]&amp;#39; +
        &amp;#39;[data-tour-ready=&amp;quot;true&amp;quot;]&amp;#39;,
    ),
  ];

  const visibleTarget = targets.find(
    (target): target is HTMLElement =&amp;gt;
      target instanceof HTMLElement &amp;amp;&amp;amp; target.offsetParent !== null,
  );

  // An empty result remains pending until waitForElement expires.
  return visibleTarget ?? (undefined as unknown as Element);
}

function step(
  id: string,
  element: DriveStep[&amp;quot;element&amp;quot;],
  title: string,
  description: string,
  placement: NonNullable&amp;lt;DriveStep[&amp;quot;popover&amp;quot;]&amp;gt; = {},
): DriveStep {
  return {
    element,
    waitForElement: 5000,
    data: { id },
    popover: { title, description, ...placement },
  };
}

export function createMarketplaceProductTour(
  locale: string,
  copy: ProductTourCopy,
): ProductTourDefinition {
  return {
    id: &amp;quot;marketplace-overview&amp;quot;,
    version: 2,
    autoStart: true,
    segments: [
      {
        id: &amp;quot;discovery&amp;quot;,
        pathname: `/${locale}`,
        steps: [
          step(
            &amp;quot;marketplace-search&amp;quot;,
            &amp;#39;[data-tour=&amp;quot;marketplace-search&amp;quot;]&amp;#39; +
              &amp;#39;[data-tour-ready=&amp;quot;true&amp;quot;]&amp;#39;,
            copy.searchTitle,
            copy.searchDescription,
            { side: &amp;quot;bottom&amp;quot;, align: &amp;quot;center&amp;quot; },
          ),
          step(
            &amp;quot;ai-guide&amp;quot;,
            &amp;#39;[data-tour=&amp;quot;ai-guide&amp;quot;]&amp;#39; +
              &amp;#39;[data-tour-ready=&amp;quot;true&amp;quot;]&amp;#39;,
            copy.aiGuideTitle,
            copy.aiGuideDescription,
            { side: &amp;quot;top&amp;quot;, align: &amp;quot;center&amp;quot; },
          ),
        ],
      },
      {
        id: &amp;quot;vehicle-results&amp;quot;,
        pathname: `/${locale}/vehicles`,
        steps: [
          step(
            &amp;quot;vehicle-filters&amp;quot;,
            visibleVehicleFilters,
            copy.filtersTitle,
            copy.filtersDescription,
            { side: &amp;quot;right&amp;quot;, align: &amp;quot;start&amp;quot; },
          ),
          step(
            &amp;quot;vehicle-results&amp;quot;,
            &amp;#39;[data-tour=&amp;quot;vehicle-results&amp;quot;]&amp;#39;,
            copy.resultsTitle,
            copy.resultsDescription,
            { side: &amp;quot;top&amp;quot;, align: &amp;quot;center&amp;quot; },
          ),
          {
            ...step(
              &amp;quot;vehicle-monthly-cost&amp;quot;,
              &amp;#39;[data-tour=&amp;quot;vehicle-monthly-cost&amp;quot;]&amp;#39;,
              copy.monthlyCostTitle,
              copy.monthlyCostDescription,
              { side: &amp;quot;left&amp;quot;, align: &amp;quot;center&amp;quot; },
            ),
            waitForElement: 1000,
            skipMissingElement: true,
            data: {
              id: &amp;quot;vehicle-monthly-cost&amp;quot;,
              optional: true,
            },
          },
        ],
      },
      {
        id: &amp;quot;leasing-calculator&amp;quot;,
        pathname: `/${locale}/leasing-calculator`,
        steps: [
          step(
            &amp;quot;leasing-inputs&amp;quot;,
            &amp;#39;[data-tour=&amp;quot;leasing-inputs&amp;quot;]&amp;#39; +
              &amp;#39;[data-tour-ready=&amp;quot;true&amp;quot;]&amp;#39;,
            copy.calculatorInputsTitle,
            copy.calculatorInputsDescription,
            { side: &amp;quot;right&amp;quot;, align: &amp;quot;start&amp;quot; },
          ),
          step(
            &amp;quot;leasing-estimate&amp;quot;,
            &amp;#39;[data-tour=&amp;quot;leasing-estimate&amp;quot;]&amp;#39; +
              &amp;#39;[data-tour-ready=&amp;quot;true&amp;quot;]&amp;#39;,
            copy.calculatorEstimateTitle,
            copy.calculatorEstimateDescription,
            { side: &amp;quot;left&amp;quot;, align: &amp;quot;center&amp;quot; },
          ),
          step(
            &amp;quot;leasing-vehicle-search&amp;quot;,
            &amp;#39;[data-tour=&amp;quot;leasing-vehicle-search&amp;quot;]&amp;#39; +
              &amp;#39;[data-tour-ready=&amp;quot;true&amp;quot;]&amp;#39;,
            copy.calculatorSearchTitle,
            copy.calculatorSearchDescription,
            { side: &amp;quot;top&amp;quot;, align: &amp;quot;center&amp;quot; },
          ),
        ],
      },
    ],
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Avtolibre ships localized Slovenian path segments (&lt;code&gt;/vozila&lt;/code&gt; and &lt;code&gt;/leasing-kalkulator&lt;/code&gt;). I used the English paths above to keep the example portable, but your route strings need to match exactly what &lt;code&gt;usePathname()&lt;/code&gt; returns in your application.&lt;/p&gt;
&lt;p&gt;Two details in this definition are worth calling out. The responsive filter step resolves whichever target is actually visible, because a desktop sidebar can exist in the DOM while hidden with CSS, and checking only for existence would target the wrong one. &lt;code&gt;offsetParent !== null&lt;/code&gt; is a practical visibility test for that layout. And only the monthly-cost step is marked optional, on purpose: turning on &lt;code&gt;skipMissingElement&lt;/code&gt; globally would silently hide any broken selector across the entire tour, turning a bug into an invisible product defect.&lt;/p&gt;
&lt;h2&gt;Step 8: Implement the client provider&lt;/h2&gt;
&lt;p&gt;This provider is the runtime. It owns the current Driver.js instance, automatic and manual starts, route-segment transitions, resume state, completion and dismissal, localization, reduced motion, analytics, and cleanup after navigation or unmounting. Here&amp;#39;s the pattern in production, with a few imports shortened to generic module paths:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// File: src/components/product-tour/ProductTourProvider.tsx
&amp;quot;use client&amp;quot;;

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from &amp;quot;react&amp;quot;;
import { usePathname, useRouter } from &amp;quot;next/navigation&amp;quot;;
import { useTranslations } from &amp;quot;next-intl&amp;quot;;
import type { Driver } from &amp;quot;driver.js&amp;quot;;

import { trackProductTourEvent } from &amp;quot;@/modules/product-tour/analytics&amp;quot;;
import {
  clearTourResumeState,
  hasSeenTour,
  markTourCompleted,
  markTourSeen,
  readTourResumeState,
  resetTourStorage,
  saveTourResumeState,
} from &amp;quot;@/modules/product-tour/storage&amp;quot;;
import {
  createMarketplaceProductTour,
  type ProductTourCopy,
} from &amp;quot;@/modules/product-tour/definition&amp;quot;;
import type {
  ProductTourSource,
  StartProductTourOptions,
} from &amp;quot;@/modules/product-tour/types&amp;quot;;
import { validateProductTourDefinition } from &amp;quot;@/modules/product-tour/validation&amp;quot;;

type ProductTourContextValue = {
  isActive: boolean;
  startTour: (
    options?: StartProductTourOptions,
  ) =&amp;gt; Promise&amp;lt;void&amp;gt;;
  stopTour: () =&amp;gt; void;
  resetTour: () =&amp;gt; void;
};

const ProductTourContext =
  createContext&amp;lt;ProductTourContextValue | null&amp;gt;(null);

export function ProductTourProvider({
  locale,
  children,
}: Readonly&amp;lt;{
  locale: string;
  children: React.ReactNode;
}&amp;gt;) {
  const pathname = usePathname();
  const router = useRouter();
  const t = useTranslations(&amp;quot;common.productTour&amp;quot;);

  const copy = useMemo&amp;lt;ProductTourCopy&amp;gt;(
    () =&amp;gt; ({
      next: t(&amp;quot;next&amp;quot;),
      previous: t(&amp;quot;previous&amp;quot;),
      finish: t(&amp;quot;finish&amp;quot;),
      continue: t(&amp;quot;continue&amp;quot;),
      progress: t.raw(&amp;quot;progress&amp;quot;) as string,
      searchTitle: t(&amp;quot;steps.search.title&amp;quot;),
      searchDescription: t(&amp;quot;steps.search.description&amp;quot;),
      aiGuideTitle: t(&amp;quot;steps.aiGuide.title&amp;quot;),
      aiGuideDescription: t(&amp;quot;steps.aiGuide.description&amp;quot;),
      filtersTitle: t(&amp;quot;steps.filters.title&amp;quot;),
      filtersDescription: t(&amp;quot;steps.filters.description&amp;quot;),
      resultsTitle: t(&amp;quot;steps.results.title&amp;quot;),
      resultsDescription: t(&amp;quot;steps.results.description&amp;quot;),
      monthlyCostTitle: t(&amp;quot;steps.monthlyCost.title&amp;quot;),
      monthlyCostDescription: t(&amp;quot;steps.monthlyCost.description&amp;quot;),
      calculatorInputsTitle: t(&amp;quot;steps.calculatorInputs.title&amp;quot;),
      calculatorInputsDescription: t(
        &amp;quot;steps.calculatorInputs.description&amp;quot;,
      ),
      calculatorEstimateTitle: t(&amp;quot;steps.calculatorEstimate.title&amp;quot;),
      calculatorEstimateDescription: t(
        &amp;quot;steps.calculatorEstimate.description&amp;quot;,
      ),
      calculatorSearchTitle: t(&amp;quot;steps.calculatorSearch.title&amp;quot;),
      calculatorSearchDescription: t(
        &amp;quot;steps.calculatorSearch.description&amp;quot;,
      ),
    }),
    [t],
  );

  const tour = useMemo(
    () =&amp;gt; createMarketplaceProductTour(locale, copy),
    [copy, locale],
  );

  const pathnameRef = useRef(pathname);
  const driverRef = useRef&amp;lt;Driver | null&amp;gt;(null);
  const mountedRef = useRef(false);
  const startingRef = useRef(false);
  const routeTransitionRef = useRef(false);
  const completedRef = useRef(false);
  const dismissedRef = useRef(false);
  const teardownRef = useRef(false);
  const generationRef = useRef(0);
  const [isActive, setIsActive] = useState(false);

  useEffect(() =&amp;gt; {
    pathnameRef.current = pathname;
  }, [pathname]);

  useEffect(() =&amp;gt; {
    if (process.env.NODE_ENV === &amp;quot;production&amp;quot;) return;
    const errors = validateProductTourDefinition(tour);
    if (errors.length &amp;gt; 0) {
      throw new Error(`[ProductTour] ${errors.join(&amp;quot;; &amp;quot;)}`);
    }
  }, [tour]);

  useEffect(() =&amp;gt; {
    mountedRef.current = true;

    return () =&amp;gt; {
      mountedRef.current = false;
      teardownRef.current = true;
      generationRef.current += 1;

      if (driverRef.current?.isActive()) {
        driverRef.current.destroy();
      }

      driverRef.current = null;
    };
  }, []);

  const runSegment = useCallback(
    async (
      segmentIndex: number,
      startIndex: number,
      source: ProductTourSource,
    ): Promise&amp;lt;void&amp;gt; =&amp;gt; {
      const segment = tour.segments[segmentIndex];

      if (!segment || segment.pathname !== pathnameRef.current) return;
      if (startingRef.current || driverRef.current?.isActive()) return;

      startingRef.current = true;
      routeTransitionRef.current = false;
      completedRef.current = false;
      dismissedRef.current = false;
      teardownRef.current = false;
      const generation = ++generationRef.current;

      markTourSeen(tour);

      try {
        const { driver } = await import(&amp;quot;driver.js&amp;quot;);

        if (
          !mountedRef.current ||
          generation !== generationRef.current ||
          segment.pathname !== pathnameRef.current
        ) {
          return;
        }

        const hasNextSegment =
          segmentIndex &amp;lt; tour.segments.length - 1;
        const reducedMotion = window.matchMedia(
          &amp;quot;(prefers-reduced-motion: reduce)&amp;quot;,
        ).matches;

        const instance = driver({
          animate: !reducedMotion,
          duration: reducedMotion ? 0 : 300,
          smoothScroll: !reducedMotion,
          showProgress: true,
          progressText: copy.progress,
          nextBtnText: copy.next,
          prevBtnText: copy.previous,
          doneBtnText: hasNextSegment
            ? copy.continue
            : copy.finish,
          allowClose: true,
          allowScroll: true,
          allowKeyboardControl: true,
          overlayColor: &amp;quot;#000000&amp;quot;,
          overlayOpacity: 0.55,
          stagePadding: 8,
          stageRadius: 12,
          popoverOffset: 12,
          popoverClass: &amp;quot;app-product-tour-popover&amp;quot;,
          waitForElement: 5000,
          skipMissingElement: false,
          steps: segment.steps,

          onHighlightStarted: (
            _element,
            activeStep,
            { index },
          ) =&amp;gt; {
            const stepIndex = index ?? 0;
            const stepId =
              typeof activeStep.data?.id === &amp;quot;string&amp;quot;
                ? activeStep.data.id
                : undefined;

            saveTourResumeState(tour, {
              tourId: tour.id,
              version: tour.version,
              segmentIndex,
              stepIndex,
              source,
              savedAt: Date.now(),
            });

            trackProductTourEvent(&amp;quot;product_tour_step_viewed&amp;quot;, {
              tourId: tour.id,
              tourVersion: tour.version,
              source,
              segmentId: segment.id,
              segmentIndex,
              stepId,
              stepIndex,
            });
          },

          onDoneClick: (
            _element,
            _step,
            { driver: activeDriver },
          ) =&amp;gt; {
            if (hasNextSegment) {
              const nextSegment = tour.segments[segmentIndex + 1];

              if (!nextSegment) {
                activeDriver.destroy();
                return;
              }

              saveTourResumeState(tour, {
                tourId: tour.id,
                version: tour.version,
                segmentIndex: segmentIndex + 1,
                stepIndex: 0,
                source: &amp;quot;resume&amp;quot;,
                savedAt: Date.now(),
              });
              routeTransitionRef.current = true;

              trackProductTourEvent(
                &amp;quot;product_tour_route_transition&amp;quot;,
                {
                  tourId: tour.id,
                  tourVersion: tour.version,
                  source,
                  segmentId: segment.id,
                  segmentIndex,
                  nextPathname: nextSegment.pathname,
                },
              );

              activeDriver.destroy();
              router.push(nextSegment.href ?? nextSegment.pathname);
              return;
            }

            completedRef.current = true;
            markTourCompleted(tour);
            clearTourResumeState(tour);

            trackProductTourEvent(&amp;quot;product_tour_completed&amp;quot;, {
              tourId: tour.id,
              tourVersion: tour.version,
              source,
              segmentId: segment.id,
              segmentIndex,
            });

            activeDriver.destroy();
          },

          onDestroyStarted: (
            _element,
            _step,
            { driver: activeDriver, index },
          ) =&amp;gt; {
            dismissedRef.current = true;
            driverRef.current = null;
            if (mountedRef.current) setIsActive(false);
            clearTourResumeState(tour);

            trackProductTourEvent(&amp;quot;product_tour_dismissed&amp;quot;, {
              tourId: tour.id,
              tourVersion: tour.version,
              source,
              segmentId: segment.id,
              segmentIndex,
              stepIndex: index,
            });

            // A custom onDestroyStarted owns final teardown.
            activeDriver.destroy();
          },

          onDestroyed: (_element, _step, { index }) =&amp;gt; {
            driverRef.current = null;
            if (mountedRef.current) setIsActive(false);

            if (
              routeTransitionRef.current ||
              completedRef.current ||
              dismissedRef.current ||
              teardownRef.current
            ) {
              return;
            }

            clearTourResumeState(tour);
            trackProductTourEvent(&amp;quot;product_tour_dismissed&amp;quot;, {
              tourId: tour.id,
              tourVersion: tour.version,
              source,
              segmentId: segment.id,
              segmentIndex,
              stepIndex: index,
            });
          },
        });

        driverRef.current = instance;
        setIsActive(true);

        trackProductTourEvent(&amp;quot;product_tour_segment_started&amp;quot;, {
          tourId: tour.id,
          tourVersion: tour.version,
          source,
          segmentId: segment.id,
          segmentIndex,
        });

        instance.drive(
          Math.min(startIndex, segment.steps.length - 1),
        );
      } catch (error) {
        const errorMessage =
          error instanceof Error
            ? error.message
            : &amp;quot;Unknown Driver.js error&amp;quot;;

        driverRef.current = null;
        clearTourResumeState(tour);
        if (mountedRef.current) setIsActive(false);

        trackProductTourEvent(&amp;quot;product_tour_error&amp;quot;, {
          tourId: tour.id,
          tourVersion: tour.version,
          source,
          segmentId: segment.id,
          segmentIndex,
          errorMessage,
        });

        console.error(&amp;quot;[ProductTour] Unable to start tour&amp;quot;, error);
      } finally {
        startingRef.current = false;
      }
    },
    [copy, router, tour],
  );

  const startTour = useCallback(
    async (
      options: StartProductTourOptions = {},
    ): Promise&amp;lt;void&amp;gt; =&amp;gt; {
      if (startingRef.current || driverRef.current?.isActive()) return;

      const source = options.source ?? &amp;quot;manual&amp;quot;;
      const firstSegment = tour.segments[0];
      if (!firstSegment) return;

      clearTourResumeState(tour);
      trackProductTourEvent(&amp;quot;product_tour_started&amp;quot;, {
        tourId: tour.id,
        tourVersion: tour.version,
        source,
      });

      if (firstSegment.pathname !== pathnameRef.current) {
        markTourSeen(tour);
        saveTourResumeState(tour, {
          tourId: tour.id,
          version: tour.version,
          segmentIndex: 0,
          stepIndex: 0,
          source: &amp;quot;resume&amp;quot;,
          savedAt: Date.now(),
        });
        router.push(firstSegment.href ?? firstSegment.pathname);
        return;
      }

      await runSegment(0, 0, source);
    },
    [router, runSegment, tour],
  );

  const stopTour = useCallback(() =&amp;gt; {
    generationRef.current += 1;
    routeTransitionRef.current = false;
    clearTourResumeState(tour);
    if (driverRef.current?.isActive()) driverRef.current.destroy();
    driverRef.current = null;
    setIsActive(false);
  }, [tour]);

  const resetTour = useCallback(() =&amp;gt; {
    generationRef.current += 1;
    if (driverRef.current?.isActive()) driverRef.current.destroy();
    driverRef.current = null;
    setIsActive(false);
    resetTourStorage(tour);
  }, [tour]);

  useEffect(() =&amp;gt; {
    const resumeState = readTourResumeState(tour);
    if (!resumeState) return;

    const segment = tour.segments[resumeState.segmentIndex];
    if (!segment || segment.pathname !== pathname) return;

    if (resumeState.stepIndex &amp;gt;= segment.steps.length) {
      clearTourResumeState(tour);
      return;
    }

    clearTourResumeState(tour);
    void runSegment(
      resumeState.segmentIndex,
      resumeState.stepIndex,
      &amp;quot;resume&amp;quot;,
    );
  }, [pathname, runSegment, tour]);

  useEffect(() =&amp;gt; {
    if (!tour.autoStart || hasSeenTour(tour)) return;
    const firstSegment = tour.segments[0];
    if (!firstSegment || firstSegment.pathname !== pathname) return;
    void runSegment(0, 0, &amp;quot;automatic&amp;quot;);
  }, [pathname, runSegment, tour]);

  const value = useMemo(
    () =&amp;gt; ({ isActive, startTour, stopTour, resetTour }),
    [isActive, resetTour, startTour, stopTour],
  );

  return (
    &amp;lt;ProductTourContext.Provider value={value}&amp;gt;
      {children}
    &amp;lt;/ProductTourContext.Provider&amp;gt;
  );
}

export function useProductTour(): ProductTourContextValue {
  const context = useContext(ProductTourContext);
  if (!context) {
    throw new Error(
      &amp;quot;useProductTour must be used within ProductTourProvider&amp;quot;,
    );
  }
  return context;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;What each ref is guarding against&lt;/h3&gt;
&lt;p&gt;Every ref in this provider closes a specific race condition or analytics ambiguity:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ref&lt;/th&gt;
&lt;th&gt;What it prevents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pathnameRef&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A delayed dynamic import starting on a route the user already left&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;startingRef&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Two effects or two clicks starting two instances at once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;routeTransitionRef&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Navigation teardown being logged as a dismissal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;completedRef&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Completion being logged as a dismissal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dismissedRef&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The same dismissal firing twice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;teardownRef&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;React unmount cleanup being logged as a dismissal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;generationRef&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stale async work resuming after stop, reset, or unmount&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mountedRef&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;State updates running after the component has unmounted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The generation check earns its place especially in React development mode, where a dynamic import can resolve after the component has already unmounted, or after the user has already stopped the tour. Incrementing the generation counter invalidates that stale continuation before it can touch state.&lt;/p&gt;
&lt;p&gt;Explicit &lt;code&gt;destroy()&lt;/code&gt; calls matter for the same reason. Once you override Driver.js&amp;#39;s lifecycle hooks, your callback becomes responsible for that behavior. This provider calls &lt;code&gt;destroy()&lt;/code&gt; when a route segment ends, when the final step completes, from the custom destroy-start callback, when the user stops or resets the tour, and when the provider unmounts. Skipping any of those leaves a stale overlay or event listener behind after navigation.&lt;/p&gt;
&lt;h2&gt;Step 9: Add the manual restart trigger&lt;/h2&gt;
&lt;p&gt;Automatic start should never remove the manual path, since a visitor might dismiss the tour, finish it, or want to show it to a colleague later:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// File: src/components/product-tour/ProductTourTrigger.tsx
&amp;quot;use client&amp;quot;;

import { CircleHelp } from &amp;quot;lucide-react&amp;quot;;
import { useTranslations } from &amp;quot;next-intl&amp;quot;;
import { useEffect, useState } from &amp;quot;react&amp;quot;;
import { Button } from &amp;quot;@/components/ui/button&amp;quot;;
import { useProductTour } from &amp;quot;./ProductTourProvider&amp;quot;;

export function ProductTourTrigger({
  compact = false,
}: {
  compact?: boolean;
}) {
  const t = useTranslations(&amp;quot;common.productTour&amp;quot;);
  const { isActive, startTour } = useProductTour();
  const [ready, setReady] = useState(false);

  useEffect(() =&amp;gt; {
    setReady(true);
  }, []);

  return (
    &amp;lt;Button
      type=&amp;quot;button&amp;quot;
      variant=&amp;quot;outline&amp;quot;
      size={compact ? &amp;quot;icon&amp;quot; : &amp;quot;sm&amp;quot;}
      disabled={!ready || isActive}
      data-tour-ready={ready ? &amp;quot;true&amp;quot; : undefined}
      aria-label={isActive ? t(&amp;quot;active&amp;quot;) : t(&amp;quot;trigger&amp;quot;)}
      title={
        compact
          ? isActive
            ? t(&amp;quot;active&amp;quot;)
            : t(&amp;quot;trigger&amp;quot;)
          : undefined
      }
      onClick={() =&amp;gt;
        void startTour({ source: &amp;quot;manual&amp;quot;, restart: true })
      }
    &amp;gt;
      &amp;lt;CircleHelp aria-hidden=&amp;quot;true&amp;quot; /&amp;gt;
      {compact ? (
        &amp;lt;span className=&amp;quot;sr-only&amp;quot;&amp;gt;{t(&amp;quot;trigger&amp;quot;)}&amp;lt;/span&amp;gt;
      ) : isActive ? (
        t(&amp;quot;active&amp;quot;)
      ) : (
        t(&amp;quot;trigger&amp;quot;)
      )}
    &amp;lt;/Button&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Render the labeled button on larger screens and the compact icon on smaller ones, keeping the accessible name intact either way.&lt;/p&gt;
&lt;h2&gt;Step 10: Mount the provider at the narrowest shared boundary&lt;/h2&gt;
&lt;p&gt;The provider needs to wrap every route the tour touches, and nothing beyond that. On Avtolibre, the public marketplace gets the provider while the authenticated back office does not:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// Server Component
export async function LocaleChromeGate({
  locale,
  children,
}: {
  locale: string;
  children: React.ReactNode;
}) {
  const pathname = await readRequestPathname();

  if (isOfficePath(pathname)) {
    return &amp;lt;div&amp;gt;{children}&amp;lt;/div&amp;gt;;
  }

  return (
    &amp;lt;ProductTourProvider locale={locale}&amp;gt;
      &amp;lt;div className=&amp;quot;flex min-h-screen flex-col&amp;quot;&amp;gt;
        &amp;lt;MarketplaceNavbar locale={locale} /&amp;gt;
        &amp;lt;main className=&amp;quot;flex-1&amp;quot;&amp;gt;{children}&amp;lt;/main&amp;gt;
        &amp;lt;Footer /&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;CookieConsent /&amp;gt;
    &amp;lt;/ProductTourProvider&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Passing Server Component content as &lt;code&gt;children&lt;/code&gt; through a Client Component boundary works fine here. The pages underneath never need &lt;code&gt;&amp;quot;use client&amp;quot;&lt;/code&gt; themselves just because Driver.js highlights elements inside them.&lt;/p&gt;
&lt;h2&gt;Step 11: Theme Driver.js with your own design tokens&lt;/h2&gt;
&lt;p&gt;Driver.js adds its own classes to the DOM, which plain CSS can theme against existing shadcn variables:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;/* File: src/components/product-tour/product-tour.css */
.driver-popover.app-product-tour-popover {
  --driver-popover-font-family:
    var(--font-sans, ui-sans-serif, system-ui, sans-serif);

  /* Driver.js puts the SVG overlay at z-index 10000 inline. */
  z-index: 10001;
  width: min(360px, calc(100vw - 32px));
  max-width: 360px;
  padding: 16px;
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) + 4px);
  background: var(--popover);
  color: var(--popover-foreground);
  box-shadow: var(--shadow-xl);
}

.driver-popover.app-product-tour-popover
  .driver-popover-title {
  color: var(--popover-foreground);
  font-size: 1rem;
  font-weight: 700;
  line-height: 1.4;
}

.driver-popover.app-product-tour-popover
  .driver-popover-description,
.driver-popover.app-product-tour-popover
  .driver-popover-progress-text {
  color: var(--muted-foreground);
}

.driver-popover.app-product-tour-popover
  .driver-popover-description {
  font-size: 0.875rem;
  line-height: 1.55;
}

.driver-popover.app-product-tour-popover
  .driver-popover-progress-text {
  font-size: 0.75rem;
}

.driver-popover.app-product-tour-popover
  .driver-popover-footer-btn {
  min-height: 36px;
  padding-inline: 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--secondary);
  color: var(--secondary-foreground);
  font-family: inherit;
  font-size: 0.875rem;
  font-weight: 600;
  text-shadow: none;
}

.driver-popover.app-product-tour-popover
  .driver-popover-next-btn {
  border-color: var(--primary);
  background: var(--primary);
  color: var(--primary-foreground);
}

.driver-popover.app-product-tour-popover
  .driver-popover-close-btn {
  color: var(--muted-foreground);
}

.driver-popover.app-product-tour-popover
  .driver-popover-close-btn:hover {
  color: var(--foreground);
}

.driver-popover.app-product-tour-popover
  .driver-popover-footer-btn:focus-visible,
.driver-popover.app-product-tour-popover
  .driver-popover-close-btn:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

@media (max-width: 640px) {
  .driver-popover.app-product-tour-popover {
    width: calc(100vw - 24px);
    max-width: none;
    padding: 14px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .driver-popover,
  .driver-overlay,
  .driver-active-element {
    transition-duration: 0s !important;
    animation-duration: 0s !important;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cross-check this against your full z-index system, and test the tour with sticky headers, dialogs, sheets, cookie banners, chat widgets, toasts, and mobile navigation all present at once.&lt;/p&gt;
&lt;h2&gt;Step 12: Validate the definition during development&lt;/h2&gt;
&lt;p&gt;A typo in a selector deserves to fail loudly before it reaches a demo. A small structural validator catches mistakes TypeScript can&amp;#39;t:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/modules/marketplace/product-tour/validation.ts
import type { ProductTourDefinition } from &amp;quot;./types&amp;quot;;

export function validateProductTourDefinition(
  tour: ProductTourDefinition,
): string[] {
  const errors: string[] = [];
  const segmentIds = new Set&amp;lt;string&amp;gt;();
  const pathnames = new Set&amp;lt;string&amp;gt;();
  const stepIds = new Set&amp;lt;string&amp;gt;();

  if (!tour.id.trim()) errors.push(&amp;quot;Tour ID must not be empty&amp;quot;);
  if (!Number.isInteger(tour.version) || tour.version &amp;lt; 1) {
    errors.push(&amp;quot;Tour version must be a positive integer&amp;quot;);
  }
  if (tour.segments.length === 0) {
    errors.push(&amp;quot;Tour must include at least one segment&amp;quot;);
  }

  tour.segments.forEach((segment, segmentIndex) =&amp;gt; {
    if (!segment.id.trim()) {
      errors.push(`Segment ${segmentIndex} must have an ID`);
    }
    if (segmentIds.has(segment.id)) {
      errors.push(`Duplicate segment ID: ${segment.id}`);
    }
    segmentIds.add(segment.id);

    if (!segment.pathname.startsWith(&amp;quot;/&amp;quot;)) {
      errors.push(
        `Segment ${segment.id} pathname must start with /`,
      );
    }
    if (pathnames.has(segment.pathname)) {
      errors.push(
        `Duplicate segment pathname: ${segment.pathname}`,
      );
    }
    pathnames.add(segment.pathname);

    if (segment.steps.length === 0) {
      errors.push(`Segment ${segment.id} must include a step`);
    }

    const selectors = new Set&amp;lt;string&amp;gt;();

    segment.steps.forEach((tourStep, stepIndex) =&amp;gt; {
      const stepId =
        typeof tourStep.data?.id === &amp;quot;string&amp;quot;
          ? tourStep.data.id.trim()
          : &amp;quot;&amp;quot;;

      if (!stepId) {
        errors.push(
          `Segment ${segment.id} step ${stepIndex} must have a data.id`,
        );
      }
      if (stepId &amp;amp;&amp;amp; stepIds.has(stepId)) {
        errors.push(`Duplicate step ID: ${stepId}`);
      }
      if (stepId) stepIds.add(stepId);

      if (typeof tourStep.element === &amp;quot;string&amp;quot;) {
        if (selectors.has(tourStep.element)) {
          errors.push(
            `Duplicate selector in segment ${segment.id}: ` +
              tourStep.element,
          );
        }
        selectors.add(tourStep.element);
      }

      if (
        stepIndex === segment.steps.length - 1 &amp;amp;&amp;amp;
        tourStep.popover?.onDoneClick
      ) {
        errors.push(
          `Segment ${segment.id} final step must not override onDoneClick`,
        );
      }
    });
  });

  return errors;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run this from the provider outside production only. It checks structure, not actual DOM presence, since Suspense and portals can render a target later than the validator would expect. Browser tests are what confirm real DOM behavior, which is what Step 14 covers.&lt;/p&gt;
&lt;h2&gt;Step 13: Add the translation keys&lt;/h2&gt;
&lt;p&gt;The provider reads every label and every step&amp;#39;s copy from one namespace. Here&amp;#39;s a shortened English example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;common&amp;quot;: {
    &amp;quot;productTour&amp;quot;: {
      &amp;quot;trigger&amp;quot;: &amp;quot;Guided tour&amp;quot;,
      &amp;quot;active&amp;quot;: &amp;quot;Tour in progress&amp;quot;,
      &amp;quot;next&amp;quot;: &amp;quot;Next&amp;quot;,
      &amp;quot;previous&amp;quot;: &amp;quot;Back&amp;quot;,
      &amp;quot;continue&amp;quot;: &amp;quot;Continue&amp;quot;,
      &amp;quot;finish&amp;quot;: &amp;quot;Finish&amp;quot;,
      &amp;quot;progress&amp;quot;: &amp;quot;{{current}} of {{total}}&amp;quot;,
      &amp;quot;steps&amp;quot;: {
        &amp;quot;search&amp;quot;: {
          &amp;quot;title&amp;quot;: &amp;quot;Find the right vehicle&amp;quot;,
          &amp;quot;description&amp;quot;: &amp;quot;Choose a vehicle type or describe the model you are looking for.&amp;quot;
        },
        &amp;quot;aiGuide&amp;quot;: {
          &amp;quot;title&amp;quot;: &amp;quot;Recommendations for your needs&amp;quot;,
          &amp;quot;description&amp;quot;: &amp;quot;The guide narrows the available vehicles with a few short questions.&amp;quot;
        },
        &amp;quot;filters&amp;quot;: {
          &amp;quot;title&amp;quot;: &amp;quot;Refine your selection&amp;quot;,
          &amp;quot;description&amp;quot;: &amp;quot;Filter by brand, price, monthly payment, and other important details.&amp;quot;
        }
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keep analytics IDs out of the translation files entirely. Copy changes often, and the event contract you rely on for reporting should stay fixed regardless. My guide on &lt;a href=&quot;https://www.buildwithmatija.com/blog/nextjs-internationalization-architecture-guide&quot;&gt;Next.js internationalization architecture&lt;/a&gt; covers the three-layer &lt;code&gt;next-intl&lt;/code&gt; setup this tour&amp;#39;s translations sit on top of, if you&amp;#39;re wiring up locales from scratch.&lt;/p&gt;
&lt;h2&gt;Step 14: Test the real browser behavior&lt;/h2&gt;
&lt;p&gt;Unit tests cover versioned keys, corrupted resume state, and definition validation well. They can&amp;#39;t confirm that the overlay actually lands on the correct responsive element, which is exactly what Playwright is for:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: e2e/product-tour.spec.ts
import { expect, test } from &amp;quot;@playwright/test&amp;quot;;

test.describe(&amp;quot;marketplace product tour&amp;quot;, () =&amp;gt; {
  test.beforeEach(async ({ page }) =&amp;gt; {
    await page.addInitScript(() =&amp;gt; {
      window.localStorage.clear();
      window.sessionStorage.clear();
    });
    await page.goto(&amp;quot;/en&amp;quot;, { waitUntil: &amp;quot;domcontentloaded&amp;quot; });
  });

  test(
    &amp;quot;starts automatically, dismisses, and restarts manually&amp;quot;,
    async ({ page }) =&amp;gt; {
      await expect(page.locator(&amp;quot;.driver-popover&amp;quot;)).toBeVisible();
      await expect(
        page.locator(&amp;#39;[data-tour=&amp;quot;marketplace-search&amp;quot;]&amp;#39;),
      ).toHaveClass(/driver-active-element/);

      await page.locator(&amp;quot;.driver-popover-close-btn&amp;quot;).click();
      await expect(page.locator(&amp;quot;.driver-popover&amp;quot;)).toHaveCount(0);

      await page
        .getByRole(&amp;quot;button&amp;quot;, { name: &amp;quot;Guided tour&amp;quot; })
        .click();
      await expect(page.locator(&amp;quot;.driver-popover&amp;quot;)).toBeVisible();
    },
  );

  test(&amp;quot;resumes after App Router navigation&amp;quot;, async ({ page }) =&amp;gt; {
    await page.locator(&amp;quot;.driver-popover-next-btn&amp;quot;).click();
    await page.locator(&amp;quot;.driver-popover-next-btn&amp;quot;).click();

    await expect(page).toHaveURL(/\/en\/vehicles$/);
    await expect(page.locator(&amp;quot;.driver-popover&amp;quot;)).toBeVisible();
  });

  test(&amp;quot;uses the mobile filter target&amp;quot;, async ({ page }) =&amp;gt; {
    await page.setViewportSize({ width: 390, height: 844 });
    await page.evaluate(() =&amp;gt; {
      window.localStorage.clear();
      window.sessionStorage.clear();
    });
    await page.reload({ waitUntil: &amp;quot;domcontentloaded&amp;quot; });

    await expect(page.locator(&amp;quot;.driver-popover&amp;quot;)).toBeVisible();
    await page.locator(&amp;quot;.driver-popover-next-btn&amp;quot;).click();
    await page.locator(&amp;quot;.driver-popover-next-btn&amp;quot;).click();

    await expect(page).toHaveURL(/\/en\/vehicles$/);
    await expect(
      page.locator(&amp;#39;[data-tour=&amp;quot;vehicle-filters-mobile&amp;quot;]&amp;#39;),
    ).toHaveClass(/driver-active-element/);
  });

  test(&amp;quot;respects reduced motion&amp;quot;, async ({ page }) =&amp;gt; {
    await page.emulateMedia({ reducedMotion: &amp;quot;reduce&amp;quot; });
    await page.evaluate(() =&amp;gt; {
      window.localStorage.clear();
      window.sessionStorage.clear();
    });
    await page.reload({ waitUntil: &amp;quot;domcontentloaded&amp;quot; });

    await expect(page.locator(&amp;quot;.driver-popover&amp;quot;)).toBeVisible();
    await expect(page.locator(&amp;quot;body&amp;quot;)).toHaveClass(/driver-simple/);

    const duration = await page
      .locator(&amp;quot;.driver-popover&amp;quot;)
      .evaluate(
        (popover) =&amp;gt;
          window.getComputedStyle(popover).animationDuration,
      );

    expect(duration).toBe(&amp;quot;0s&amp;quot;);
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My full Avtolibre suite also covers completion storage and filters console errors for hydration, &lt;code&gt;window is not defined&lt;/code&gt;, and &lt;code&gt;document is not defined&lt;/code&gt; failures. Run the narrow tests first:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm exec vitest run src/tests/unit/product-tour.test.ts
pnpm exec playwright test product-tour.spec.ts --project=default --workers=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Follow up with the application&amp;#39;s normal lint, type-check, and production build commands before merging.&lt;/p&gt;
&lt;h2&gt;Common mistakes to check for&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Mistake&lt;/th&gt;&lt;th&gt;Fix&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Importing Driver.js at module scope&lt;/td&gt;&lt;td&gt;Import it dynamically inside an effect or a user action, after hydration.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Turning the entire page into a Client Component&lt;/td&gt;&lt;td&gt;Keep the client boundary around the provider and trigger; Server Components can render &lt;code&gt;data-tour&lt;/code&gt; attributes fine.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Targeting Tailwind classes for tour steps&lt;/td&gt;&lt;td&gt;Use dedicated &lt;code&gt;data-tour&lt;/code&gt; attributes, since styling classes describe appearance and change during redesigns.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keeping one Driver.js instance alive across routes&lt;/td&gt;&lt;td&gt;Save resume state, destroy the instance, navigate, and start a fresh segment once &lt;code&gt;usePathname()&lt;/code&gt; changes.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Using a fixed timeout for async UI&lt;/td&gt;&lt;td&gt;Use &lt;code&gt;waitForElement&lt;/code&gt;, &lt;code&gt;advanceOnClick&lt;/code&gt;, or an explicit &lt;code&gt;data-tour-ready&lt;/code&gt; signal instead of guessing hydration timing.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Enabling &lt;code&gt;skipMissingElement&lt;/code&gt; globally&lt;/td&gt;&lt;td&gt;Keep it false by default and opt in per step, only for targets that are genuinely optional.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Overriding Done or Destroy hooks without owning teardown&lt;/td&gt;&lt;td&gt;Explicitly move or destroy the Driver.js instance inside any custom hook that replaces default behavior.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Treating completion and dismissal as the same event&lt;/td&gt;&lt;td&gt;Track intent with refs, since &lt;code&gt;destroy()&lt;/code&gt; fires for completion, route transitions, unmounting, and dismissal alike.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Replaying auto-start on every visit&lt;/td&gt;&lt;td&gt;Persist a versioned seen key, auto-start once per version, and leave a manual restart button available permanently.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;h2&gt;Accessibility and privacy checklist&lt;/h2&gt;
&lt;p&gt;Before shipping a tour like this, confirm:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Escape and the close button both dismiss the tour.&lt;/li&gt;
&lt;li&gt;Previous, Next, and Finish are all keyboard reachable.&lt;/li&gt;
&lt;li&gt;Focus indicators stay visible throughout.&lt;/li&gt;
&lt;li&gt;Popovers fit at mobile widths and at 200% zoom.&lt;/li&gt;
&lt;li&gt;Reduced-motion users get no animated transition.&lt;/li&gt;
&lt;li&gt;The tour never traps a user permanently.&lt;/li&gt;
&lt;li&gt;Cookie, privacy, or emergency controls stay accessible while the tour runs.&lt;/li&gt;
&lt;li&gt;Translated copy holds sufficient contrast in every supported theme.&lt;/li&gt;
&lt;li&gt;Analytics events carry no personal, search, or financial data.&lt;/li&gt;
&lt;li&gt;Any CMS or user-provided HTML gets sanitized before it reaches popover copy.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Adding another step later&lt;/h2&gt;
&lt;p&gt;Whenever the tour changes, work through this sequence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Add one unique &lt;code&gt;data-tour=&amp;quot;...&amp;quot;&lt;/code&gt; attribute to a stable rendered element.&lt;/li&gt;
&lt;li&gt;Add the matching step with a globally unique &lt;code&gt;data.id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add translated title and description keys for every locale.&lt;/li&gt;
&lt;li&gt;Decide whether the target needs &lt;code&gt;data-tour-ready&lt;/code&gt; or a longer &lt;code&gt;waitForElement&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Mark it optional only when its absence is genuinely valid product behavior.&lt;/li&gt;
&lt;li&gt;Update the validator and the Playwright coverage.&lt;/li&gt;
&lt;li&gt;Bump the tour version whenever order or meaning changes materially.&lt;/li&gt;
&lt;li&gt;Test desktop, mobile, reduced motion, dismissal, restart, completion, and cross-route resume.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Does Driver.js support React out of the box?&lt;/strong&gt;
No. Driver.js is a vanilla JavaScript library with no React bindings, which is exactly why this implementation wraps it in a Client Component that manages its own lifecycle, refs, and cleanup by hand.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why dynamically import Driver.js instead of importing it normally?&lt;/strong&gt;
A normal top-level import pulls the library into your initial client bundle even on routes where the tour never runs. A dynamic import inside an effect or a click handler defers that cost until the tour actually starts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How do I stop the tour from re-running for every returning visitor?&lt;/strong&gt;
Store a seen flag in &lt;code&gt;localStorage&lt;/code&gt;, keyed by tour ID and version, and check it before auto-starting. Bump the version number only when the tour changes enough to warrant showing it again.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens if a target element never appears?&lt;/strong&gt;
Driver.js waits up to the &lt;code&gt;waitForElement&lt;/code&gt; duration and then either fails the step or skips it, depending on &lt;code&gt;skipMissingElement&lt;/code&gt;. Set that flag per step rather than globally, so a genuinely broken selector still surfaces as an error instead of silently vanishing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can the tour run across multiple routes in Next.js App Router?&lt;/strong&gt;
Yes, by treating each route as its own segment with its own Driver.js instance. Save the target segment and step index to &lt;code&gt;sessionStorage&lt;/code&gt; before navigating, destroy the current instance, and start a new one once &lt;code&gt;usePathname()&lt;/code&gt; matches the destination.&lt;/p&gt;
&lt;h2&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;This structure keeps the strengths of the App Router intact. Pages stay server-rendered, Driver.js stays a small browser-only enhancement, Next.js keeps owning navigation, and state stays explicit and versioned. Responsive differences get resolved on purpose, analytics respect consent, and the Playwright suite proves the overlay actually appears and survives the full route sequence rather than just working in a local demo.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;h2&gt;Further reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://driverjs.com/docs/installation&quot;&gt;Driver.js installation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://driverjs.com/docs/configuration&quot;&gt;Driver.js configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://driverjs.com/docs/theming&quot;&gt;Driver.js theming&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/app/getting-started/server-and-client-components&quot;&gt;Next.js Server and Client Components&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/app/api-reference/functions/use-router&quot;&gt;Next.js &lt;code&gt;useRouter&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nextjs.org/docs/app/api-reference/functions/use-pathname&quot;&gt;Next.js &lt;code&gt;usePathname&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Thanks, Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/build-driverjs-product-tour-nextjs-16"/>
        </item>
        <item>
            <title><![CDATA[Build a €25 AI Shortcut Keyboard for Mac (Karabiner)]]></title>
            <description><![CDATA[<p>A cheap Bluetooth mini keyboard, the free Karabiner-Elements app, and about 30 minutes of configuration are enough to build a dedicated shortcut pad for dictation and AI coding tools on a Mac. The approach works even when the keyboard ships with no Mac software at all: as long as macOS recognizes its buttons as ordinary keystrokes, Karabiner can intercept those keystrokes on a per-device basis and remap them into whatever shortcuts your AI tools already use, without touching how those same letters behave on your built-in keyboard.</p>
<p>I picked up a six-button mini keyboard for around €25 with exactly this in mind. Out of the box it had no configuration software and simply typed letters when pressed. I used Karabiner-Elements to turn four of those buttons into direct shortcuts for Cursor, Claude, Codex, and native macOS Dictation. This guide walks through the exact setup, including the device-matching step that keeps the remapped letters from also changing on your MacBook&#39;s own keyboard.</p>
<h2>What you need</h2>
<ul>
<li>A Mac</li>
<li>A small Bluetooth or USB mini keyboard</li>
<li><a href="https://karabiner-elements.pqrs.org/">Karabiner-Elements</a></li>
<li>The keyboard shortcuts your preferred applications already use</li>
</ul>
<p>The mini keyboard does not need official Mac support or bundled software. macOS only needs to recognize its buttons as standard keyboard events, and Karabiner takes it from there.</p>
<h2>The finished layout</h2>
<p>Here is how the four lower buttons ended up mapped:</p>
<table>
<thead>
<tr>
<th>Button</th>
<th>Output</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td>Middle-left (E)</td>
<td>Control + M</td>
<td>Open Cursor</td>
</tr>
<tr>
<td>Middle-right (B)</td>
<td>Dictation media key</td>
<td>Native macOS Dictation</td>
</tr>
<tr>
<td>Bottom-left (F)</td>
<td>Fn + Control + Shift + D</td>
<td>Codex dictation</td>
</tr>
<tr>
<td>Bottom-right (C)</td>
<td>Command + D</td>
<td>Claude</td>
</tr>
</tbody></table>
<p>Karabiner replaces these letters with the assigned shortcuts only when they arrive from the mini keyboard specifically. The same letters typed on the MacBook&#39;s built-in keyboard keep working exactly as they always have.</p>
<h2>Step 1: Connect the mini keyboard and record its raw output</h2>
<p>Pair the device first, through <strong>System Settings → Bluetooth</strong>.</p>
<p>Once it&#39;s connected, open a plain text editor and press every button on the keypad, noting exactly what each one types. My device produced plain letters with no special codes: the two top buttons typed D and A, the middle row typed E and B, and the bottom row typed F and C.</p>
<p>Cheap mini keyboards commonly present themselves to macOS as ordinary keyboards rather than as true programmable macro pads. That&#39;s completely fine for this project; Karabiner works from whatever raw keystrokes the device already sends.</p>
<h2>Step 2: Install Karabiner-Elements</h2>
<p>Download and install Karabiner-Elements from the link above. During installation, macOS will prompt for a few permissions:</p>
<ul>
<li>Input Monitoring</li>
<li>Accessibility access</li>
<li>The Karabiner Driver Extension</li>
</ul>
<p>Grant all three. Karabiner can display and edit rules without them, but it needs Input Monitoring and the driver extension to actually intercept keystrokes before they reach the rest of the system.</p>
<p>If Karabiner shows &quot;Waiting for a connection to the IOKit service,&quot; check <strong>System Settings → General → Login Items &amp; Extensions → Driver Extensions</strong> and confirm the Karabiner virtual HID driver is enabled there. Restarting the Karabiner background services, or in some cases restarting the Mac entirely, is sometimes necessary right after the first install.</p>
<h2>Step 3: Identify the mini keyboard&#39;s device signature</h2>
<p>Open Karabiner-EventViewer, included with the Karabiner-Elements install, and press a button on the mini keyboard. EventViewer will show:</p>
<ul>
<li>Vendor ID</li>
<li>Product ID</li>
<li>The key code the button actually produces</li>
</ul>
<p>My keyboard identified itself as product <code>MINI-KEYBOARD</code>, vendor ID <code>1452</code>, product ID <code>556</code>. Your device will almost certainly report different numbers, so pull your own values from EventViewer rather than reusing these.</p>
<p>This device-specific matching step matters more than it might look. Without it, a rule that remaps the F key would remap F everywhere, including on your MacBook&#39;s built-in keyboard, not just on the mini keypad.</p>
<h2>Step 4: Enable Modify Events for the device</h2>
<p>In Karabiner-Elements, open the <strong>Devices</strong> tab, find the mini keyboard in the list, and enable <strong>Modify events</strong> for it.</p>
<p>Some inexpensive keypads register themselves with macOS as both a keyboard and a pointing device simultaneously. If Karabiner detects button presses in EventViewer but your rules never fire, check that event modification is enabled for the full composite device, not just the keyboard portion of it. My device needed identifiers matching this shape:</p>
<pre><code class="language-json">// File: identifiers used to match the composite device
{
  &quot;is_keyboard&quot;: true,
  &quot;is_pointing_device&quot;: true,
  &quot;product_id&quot;: 556,
  &quot;vendor_id&quot;: 1452
}
</code></pre>
<h2>Step 5: Design the shortcut mappings</h2>
<p>Karabiner can turn one physical button press into any combination of keys, including modifier combinations your applications wouldn&#39;t otherwise let you trigger from a single key.</p>
<p>Before writing any Karabiner rules, test each target shortcut manually on the MacBook keyboard first. Confirming that Control+M actually opens the right panel in Cursor, or that Command+D actually triggers the intended Claude action, saves time over debugging the shortcut and the Karabiner rule at once. Once a shortcut is confirmed working manually, the mapping table above becomes the spec for the rule itself.</p>
<h2>Step 6: Write the Karabiner Complex Modification</h2>
<p>Karabiner rules live in the app&#39;s Complex Modifications editor, but the underlying format is JSON, which makes it easy to write directly. Here is the complete rule for the four buttons on my device:</p>
<pre><code class="language-json">// File: karabiner_mini_keyboard_ai_shortcuts.json
{
  &quot;description&quot;: &quot;MINI-KEYBOARD: AI and dictation shortcuts&quot;,
  &quot;manipulators&quot;: [
    {
      &quot;type&quot;: &quot;basic&quot;,
      &quot;conditions&quot;: [
        {
          &quot;type&quot;: &quot;device_if&quot;,
          &quot;identifiers&quot;: [
            {
              &quot;vendor_id&quot;: 1452,
              &quot;product_id&quot;: 556
            }
          ]
        }
      ],
      &quot;from&quot;: {
        &quot;key_code&quot;: &quot;e&quot;
      },
      &quot;to&quot;: [
        {
          &quot;key_code&quot;: &quot;m&quot;,
          &quot;modifiers&quot;: [&quot;left_control&quot;]
        }
      ]
    },
    {
      &quot;type&quot;: &quot;basic&quot;,
      &quot;conditions&quot;: [
        {
          &quot;type&quot;: &quot;device_if&quot;,
          &quot;identifiers&quot;: [
            {
              &quot;vendor_id&quot;: 1452,
              &quot;product_id&quot;: 556
            }
          ]
        }
      ],
      &quot;from&quot;: {
        &quot;key_code&quot;: &quot;b&quot;
      },
      &quot;to&quot;: [
        {
          &quot;consumer_key_code&quot;: &quot;dictation&quot;
        }
      ]
    },
    {
      &quot;type&quot;: &quot;basic&quot;,
      &quot;conditions&quot;: [
        {
          &quot;type&quot;: &quot;device_if&quot;,
          &quot;identifiers&quot;: [
            {
              &quot;vendor_id&quot;: 1452,
              &quot;product_id&quot;: 556
            }
          ]
        }
      ],
      &quot;from&quot;: {
        &quot;key_code&quot;: &quot;f&quot;
      },
      &quot;to&quot;: [
        {
          &quot;key_code&quot;: &quot;d&quot;,
          &quot;modifiers&quot;: [
            &quot;fn&quot;,
            &quot;left_control&quot;,
            &quot;left_shift&quot;
          ]
        }
      ]
    },
    {
      &quot;type&quot;: &quot;basic&quot;,
      &quot;conditions&quot;: [
        {
          &quot;type&quot;: &quot;device_if&quot;,
          &quot;identifiers&quot;: [
            {
              &quot;vendor_id&quot;: 1452,
              &quot;product_id&quot;: 556
            }
          ]
        }
      ],
      &quot;from&quot;: {
        &quot;key_code&quot;: &quot;c&quot;
      },
      &quot;to&quot;: [
        {
          &quot;key_code&quot;: &quot;d&quot;,
          &quot;modifiers&quot;: [&quot;left_command&quot;]
        }
      ]
    }
  ]
}
</code></pre>
<p>Each manipulator follows the same shape: a <code>device_if</code> condition scoped to the exact vendor and product ID from Step 3, a <code>from</code> key code matching the raw letter the button sends, and a <code>to</code> block defining the actual shortcut to emit. The <code>device_if</code> condition is what keeps this rule from affecting your MacBook keyboard at all. Replace the vendor ID, product ID, input key codes, and output shortcuts with the values that match your own hardware and applications, then import the rule through Karabiner-Elements&#39; Complex Modifications tab.</p>
<h2>Step 7: Test each mapping individually</h2>
<p>Work through each button one at a time rather than testing all four at once:</p>
<ol>
<li>Click into the application the button is meant to control.</li>
<li>Press the mini-keyboard button once.</li>
<li>Confirm the expected action actually starts.</li>
<li>Confirm the original letter was not inserted anywhere.</li>
<li>Confirm the same letter still types normally on the MacBook keyboard.</li>
</ol>
<p>If a button keeps typing its original letter instead of triggering the shortcut, check Karabiner&#39;s log for whether it describes the device as grabbed. Karabiner-EventViewer detecting button presses is a separate thing from Karabiner actually capturing the device; a rule only takes effect once the device is grabbed.</p>
<h2>FAQ</h2>
<p><strong>Does the mini keyboard need to advertise itself as a programmable macro pad?</strong>
No. Karabiner works from whatever raw keyboard events macOS already receives from the device. A keypad that simply types plain letters is enough as a starting point.</p>
<p><strong>Why remap by vendor ID and product ID instead of just the key code?</strong>
Matching only the key code would remap that letter everywhere, including on the built-in MacBook keyboard. Scoping each rule to the mini keyboard&#39;s specific vendor and product ID keeps the remapping isolated to that one device.</p>
<p><strong>What if Karabiner shows the button press in EventViewer but the rule never fires?</strong>
Check the Devices tab and confirm Modify Events is enabled for the mini keyboard. Composite devices that register as both a keyboard and a pointing device sometimes need this toggle set explicitly before rules apply.</p>
<p><strong>Can one button trigger more than a simple two-key shortcut?</strong>
Yes. The <code>to</code> array in a Karabiner manipulator accepts any key code plus any combination of modifiers, so a single button press can reproduce shortcuts your applications only expose through multi-key combinations.</p>
<p><strong>What happens if I switch which AI tool a button controls later?</strong>
Editing the rule&#39;s <code>to</code> block for that manipulator and reimporting it is all that&#39;s needed. The mapping lives entirely in the JSON rule, not on the hardware, so reassigning a button takes a few minutes.</p>
<h2>Wrapping up</h2>
<p>A €25 mini keyboard combined with Karabiner-Elements produces a genuinely useful AI shortcut pad: one-touch dictation, dedicated buttons for Cursor, Claude, and Codex, and mappings that stay entirely under your own control rather than a vendor&#39;s software. The device-matching step in the Complex Modification is what makes it safe to remap ordinary letters without disturbing your regular typing, and the same JSON rule can be edited in minutes whenever your toolset changes. The buttons that control Codex, Claude, and Cursor today can just as easily control a different editor, assistant, or automation tool tomorrow.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<p>Thanks, Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/build-25-euro-ai-shortcut-keyboard-mac-karabiner</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/build-25-euro-ai-shortcut-keyboard-mac-karabiner</guid>
            <category><![CDATA[AI]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Sat, 18 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;A cheap Bluetooth mini keyboard, the free Karabiner-Elements app, and about 30 minutes of configuration are enough to build a dedicated shortcut pad for dictation and AI coding tools on a Mac. The approach works even when the keyboard ships with no Mac software at all: as long as macOS recognizes its buttons as ordinary keystrokes, Karabiner can intercept those keystrokes on a per-device basis and remap them into whatever shortcuts your AI tools already use, without touching how those same letters behave on your built-in keyboard.&lt;/p&gt;
&lt;p&gt;I picked up a six-button mini keyboard for around €25 with exactly this in mind. Out of the box it had no configuration software and simply typed letters when pressed. I used Karabiner-Elements to turn four of those buttons into direct shortcuts for Cursor, Claude, Codex, and native macOS Dictation. This guide walks through the exact setup, including the device-matching step that keeps the remapped letters from also changing on your MacBook&amp;#39;s own keyboard.&lt;/p&gt;
&lt;h2&gt;What you need&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A Mac&lt;/li&gt;
&lt;li&gt;A small Bluetooth or USB mini keyboard&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://karabiner-elements.pqrs.org/&quot;&gt;Karabiner-Elements&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;The keyboard shortcuts your preferred applications already use&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The mini keyboard does not need official Mac support or bundled software. macOS only needs to recognize its buttons as standard keyboard events, and Karabiner takes it from there.&lt;/p&gt;
&lt;h2&gt;The finished layout&lt;/h2&gt;
&lt;p&gt;Here is how the four lower buttons ended up mapped:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Button&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Middle-left (E)&lt;/td&gt;
&lt;td&gt;Control + M&lt;/td&gt;
&lt;td&gt;Open Cursor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Middle-right (B)&lt;/td&gt;
&lt;td&gt;Dictation media key&lt;/td&gt;
&lt;td&gt;Native macOS Dictation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bottom-left (F)&lt;/td&gt;
&lt;td&gt;Fn + Control + Shift + D&lt;/td&gt;
&lt;td&gt;Codex dictation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bottom-right (C)&lt;/td&gt;
&lt;td&gt;Command + D&lt;/td&gt;
&lt;td&gt;Claude&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Karabiner replaces these letters with the assigned shortcuts only when they arrive from the mini keyboard specifically. The same letters typed on the MacBook&amp;#39;s built-in keyboard keep working exactly as they always have.&lt;/p&gt;
&lt;h2&gt;Step 1: Connect the mini keyboard and record its raw output&lt;/h2&gt;
&lt;p&gt;Pair the device first, through &lt;strong&gt;System Settings → Bluetooth&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Once it&amp;#39;s connected, open a plain text editor and press every button on the keypad, noting exactly what each one types. My device produced plain letters with no special codes: the two top buttons typed D and A, the middle row typed E and B, and the bottom row typed F and C.&lt;/p&gt;
&lt;p&gt;Cheap mini keyboards commonly present themselves to macOS as ordinary keyboards rather than as true programmable macro pads. That&amp;#39;s completely fine for this project; Karabiner works from whatever raw keystrokes the device already sends.&lt;/p&gt;
&lt;h2&gt;Step 2: Install Karabiner-Elements&lt;/h2&gt;
&lt;p&gt;Download and install Karabiner-Elements from the link above. During installation, macOS will prompt for a few permissions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Input Monitoring&lt;/li&gt;
&lt;li&gt;Accessibility access&lt;/li&gt;
&lt;li&gt;The Karabiner Driver Extension&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Grant all three. Karabiner can display and edit rules without them, but it needs Input Monitoring and the driver extension to actually intercept keystrokes before they reach the rest of the system.&lt;/p&gt;
&lt;p&gt;If Karabiner shows &amp;quot;Waiting for a connection to the IOKit service,&amp;quot; check &lt;strong&gt;System Settings → General → Login Items &amp;amp; Extensions → Driver Extensions&lt;/strong&gt; and confirm the Karabiner virtual HID driver is enabled there. Restarting the Karabiner background services, or in some cases restarting the Mac entirely, is sometimes necessary right after the first install.&lt;/p&gt;
&lt;h2&gt;Step 3: Identify the mini keyboard&amp;#39;s device signature&lt;/h2&gt;
&lt;p&gt;Open Karabiner-EventViewer, included with the Karabiner-Elements install, and press a button on the mini keyboard. EventViewer will show:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Vendor ID&lt;/li&gt;
&lt;li&gt;Product ID&lt;/li&gt;
&lt;li&gt;The key code the button actually produces&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My keyboard identified itself as product &lt;code&gt;MINI-KEYBOARD&lt;/code&gt;, vendor ID &lt;code&gt;1452&lt;/code&gt;, product ID &lt;code&gt;556&lt;/code&gt;. Your device will almost certainly report different numbers, so pull your own values from EventViewer rather than reusing these.&lt;/p&gt;
&lt;p&gt;This device-specific matching step matters more than it might look. Without it, a rule that remaps the F key would remap F everywhere, including on your MacBook&amp;#39;s built-in keyboard, not just on the mini keypad.&lt;/p&gt;
&lt;h2&gt;Step 4: Enable Modify Events for the device&lt;/h2&gt;
&lt;p&gt;In Karabiner-Elements, open the &lt;strong&gt;Devices&lt;/strong&gt; tab, find the mini keyboard in the list, and enable &lt;strong&gt;Modify events&lt;/strong&gt; for it.&lt;/p&gt;
&lt;p&gt;Some inexpensive keypads register themselves with macOS as both a keyboard and a pointing device simultaneously. If Karabiner detects button presses in EventViewer but your rules never fire, check that event modification is enabled for the full composite device, not just the keyboard portion of it. My device needed identifiers matching this shape:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// File: identifiers used to match the composite device
{
  &amp;quot;is_keyboard&amp;quot;: true,
  &amp;quot;is_pointing_device&amp;quot;: true,
  &amp;quot;product_id&amp;quot;: 556,
  &amp;quot;vendor_id&amp;quot;: 1452
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 5: Design the shortcut mappings&lt;/h2&gt;
&lt;p&gt;Karabiner can turn one physical button press into any combination of keys, including modifier combinations your applications wouldn&amp;#39;t otherwise let you trigger from a single key.&lt;/p&gt;
&lt;p&gt;Before writing any Karabiner rules, test each target shortcut manually on the MacBook keyboard first. Confirming that Control+M actually opens the right panel in Cursor, or that Command+D actually triggers the intended Claude action, saves time over debugging the shortcut and the Karabiner rule at once. Once a shortcut is confirmed working manually, the mapping table above becomes the spec for the rule itself.&lt;/p&gt;
&lt;h2&gt;Step 6: Write the Karabiner Complex Modification&lt;/h2&gt;
&lt;p&gt;Karabiner rules live in the app&amp;#39;s Complex Modifications editor, but the underlying format is JSON, which makes it easy to write directly. Here is the complete rule for the four buttons on my device:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// File: karabiner_mini_keyboard_ai_shortcuts.json
{
  &amp;quot;description&amp;quot;: &amp;quot;MINI-KEYBOARD: AI and dictation shortcuts&amp;quot;,
  &amp;quot;manipulators&amp;quot;: [
    {
      &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,
      &amp;quot;conditions&amp;quot;: [
        {
          &amp;quot;type&amp;quot;: &amp;quot;device_if&amp;quot;,
          &amp;quot;identifiers&amp;quot;: [
            {
              &amp;quot;vendor_id&amp;quot;: 1452,
              &amp;quot;product_id&amp;quot;: 556
            }
          ]
        }
      ],
      &amp;quot;from&amp;quot;: {
        &amp;quot;key_code&amp;quot;: &amp;quot;e&amp;quot;
      },
      &amp;quot;to&amp;quot;: [
        {
          &amp;quot;key_code&amp;quot;: &amp;quot;m&amp;quot;,
          &amp;quot;modifiers&amp;quot;: [&amp;quot;left_control&amp;quot;]
        }
      ]
    },
    {
      &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,
      &amp;quot;conditions&amp;quot;: [
        {
          &amp;quot;type&amp;quot;: &amp;quot;device_if&amp;quot;,
          &amp;quot;identifiers&amp;quot;: [
            {
              &amp;quot;vendor_id&amp;quot;: 1452,
              &amp;quot;product_id&amp;quot;: 556
            }
          ]
        }
      ],
      &amp;quot;from&amp;quot;: {
        &amp;quot;key_code&amp;quot;: &amp;quot;b&amp;quot;
      },
      &amp;quot;to&amp;quot;: [
        {
          &amp;quot;consumer_key_code&amp;quot;: &amp;quot;dictation&amp;quot;
        }
      ]
    },
    {
      &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,
      &amp;quot;conditions&amp;quot;: [
        {
          &amp;quot;type&amp;quot;: &amp;quot;device_if&amp;quot;,
          &amp;quot;identifiers&amp;quot;: [
            {
              &amp;quot;vendor_id&amp;quot;: 1452,
              &amp;quot;product_id&amp;quot;: 556
            }
          ]
        }
      ],
      &amp;quot;from&amp;quot;: {
        &amp;quot;key_code&amp;quot;: &amp;quot;f&amp;quot;
      },
      &amp;quot;to&amp;quot;: [
        {
          &amp;quot;key_code&amp;quot;: &amp;quot;d&amp;quot;,
          &amp;quot;modifiers&amp;quot;: [
            &amp;quot;fn&amp;quot;,
            &amp;quot;left_control&amp;quot;,
            &amp;quot;left_shift&amp;quot;
          ]
        }
      ]
    },
    {
      &amp;quot;type&amp;quot;: &amp;quot;basic&amp;quot;,
      &amp;quot;conditions&amp;quot;: [
        {
          &amp;quot;type&amp;quot;: &amp;quot;device_if&amp;quot;,
          &amp;quot;identifiers&amp;quot;: [
            {
              &amp;quot;vendor_id&amp;quot;: 1452,
              &amp;quot;product_id&amp;quot;: 556
            }
          ]
        }
      ],
      &amp;quot;from&amp;quot;: {
        &amp;quot;key_code&amp;quot;: &amp;quot;c&amp;quot;
      },
      &amp;quot;to&amp;quot;: [
        {
          &amp;quot;key_code&amp;quot;: &amp;quot;d&amp;quot;,
          &amp;quot;modifiers&amp;quot;: [&amp;quot;left_command&amp;quot;]
        }
      ]
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each manipulator follows the same shape: a &lt;code&gt;device_if&lt;/code&gt; condition scoped to the exact vendor and product ID from Step 3, a &lt;code&gt;from&lt;/code&gt; key code matching the raw letter the button sends, and a &lt;code&gt;to&lt;/code&gt; block defining the actual shortcut to emit. The &lt;code&gt;device_if&lt;/code&gt; condition is what keeps this rule from affecting your MacBook keyboard at all. Replace the vendor ID, product ID, input key codes, and output shortcuts with the values that match your own hardware and applications, then import the rule through Karabiner-Elements&amp;#39; Complex Modifications tab.&lt;/p&gt;
&lt;h2&gt;Step 7: Test each mapping individually&lt;/h2&gt;
&lt;p&gt;Work through each button one at a time rather than testing all four at once:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Click into the application the button is meant to control.&lt;/li&gt;
&lt;li&gt;Press the mini-keyboard button once.&lt;/li&gt;
&lt;li&gt;Confirm the expected action actually starts.&lt;/li&gt;
&lt;li&gt;Confirm the original letter was not inserted anywhere.&lt;/li&gt;
&lt;li&gt;Confirm the same letter still types normally on the MacBook keyboard.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If a button keeps typing its original letter instead of triggering the shortcut, check Karabiner&amp;#39;s log for whether it describes the device as grabbed. Karabiner-EventViewer detecting button presses is a separate thing from Karabiner actually capturing the device; a rule only takes effect once the device is grabbed.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Does the mini keyboard need to advertise itself as a programmable macro pad?&lt;/strong&gt;
No. Karabiner works from whatever raw keyboard events macOS already receives from the device. A keypad that simply types plain letters is enough as a starting point.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why remap by vendor ID and product ID instead of just the key code?&lt;/strong&gt;
Matching only the key code would remap that letter everywhere, including on the built-in MacBook keyboard. Scoping each rule to the mini keyboard&amp;#39;s specific vendor and product ID keeps the remapping isolated to that one device.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What if Karabiner shows the button press in EventViewer but the rule never fires?&lt;/strong&gt;
Check the Devices tab and confirm Modify Events is enabled for the mini keyboard. Composite devices that register as both a keyboard and a pointing device sometimes need this toggle set explicitly before rules apply.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can one button trigger more than a simple two-key shortcut?&lt;/strong&gt;
Yes. The &lt;code&gt;to&lt;/code&gt; array in a Karabiner manipulator accepts any key code plus any combination of modifiers, so a single button press can reproduce shortcuts your applications only expose through multi-key combinations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens if I switch which AI tool a button controls later?&lt;/strong&gt;
Editing the rule&amp;#39;s &lt;code&gt;to&lt;/code&gt; block for that manipulator and reimporting it is all that&amp;#39;s needed. The mapping lives entirely in the JSON rule, not on the hardware, so reassigning a button takes a few minutes.&lt;/p&gt;
&lt;h2&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;A €25 mini keyboard combined with Karabiner-Elements produces a genuinely useful AI shortcut pad: one-touch dictation, dedicated buttons for Cursor, Claude, and Codex, and mappings that stay entirely under your own control rather than a vendor&amp;#39;s software. The device-matching step in the Complex Modification is what makes it safe to remap ordinary letters without disturbing your regular typing, and the same JSON rule can be edited in minutes whenever your toolset changes. The buttons that control Codex, Claude, and Cursor today can just as easily control a different editor, assistant, or automation tool tomorrow.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks, Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/build-25-euro-ai-shortcut-keyboard-mac-karabiner"/>
        </item>
        <item>
            <title><![CDATA[Remote Development Workstation: Build a Thin-Client PC]]></title>
            <description><![CDATA[<p>If you run several Cursor windows at once and want more screen space without buying an expensive laptop, move your development environment to a cloud VPS and turn your local machine into a display terminal. The remote server stores your repositories, runs your builds, and hosts your databases and containers. The local machine only needs enough power to render editor windows, terminals, and a browser. This split lets you build a capable multi-monitor workstation from cheap secondhand hardware, including a $25 graphics card that can drive four displays.</p>
<p>I run five Cursor windows across separate client and internal projects most days, and the bottleneck was never my local CPU. It was how much of my work I could keep visible at once. Once I moved my actual development environment onto a Hetzner server, I stopped caring what processor sat on my desk and started caring about how many monitors it could drive.</p>
<p>This guide walks through the architecture, what actually consumes local resources, and how to pick hardware, including a specific low-cost GPU that solves the multi-monitor problem cleanly.</p>
<h2>The Two-Machine Architecture</h2>
<p>The setup splits into a local client and a remote development server.</p>
<h3>Local client</h3>
<p>Your desk machine runs Ubuntu, your editor (Cursor or VS Code), SSH terminals, a browser, and communication apps. It also drives your physical monitors, keyboard, and mouse. Nothing here needs to be fast. It just needs to render windows reliably.</p>
<h3>Remote development server</h3>
<p>The VPS runs your Git repositories, your Node.js or Python runtime, your dev servers, type checking, builds, Docker containers, databases, Redis, and any AI agent terminal commands. This is where the actual work happens.</p>
<p>My remote machine is a Hetzner server with an AMD Ryzen 5 3600, 64 GB of RAM, and SSD storage. That&#39;s far more capacity than my local client will ever touch, because the client&#39;s job is display, not computation.</p>
<p>Remote SSH extensions in VS Code and Cursor install a small server component on the remote machine, which lets the editor interface stay local while every command you run executes on the remote filesystem. Your local machine becomes a terminal in the original sense of the word: a window into compute that lives elsewhere.</p>
<h2>Why This Fits AI Coding Specifically</h2>
<p>Traditional development tends to center on one active editor window where you write and review code continuously. AI-assisted development spreads that attention across several windows at once.</p>
<p>On a typical day I have Cursor open on a client website, an internal proposal system, my own business site, and a demo project, sometimes with a second worktree of another project alongside them. The limiting factor is how much I can supervise, not how fast I can type. A wider desktop surface directly increases how many active AI agents I can watch and steer at the same time.</p>
<p>One monitor holds the primary Cursor agent. Another shows the running site. A third holds logs and terminals. A fourth holds documentation or the CMS admin panel. Extra monitors here produce more practical value than a faster local CPU.</p>
<h2>What Actually Runs Locally</h2>
<p>Moving development to a VPS still leaves real work for the local machine. Cursor and VS Code are Electron apps, so every open window needs local memory for rendering, chat history, and background processes.</p>
<p>On my current setup, five Cursor windows together use roughly 3.5 to 3.8 GiB of local RAM:</p>
<table>
<thead>
<tr>
<th>Cursor workspace</th>
<th>Approximate RAM</th>
</tr>
</thead>
<tbody><tr>
<td>Client project</td>
<td>0.88 GiB</td>
</tr>
<tr>
<td>Proposal builder, window one</td>
<td>0.84 GiB</td>
</tr>
<tr>
<td>Business website</td>
<td>0.71 GiB</td>
</tr>
<tr>
<td>Proposal builder, window two</td>
<td>0.63 GiB</td>
</tr>
<tr>
<td>B2B demo project</td>
<td>0.58 GiB</td>
</tr>
</tbody></table>
<p>About 0.5 GiB of that is shared Cursor overhead distributed across windows, so each workspace&#39;s actual footprint runs closer to 0.47 to 0.77 GiB.</p>
<p>A realistic local memory budget looks like this:</p>
<table>
<thead>
<tr>
<th>Workload</th>
<th>Approximate RAM</th>
</tr>
</thead>
<tbody><tr>
<td>Five Cursor windows</td>
<td>3.5 to 4 GiB</td>
</tr>
<tr>
<td>Ubuntu desktop and services</td>
<td>1.5 to 2.5 GiB</td>
</tr>
<tr>
<td>Browser windows and previews</td>
<td>2 to 4 GiB</td>
</tr>
<tr>
<td>Terminals and utilities</td>
<td>0.5 to 1.5 GiB</td>
</tr>
<tr>
<td>Comfortable working total</td>
<td>8 to 12 GiB</td>
</tr>
</tbody></table>
<p>Ubuntu 26.04 LTS lists 6 GB as the minimum for a comfortable desktop, but that figure assumes light usage. Running several Electron apps alongside a dozen browser tabs pushes well past that. Here&#39;s where I landed on RAM tiers:</p>
<table>
<thead>
<tr>
<th>RAM</th>
<th>Verdict</th>
</tr>
</thead>
<tbody><tr>
<td>8 GB</td>
<td>Usable but restrictive</td>
</tr>
<tr>
<td>16 GB</td>
<td>Practical minimum</td>
</tr>
<tr>
<td>32 GB</td>
<td>Comfortable for heavy multitasking</td>
</tr>
<tr>
<td>32 GB+</td>
<td>Unnecessary for this setup</td>
</tr>
</tbody></table>
<p>The monitor count itself barely touches system memory. Framebuffers are cheap. Editor windows, browser tabs, and desktop apps are what eat RAM.</p>
<h2>The GPU Matters More Than the CPU</h2>
<p>The first questions to ask about a thin client aren&#39;t about processor speed. Ask instead how many independent displays it can drive, whether it accepts one or two graphics cards, whether it supports at least 16 GB of RAM, whether it has an SSD, and whether it has stable Ethernet.</p>
<p>A secondhand six-core Intel i5 from several generations back already handles terminals, editor UI, and browser previews without strain. The harder problem is driving four or more independent displays, since most integrated graphics chips top out at two or three monitors and laptops are often more restrictive still. Docks and USB adapters can extend that, but they add compression, extra cables, and their own reliability problems. A desktop with a dedicated multi-output GPU sidesteps all of it.</p>
<h2>The $25 Four-Monitor Graphics Card</h2>
<p>The NVIDIA Quadro P620 is a professional workstation card, not a gaming card, and its display setup is unusually well suited to this use case:</p>
<ul>
<li>Four Mini DisplayPort connectors</li>
<li>Four simultaneous displays</li>
<li>Low-profile, single-slot design</li>
<li>Maximum power draw around 40W</li>
<li>No separate PCIe power connector</li>
</ul>
<p>NVIDIA&#39;s own specs confirm the four Mini DisplayPort outputs, the single-slot form factor, and the 40W ceiling. I found secondhand units for around €25, which puts the card well below the price of most docking stations or multi-monitor USB adapter setups, and for a machine whose job is displaying terminals and editor windows rather than rendering games, that&#39;s plenty of headroom.</p>
<p>One warning: confirm you&#39;re buying a <strong>P620</strong> and not the older <strong>K620</strong>. The K620 ships with just one DisplayPort and one DVI output, so it won&#39;t get you anywhere near four monitors.</p>
<h2>Running Two Graphics Cards</h2>
<p>Two P620 cards work independently in the same machine, with no SLI bridge, no NVLink, and no cable connecting them. The first card drives monitors one through four, the second drives five through eight, and Ubuntu presents all of them as a single extended desktop.</p>
<p>For this workload the second PCIe slot doesn&#39;t need much bandwidth, so even a slot with fewer lanes works fine for rendering desktop applications. Two secondhand P620 cards can give you eight monitor outputs for around €50 in GPU cost, provided the host machine has two usable PCIe slots, room for two single-slot cards, matching brackets, sufficient power headroom, and adequate airflow around both cards. Both cards run on the same NVIDIA driver.</p>
<p>This route is often easier to pull off than hunting for a rare six-output card, since four-output cards like the P620 are common on the secondhand market.</p>
<h2>Choosing the Host Computer</h2>
<p>Check what you already own before buying anything. An older business desktop is often enough:</p>
<ul>
<li>Dell OptiPlex</li>
<li>HP EliteDesk</li>
<li>Lenovo ThinkCentre</li>
<li>A used custom-built tower</li>
<li>Older office workstations</li>
</ul>
<p>The CPU can be modest. A fourth-generation Intel i5 will do the job, and something like an eighth-generation i5-8500 gives you more local headroom than you&#39;ll use.</p>
<p><strong>16 GB of RAM</strong> is the target. Eight gigabytes technically works, but five Cursor windows plus Ubuntu plus a browser will already push against that ceiling. DDR4 is cheap now, so bumping an 8 GB office PC to 16 GB usually costs very little.</p>
<p><strong>An SSD</strong> matters even though your repositories live remotely, because the local machine still writes application data, browser cache, editor state, logs, and swap. A 250 GB SSD is plenty, since Docker data and large repos stay on the server.</p>
<p><strong>PCIe slots</strong> determine your monitor ceiling. One P620 covers four monitors; two cover eight, assuming the board and case can physically accept them. A second full-length slot doesn&#39;t need to run at x16, since physical fit matters more than bandwidth here.</p>
<p><strong>Case size</strong> rules cards in or out. Towers and small-form-factor cases generally accept graphics cards; Mini, Micro, and Tiny form factors usually don&#39;t have standard PCIe slots at all. Small-form-factor machines need low-profile brackets, towers need full-height brackets, and secondhand cards sometimes ship with only one bracket type, so check before buying.</p>
<p><strong>Power supply</strong> headroom should cover roughly 40W per card, so two cards add about 80W to the system&#39;s peak draw. A standard tower PSU handles that without issue, though compact office machines running 180W or 200W proprietary supplies are worth checking. The P620 needs no separate GPU power cable, which makes it drop into office desktops without a PSU upgrade.</p>
<h2>Example Build</h2>
<table>
<thead>
<tr>
<th>Component</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td>Computer</td>
<td>Used Dell, HP, or Lenovo office desktop</td>
</tr>
<tr>
<td>Processor</td>
<td>Intel i5-6500, i5-7500, i5-8500, or similar</td>
</tr>
<tr>
<td>Memory</td>
<td>16 GB DDR4</td>
</tr>
<tr>
<td>Storage</td>
<td>250 GB SSD</td>
</tr>
<tr>
<td>Graphics</td>
<td>NVIDIA Quadro P620</td>
</tr>
<tr>
<td>Displays</td>
<td>Four DisplayPort monitors</td>
</tr>
<tr>
<td>OS</td>
<td>Ubuntu 26.04 LTS</td>
</tr>
<tr>
<td>Dev environment</td>
<td>Cursor over Remote SSH</td>
</tr>
<tr>
<td>Network</td>
<td>Wired Ethernet</td>
</tr>
</tbody></table>
<p>Going to six or eight monitors just means a board with two usable PCIe slots, a second P620, and a few more Mini DisplayPort cables. The CPU stays the same.</p>
<h2>Monitor Cables</h2>
<p>The P620 outputs Mini DisplayPort, so the cleanest path is a direct Mini DisplayPort to DisplayPort cable rather than an adapter chain. Each adapter you add is another point where resolution or refresh rate can misbehave, and passive Mini DisplayPort to HDMI cables in particular carry their own resolution limits.</p>
<p>Before ordering cables, check your monitor&#39;s input type, the resolution and refresh rate you want, whether the cable actually supports that combination, and whether you need an active adapter. For coding work, 60Hz is enough. Higher refresh rates make scrolling feel smoother but add nothing essential to this setup.</p>
<h2>Setting Up the Local Client</h2>
<p>Ubuntu is a good fit here because the local machine doesn&#39;t need a large software ecosystem. It only needs NVIDIA drivers, your editor, OpenSSH, a browser, a password manager, and whatever display arrangement tool you prefer.</p>
<p>Ubuntu 26.04 LTS asks for a 2GHz dual-core CPU, 6 GB of RAM, and 25 GB of storage for a comfortable install, and a 16 GB machine will run the standard desktop without trouble. The release also includes NVIDIA-related improvements aimed at smoother GNOME performance with NVIDIA drivers.</p>
<p>Keep the local install lean. Skip local databases, container runtimes, and language toolchains unless you genuinely need them there, since every local dependency you add pulls the client back toward doing work it shouldn&#39;t be doing.</p>
<h2>Configuring the SSH Connection</h2>
<p>Create a host alias in <code>~/.ssh/config</code>:</p>
<pre><code class="language-text"># File: ~/.ssh/config
Host development
    HostName your-server.example.com
    User developer
    IdentityFile ~/.ssh/id_ed25519
    ServerAliveInterval 30
    ServerAliveCountMax 3
</code></pre>
<p>Connect with:</p>
<pre><code class="language-bash">ssh development
</code></pre>
<p>This alias means the editor&#39;s Remote SSH connection picker just needs the name <code>development</code>, and you can open your project directory directly on the server from there.</p>
<p>In Cursor or VS Code, select the Remote SSH connection and open the project folder on the server. The remote folder behaves like a normal workspace, but every command you run in its integrated terminal executes on the remote machine. VS Code&#39;s Remote SSH documentation describes this as opening a remote folder while keeping the editor&#39;s usual features intact.</p>
<h2>Accessing Website Previews</h2>
<p>Say your dev server runs a Next.js app on port 3000:</p>
<pre><code class="language-bash">pnpm dev
</code></pre>
<p>You don&#39;t need to expose that port publicly. Tunnel it through SSH instead:</p>
<pre><code class="language-bash">ssh -L 3000:localhost:3000 development
</code></pre>
<p>Then open <code>http://localhost:3000</code> in your local browser. Traffic runs through the SSH tunnel to the remote server, so nothing is exposed beyond your own connection.</p>
<p>For multiple projects, map each to its own local port:</p>
<pre><code class="language-bash">ssh \
  -L 3001:localhost:3000 \
  -L 3002:localhost:3001 \
  -L 3003:localhost:3002 \
  development
</code></pre>
<p>Some Remote SSH implementations detect and forward dev server ports automatically, so check your editor&#39;s behavior before setting up manual tunnels for every project.</p>
<h2>Security Considerations</h2>
<p>Your remote server now holds your active development environments, so locking it down deserves real attention. Use SSH keys instead of passwords, disable direct root login, and run everything under a dedicated non-root user. Keep the OS patched, restrict exposed ports with a firewall, and back up repositories and persistent data on a schedule. Separate production credentials from development credentials, encrypt local storage in case the client machine is lost or stolen, and consider routing the connection through a VPN or private network.</p>
<p>The client itself shouldn&#39;t become a long-term home for copied credentials or environment files. One benefit of this architecture is that replacing the local machine is simple: install the editor, restore your SSH key securely, and reconnect. Nothing about your development environment needs rebuilding.</p>
<h2>Network Latency Over Raw Bandwidth</h2>
<p>Remote development doesn&#39;t require heavy download bandwidth, since the editor exchanges filesystem operations, terminal output, and extension messages rather than streaming video of a remote desktop. Latency and connection stability carry far more weight.</p>
<p>A wired Ethernet connection suits a fixed workstation well, and a stable fibre or cable line will make a nearby server feel close to local for everyday editing. Things get uncomfortable when the connection drops frequently, latency runs high, a repository operation floods the terminal with output, the server sits far away geographically, remote storage is slow, or the remote editor process gets overloaded. Picking a data centre in your own region, rather than the cheapest available, avoids most of this.</p>
<h2>FAQ</h2>
<p><strong>Do I need a powerful CPU on the local machine?</strong>
No. A secondhand six-core Intel i5 from several generations back handles terminals, editor rendering, and browser previews without strain, since the heavy computation runs on the remote server.</p>
<p><strong>How much RAM does the local client actually need?</strong>
16 GB is the practical minimum for running several Cursor or VS Code windows alongside a browser and desktop environment. 8 GB works but leaves little headroom.</p>
<p><strong>Can I use a gaming GPU instead of the P620?</strong>
You can, but most gaming GPUs support two or three outputs and cost far more than a $25 workstation card built for exactly this display count.</p>
<p><strong>Does the P620 need extra power cables?</strong>
No. It draws a maximum of about 40W and pulls power directly from the PCIe slot, which is part of why it fits into office desktops without a PSU upgrade.</p>
<p><strong>What if my internet connection is slow?</strong>
Bandwidth matters less than latency and stability here. A modest connection with low latency to a nearby server will feel more responsive than a fast connection to a distant one.</p>
<h2>Wrapping Up</h2>
<p>Splitting your development environment across a lightweight local client and a capable remote VPS turns an old office desktop and a $25 graphics card into a genuine multi-monitor AI coding workstation. The Hetzner server handles repositories, builds, and containers, while the local machine&#39;s only job is rendering windows reliably across as many displays as you can attach to it. For anyone running several AI coding agents across multiple projects at once, that display space tends to matter more than another CPU upgrade ever would.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/remote-development-workstation-cloud-vps-multi-monitor</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/remote-development-workstation-cloud-vps-multi-monitor</guid>
            <category><![CDATA[AI]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Fri, 17 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;If you run several Cursor windows at once and want more screen space without buying an expensive laptop, move your development environment to a cloud VPS and turn your local machine into a display terminal. The remote server stores your repositories, runs your builds, and hosts your databases and containers. The local machine only needs enough power to render editor windows, terminals, and a browser. This split lets you build a capable multi-monitor workstation from cheap secondhand hardware, including a $25 graphics card that can drive four displays.&lt;/p&gt;
&lt;p&gt;I run five Cursor windows across separate client and internal projects most days, and the bottleneck was never my local CPU. It was how much of my work I could keep visible at once. Once I moved my actual development environment onto a Hetzner server, I stopped caring what processor sat on my desk and started caring about how many monitors it could drive.&lt;/p&gt;
&lt;p&gt;This guide walks through the architecture, what actually consumes local resources, and how to pick hardware, including a specific low-cost GPU that solves the multi-monitor problem cleanly.&lt;/p&gt;
&lt;h2&gt;The Two-Machine Architecture&lt;/h2&gt;
&lt;p&gt;The setup splits into a local client and a remote development server.&lt;/p&gt;
&lt;h3&gt;Local client&lt;/h3&gt;
&lt;p&gt;Your desk machine runs Ubuntu, your editor (Cursor or VS Code), SSH terminals, a browser, and communication apps. It also drives your physical monitors, keyboard, and mouse. Nothing here needs to be fast. It just needs to render windows reliably.&lt;/p&gt;
&lt;h3&gt;Remote development server&lt;/h3&gt;
&lt;p&gt;The VPS runs your Git repositories, your Node.js or Python runtime, your dev servers, type checking, builds, Docker containers, databases, Redis, and any AI agent terminal commands. This is where the actual work happens.&lt;/p&gt;
&lt;p&gt;My remote machine is a Hetzner server with an AMD Ryzen 5 3600, 64 GB of RAM, and SSD storage. That&amp;#39;s far more capacity than my local client will ever touch, because the client&amp;#39;s job is display, not computation.&lt;/p&gt;
&lt;p&gt;Remote SSH extensions in VS Code and Cursor install a small server component on the remote machine, which lets the editor interface stay local while every command you run executes on the remote filesystem. Your local machine becomes a terminal in the original sense of the word: a window into compute that lives elsewhere.&lt;/p&gt;
&lt;h2&gt;Why This Fits AI Coding Specifically&lt;/h2&gt;
&lt;p&gt;Traditional development tends to center on one active editor window where you write and review code continuously. AI-assisted development spreads that attention across several windows at once.&lt;/p&gt;
&lt;p&gt;On a typical day I have Cursor open on a client website, an internal proposal system, my own business site, and a demo project, sometimes with a second worktree of another project alongside them. The limiting factor is how much I can supervise, not how fast I can type. A wider desktop surface directly increases how many active AI agents I can watch and steer at the same time.&lt;/p&gt;
&lt;p&gt;One monitor holds the primary Cursor agent. Another shows the running site. A third holds logs and terminals. A fourth holds documentation or the CMS admin panel. Extra monitors here produce more practical value than a faster local CPU.&lt;/p&gt;
&lt;h2&gt;What Actually Runs Locally&lt;/h2&gt;
&lt;p&gt;Moving development to a VPS still leaves real work for the local machine. Cursor and VS Code are Electron apps, so every open window needs local memory for rendering, chat history, and background processes.&lt;/p&gt;
&lt;p&gt;On my current setup, five Cursor windows together use roughly 3.5 to 3.8 GiB of local RAM:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cursor workspace&lt;/th&gt;
&lt;th&gt;Approximate RAM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Client project&lt;/td&gt;
&lt;td&gt;0.88 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proposal builder, window one&lt;/td&gt;
&lt;td&gt;0.84 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business website&lt;/td&gt;
&lt;td&gt;0.71 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proposal builder, window two&lt;/td&gt;
&lt;td&gt;0.63 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B2B demo project&lt;/td&gt;
&lt;td&gt;0.58 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;About 0.5 GiB of that is shared Cursor overhead distributed across windows, so each workspace&amp;#39;s actual footprint runs closer to 0.47 to 0.77 GiB.&lt;/p&gt;
&lt;p&gt;A realistic local memory budget looks like this:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Approximate RAM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Five Cursor windows&lt;/td&gt;
&lt;td&gt;3.5 to 4 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ubuntu desktop and services&lt;/td&gt;
&lt;td&gt;1.5 to 2.5 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser windows and previews&lt;/td&gt;
&lt;td&gt;2 to 4 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terminals and utilities&lt;/td&gt;
&lt;td&gt;0.5 to 1.5 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comfortable working total&lt;/td&gt;
&lt;td&gt;8 to 12 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Ubuntu 26.04 LTS lists 6 GB as the minimum for a comfortable desktop, but that figure assumes light usage. Running several Electron apps alongside a dozen browser tabs pushes well past that. Here&amp;#39;s where I landed on RAM tiers:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;8 GB&lt;/td&gt;
&lt;td&gt;Usable but restrictive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 GB&lt;/td&gt;
&lt;td&gt;Practical minimum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32 GB&lt;/td&gt;
&lt;td&gt;Comfortable for heavy multitasking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32 GB+&lt;/td&gt;
&lt;td&gt;Unnecessary for this setup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The monitor count itself barely touches system memory. Framebuffers are cheap. Editor windows, browser tabs, and desktop apps are what eat RAM.&lt;/p&gt;
&lt;h2&gt;The GPU Matters More Than the CPU&lt;/h2&gt;
&lt;p&gt;The first questions to ask about a thin client aren&amp;#39;t about processor speed. Ask instead how many independent displays it can drive, whether it accepts one or two graphics cards, whether it supports at least 16 GB of RAM, whether it has an SSD, and whether it has stable Ethernet.&lt;/p&gt;
&lt;p&gt;A secondhand six-core Intel i5 from several generations back already handles terminals, editor UI, and browser previews without strain. The harder problem is driving four or more independent displays, since most integrated graphics chips top out at two or three monitors and laptops are often more restrictive still. Docks and USB adapters can extend that, but they add compression, extra cables, and their own reliability problems. A desktop with a dedicated multi-output GPU sidesteps all of it.&lt;/p&gt;
&lt;h2&gt;The $25 Four-Monitor Graphics Card&lt;/h2&gt;
&lt;p&gt;The NVIDIA Quadro P620 is a professional workstation card, not a gaming card, and its display setup is unusually well suited to this use case:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Four Mini DisplayPort connectors&lt;/li&gt;
&lt;li&gt;Four simultaneous displays&lt;/li&gt;
&lt;li&gt;Low-profile, single-slot design&lt;/li&gt;
&lt;li&gt;Maximum power draw around 40W&lt;/li&gt;
&lt;li&gt;No separate PCIe power connector&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;NVIDIA&amp;#39;s own specs confirm the four Mini DisplayPort outputs, the single-slot form factor, and the 40W ceiling. I found secondhand units for around €25, which puts the card well below the price of most docking stations or multi-monitor USB adapter setups, and for a machine whose job is displaying terminals and editor windows rather than rendering games, that&amp;#39;s plenty of headroom.&lt;/p&gt;
&lt;p&gt;One warning: confirm you&amp;#39;re buying a &lt;strong&gt;P620&lt;/strong&gt; and not the older &lt;strong&gt;K620&lt;/strong&gt;. The K620 ships with just one DisplayPort and one DVI output, so it won&amp;#39;t get you anywhere near four monitors.&lt;/p&gt;
&lt;h2&gt;Running Two Graphics Cards&lt;/h2&gt;
&lt;p&gt;Two P620 cards work independently in the same machine, with no SLI bridge, no NVLink, and no cable connecting them. The first card drives monitors one through four, the second drives five through eight, and Ubuntu presents all of them as a single extended desktop.&lt;/p&gt;
&lt;p&gt;For this workload the second PCIe slot doesn&amp;#39;t need much bandwidth, so even a slot with fewer lanes works fine for rendering desktop applications. Two secondhand P620 cards can give you eight monitor outputs for around €50 in GPU cost, provided the host machine has two usable PCIe slots, room for two single-slot cards, matching brackets, sufficient power headroom, and adequate airflow around both cards. Both cards run on the same NVIDIA driver.&lt;/p&gt;
&lt;p&gt;This route is often easier to pull off than hunting for a rare six-output card, since four-output cards like the P620 are common on the secondhand market.&lt;/p&gt;
&lt;h2&gt;Choosing the Host Computer&lt;/h2&gt;
&lt;p&gt;Check what you already own before buying anything. An older business desktop is often enough:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dell OptiPlex&lt;/li&gt;
&lt;li&gt;HP EliteDesk&lt;/li&gt;
&lt;li&gt;Lenovo ThinkCentre&lt;/li&gt;
&lt;li&gt;A used custom-built tower&lt;/li&gt;
&lt;li&gt;Older office workstations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The CPU can be modest. A fourth-generation Intel i5 will do the job, and something like an eighth-generation i5-8500 gives you more local headroom than you&amp;#39;ll use.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;16 GB of RAM&lt;/strong&gt; is the target. Eight gigabytes technically works, but five Cursor windows plus Ubuntu plus a browser will already push against that ceiling. DDR4 is cheap now, so bumping an 8 GB office PC to 16 GB usually costs very little.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;An SSD&lt;/strong&gt; matters even though your repositories live remotely, because the local machine still writes application data, browser cache, editor state, logs, and swap. A 250 GB SSD is plenty, since Docker data and large repos stay on the server.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PCIe slots&lt;/strong&gt; determine your monitor ceiling. One P620 covers four monitors; two cover eight, assuming the board and case can physically accept them. A second full-length slot doesn&amp;#39;t need to run at x16, since physical fit matters more than bandwidth here.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Case size&lt;/strong&gt; rules cards in or out. Towers and small-form-factor cases generally accept graphics cards; Mini, Micro, and Tiny form factors usually don&amp;#39;t have standard PCIe slots at all. Small-form-factor machines need low-profile brackets, towers need full-height brackets, and secondhand cards sometimes ship with only one bracket type, so check before buying.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Power supply&lt;/strong&gt; headroom should cover roughly 40W per card, so two cards add about 80W to the system&amp;#39;s peak draw. A standard tower PSU handles that without issue, though compact office machines running 180W or 200W proprietary supplies are worth checking. The P620 needs no separate GPU power cable, which makes it drop into office desktops without a PSU upgrade.&lt;/p&gt;
&lt;h2&gt;Example Build&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Computer&lt;/td&gt;
&lt;td&gt;Used Dell, HP, or Lenovo office desktop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Processor&lt;/td&gt;
&lt;td&gt;Intel i5-6500, i5-7500, i5-8500, or similar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;16 GB DDR4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;250 GB SSD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Graphics&lt;/td&gt;
&lt;td&gt;NVIDIA Quadro P620&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Displays&lt;/td&gt;
&lt;td&gt;Four DisplayPort monitors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;Ubuntu 26.04 LTS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev environment&lt;/td&gt;
&lt;td&gt;Cursor over Remote SSH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;Wired Ethernet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Going to six or eight monitors just means a board with two usable PCIe slots, a second P620, and a few more Mini DisplayPort cables. The CPU stays the same.&lt;/p&gt;
&lt;h2&gt;Monitor Cables&lt;/h2&gt;
&lt;p&gt;The P620 outputs Mini DisplayPort, so the cleanest path is a direct Mini DisplayPort to DisplayPort cable rather than an adapter chain. Each adapter you add is another point where resolution or refresh rate can misbehave, and passive Mini DisplayPort to HDMI cables in particular carry their own resolution limits.&lt;/p&gt;
&lt;p&gt;Before ordering cables, check your monitor&amp;#39;s input type, the resolution and refresh rate you want, whether the cable actually supports that combination, and whether you need an active adapter. For coding work, 60Hz is enough. Higher refresh rates make scrolling feel smoother but add nothing essential to this setup.&lt;/p&gt;
&lt;h2&gt;Setting Up the Local Client&lt;/h2&gt;
&lt;p&gt;Ubuntu is a good fit here because the local machine doesn&amp;#39;t need a large software ecosystem. It only needs NVIDIA drivers, your editor, OpenSSH, a browser, a password manager, and whatever display arrangement tool you prefer.&lt;/p&gt;
&lt;p&gt;Ubuntu 26.04 LTS asks for a 2GHz dual-core CPU, 6 GB of RAM, and 25 GB of storage for a comfortable install, and a 16 GB machine will run the standard desktop without trouble. The release also includes NVIDIA-related improvements aimed at smoother GNOME performance with NVIDIA drivers.&lt;/p&gt;
&lt;p&gt;Keep the local install lean. Skip local databases, container runtimes, and language toolchains unless you genuinely need them there, since every local dependency you add pulls the client back toward doing work it shouldn&amp;#39;t be doing.&lt;/p&gt;
&lt;h2&gt;Configuring the SSH Connection&lt;/h2&gt;
&lt;p&gt;Create a host alias in &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;# File: ~/.ssh/config
Host development
    HostName your-server.example.com
    User developer
    IdentityFile ~/.ssh/id_ed25519
    ServerAliveInterval 30
    ServerAliveCountMax 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Connect with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh development
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This alias means the editor&amp;#39;s Remote SSH connection picker just needs the name &lt;code&gt;development&lt;/code&gt;, and you can open your project directory directly on the server from there.&lt;/p&gt;
&lt;p&gt;In Cursor or VS Code, select the Remote SSH connection and open the project folder on the server. The remote folder behaves like a normal workspace, but every command you run in its integrated terminal executes on the remote machine. VS Code&amp;#39;s Remote SSH documentation describes this as opening a remote folder while keeping the editor&amp;#39;s usual features intact.&lt;/p&gt;
&lt;h2&gt;Accessing Website Previews&lt;/h2&gt;
&lt;p&gt;Say your dev server runs a Next.js app on port 3000:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You don&amp;#39;t need to expose that port publicly. Tunnel it through SSH instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh -L 3000:localhost:3000 development
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then open &lt;code&gt;http://localhost:3000&lt;/code&gt; in your local browser. Traffic runs through the SSH tunnel to the remote server, so nothing is exposed beyond your own connection.&lt;/p&gt;
&lt;p&gt;For multiple projects, map each to its own local port:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh \
  -L 3001:localhost:3000 \
  -L 3002:localhost:3001 \
  -L 3003:localhost:3002 \
  development
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Some Remote SSH implementations detect and forward dev server ports automatically, so check your editor&amp;#39;s behavior before setting up manual tunnels for every project.&lt;/p&gt;
&lt;h2&gt;Security Considerations&lt;/h2&gt;
&lt;p&gt;Your remote server now holds your active development environments, so locking it down deserves real attention. Use SSH keys instead of passwords, disable direct root login, and run everything under a dedicated non-root user. Keep the OS patched, restrict exposed ports with a firewall, and back up repositories and persistent data on a schedule. Separate production credentials from development credentials, encrypt local storage in case the client machine is lost or stolen, and consider routing the connection through a VPN or private network.&lt;/p&gt;
&lt;p&gt;The client itself shouldn&amp;#39;t become a long-term home for copied credentials or environment files. One benefit of this architecture is that replacing the local machine is simple: install the editor, restore your SSH key securely, and reconnect. Nothing about your development environment needs rebuilding.&lt;/p&gt;
&lt;h2&gt;Network Latency Over Raw Bandwidth&lt;/h2&gt;
&lt;p&gt;Remote development doesn&amp;#39;t require heavy download bandwidth, since the editor exchanges filesystem operations, terminal output, and extension messages rather than streaming video of a remote desktop. Latency and connection stability carry far more weight.&lt;/p&gt;
&lt;p&gt;A wired Ethernet connection suits a fixed workstation well, and a stable fibre or cable line will make a nearby server feel close to local for everyday editing. Things get uncomfortable when the connection drops frequently, latency runs high, a repository operation floods the terminal with output, the server sits far away geographically, remote storage is slow, or the remote editor process gets overloaded. Picking a data centre in your own region, rather than the cheapest available, avoids most of this.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Do I need a powerful CPU on the local machine?&lt;/strong&gt;
No. A secondhand six-core Intel i5 from several generations back handles terminals, editor rendering, and browser previews without strain, since the heavy computation runs on the remote server.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How much RAM does the local client actually need?&lt;/strong&gt;
16 GB is the practical minimum for running several Cursor or VS Code windows alongside a browser and desktop environment. 8 GB works but leaves little headroom.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can I use a gaming GPU instead of the P620?&lt;/strong&gt;
You can, but most gaming GPUs support two or three outputs and cost far more than a $25 workstation card built for exactly this display count.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does the P620 need extra power cables?&lt;/strong&gt;
No. It draws a maximum of about 40W and pulls power directly from the PCIe slot, which is part of why it fits into office desktops without a PSU upgrade.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What if my internet connection is slow?&lt;/strong&gt;
Bandwidth matters less than latency and stability here. A modest connection with low latency to a nearby server will feel more responsive than a fast connection to a distant one.&lt;/p&gt;
&lt;h2&gt;Wrapping Up&lt;/h2&gt;
&lt;p&gt;Splitting your development environment across a lightweight local client and a capable remote VPS turns an old office desktop and a $25 graphics card into a genuine multi-monitor AI coding workstation. The Hetzner server handles repositories, builds, and containers, while the local machine&amp;#39;s only job is rendering windows reliably across as many displays as you can attach to it. For anyone running several AI coding agents across multiple projects at once, that display space tends to matter more than another CPU upgrade ever would.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/remote-development-workstation-cloud-vps-multi-monitor"/>
        </item>
        <item>
            <title><![CDATA[Infisical CLI Secrets Workflow: Complete Next.js Guide]]></title>
            <description><![CDATA[<p>Infisical CLI lets you inject secrets straight into a running process instead of writing them to a <code>.env</code> file, and that one property shapes almost every command in this guide. Pin the CLI version, link your project once, upload your existing <code>.env</code> as a starting point, then switch to <code>infisical run</code> for local development, CI builds, and production runtime. Version <code>0.43.84</code> has one quirk worth knowing before you touch it: <code>--silent</code> does not stop the <code>secrets set</code> command from printing a result table full of secret values, so every upload command in this guide redirects output to <code>/dev/null</code>. This article walks through the full path, from a fresh clone to a rotated production credential, using a Next.js and Payload CMS project as the running example.</p>
<p>I set this up for a client project that runs Payload CMS on Next.js across dev, staging, and production, with GitHub Actions building the Docker image and a Compose file handling runtime. The team had been passing <code>.env</code> files around in Slack before this, which is exactly the failure mode Infisical exists to prevent. What follows is the workflow that replaced it, including the two mistakes that cost me the most time: assuming <code>--silent</code> actually silenced the CLI, and pointing a CI runner at <code>localhost:8085</code>.</p>
<h2>Why process injection instead of a <code>.env</code> file</h2>
<p>A <code>.env</code> file sitting on disk is a file that can get committed, screenshotted, or copied into a support ticket. <code>infisical run -- &lt;command&gt;</code> skips that step by fetching secrets and setting them as environment variables directly on the child process. The values live in memory for that process only. Reach for <code>infisical export --output-file</code> when you genuinely need a local file, such as feeding a tool that only reads dotenv files, and treat that as the exception rather than the default.</p>
<h2>Platform configuration</h2>
<table>
<thead>
<tr>
<th>Setting</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Local Infisical URL</td>
<td><code>http://localhost:8085</code></td>
</tr>
<tr>
<td>Organization</td>
<td><code>Admin Org</code></td>
</tr>
<tr>
<td>Project</td>
<td><code>Website Platform</code></td>
</tr>
<tr>
<td>Project/workspace ID</td>
<td><code>00000000-0000-0000-0000-000000000000</code> (yours will differ)</td>
</tr>
<tr>
<td>Local project configuration</td>
<td><code>.infisical.json</code></td>
</tr>
<tr>
<td>Supported environments</td>
<td><code>dev</code>, <code>staging</code>, <code>prod</code></td>
</tr>
<tr>
<td>Default secret path</td>
<td><code>/</code></td>
</tr>
<tr>
<td>Pinned CLI version</td>
<td><code>0.43.84</code></td>
</tr>
</tbody></table>
<p><code>localhost:8085</code> is a developer-facing local forward. CI runners and containers cannot reach it because <code>localhost</code> always resolves to the current host. Server bootstrap files need the approved HTTPS Infisical hostname instead, which shows up later in the CI and runtime sections.</p>
<h2>Security rules to follow from the start</h2>
<p>A few habits prevent most of the incidents that happen with secrets tooling:</p>
<ul>
<li>Never commit <code>.env</code>, machine client secrets, access tokens, or exported values.</li>
<li>Use a personal Infisical account for development, and separate read-only Universal Auth machine identities for staging CI, production CI, and production runtime.</li>
<li>Keep development values out of staging and production; provision each environment independently.</li>
<li>Redirect every <code>secrets set</code> and <code>export</code> command&#39;s output, because CLI <code>0.43.84</code> prints values even with <code>--silent</code> set.</li>
<li>Skip shell tracing (<code>set -x</code>) anywhere near a secret command.</li>
<li>Rotate a credential immediately if it appears in a terminal transcript, CI log, screenshot, ticket, or chat message.</li>
</ul>
<h2>1. Install the CLI</h2>
<p>Pin the version for reproducibility across every machine that touches the project.</p>
<pre><code class="language-bash"># Persistent installation
pnpm add --global @infisical/cli@0.43.84
infisical --version
</code></pre>
<p>Expected output:</p>
<pre><code class="language-text">infisical version 0.43.84
</code></pre>
<p>For a one-off run without a global install:</p>
<pre><code class="language-bash">pnpm dlx @infisical/cli@0.43.84 --version
</code></pre>
<p>Swap <code>infisical</code> for <code>pnpm dlx @infisical/cli@0.43.84</code> in any command below if you&#39;d rather not install globally.</p>
<h2>2. Authenticate</h2>
<p>Start the local Infisical forward, then log in:</p>
<pre><code class="language-bash">infisical login --domain=http://localhost:8085
</code></pre>
<p>This opens a browser callback. Complete it with your personal account. The CLI stores the session in its local credential vault and never writes the login token into the repository.</p>
<p>Confirm the session is active:</p>
<pre><code class="language-bash">infisical login status --domain=http://localhost:8085
</code></pre>
<p>If the browser callback doesn&#39;t fire on its own, open the URL the CLI prints in the terminal.</p>
<h2>3. Link the repository</h2>
<p>A checked-in <code>.infisical.json</code> links the repo to the right project and defaults to <code>dev</code>. To recreate that linkage from scratch:</p>
<pre><code class="language-bash">infisical init --domain=http://localhost:8085
</code></pre>
<p>Pick your organization, then your project, from the prompts. The resulting file holds project metadata only:</p>
<pre><code class="language-json">{
  &quot;workspaceId&quot;: &quot;00000000-0000-0000-0000-000000000000&quot;,
  &quot;defaultEnvironment&quot;: &quot;dev&quot;,
  &quot;gitBranchToEnvironmentMapping&quot;: null
}
</code></pre>
<h2>4. Upload an existing <code>.env</code> to <code>dev</code></h2>
<p>Check which keys you&#39;re about to upload before you run anything, since this command shows names only:</p>
<pre><code class="language-bash">awk -F= &#39;/^[A-Za-z_][A-Za-z0-9_]*=/{print $1}&#39; .env | sort
</code></pre>
<p>Then upload every valid entry as a shared secret at the root path:</p>
<pre><code class="language-bash">infisical secrets set \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --type=shared \
  --file=.env \
  --silent \
  &gt;/dev/null
</code></pre>
<p>This creates any missing keys and updates the ones that already exist, including empty values. Comment lines in the source file are skipped automatically. The output redirect matters here: without it, the terminal fills with a table of your secret values despite <code>--silent</code> being set. Keep dev, staging, and production values independent; don&#39;t push the same <code>.env</code> into more than one environment.</p>
<h2>5. Verify an upload without exposing values</h2>
<p>Injecting secrets into a short Node script and checking for presence is safer than printing them:</p>
<pre><code class="language-bash">infisical run \
  --domain=http://localhost:8085 \
  --env=dev \
  --silent \
  -- node -e &#39;
    const expected = [
      &quot;DATABASE_URL&quot;,
      &quot;NEXT_PUBLIC_SERVER_URL&quot;,
      &quot;PAYLOAD_SECRET&quot;,
      &quot;DEFAULT_SITE_SLUG&quot;,
      &quot;SITE_HOST_MAP&quot;,
      &quot;INTEGRATIONS_MODE&quot;,
    ]
    const missing = expected.filter((key) =&gt; !process.env[key])
    console.log(JSON.stringify({
      injection: &quot;ok&quot;,
      expected: expected.length,
      present: expected.length - missing.length,
      missing,
    }))
    process.exit(missing.length ? 2 : 0)
  &#39;
</code></pre>
<p>Steer clear of <code>printenv</code>, <code>env</code>, <code>infisical secrets --output=json</code>, or piping an export to standard output for this check. Each of those prints the actual values.</p>
<h2>6. Run the application with process-only injection</h2>
<p>For day-to-day development:</p>
<pre><code class="language-bash">infisical run \
  --domain=http://localhost:8085 \
  --env=dev \
  -- pnpm dev
</code></pre>
<p>If your <code>package.json</code> wraps this in a script (for example <code>pnpm dev:infisical</code>), use that instead. The same pattern works for any command that needs environment access:</p>
<pre><code class="language-bash"># Seed development data
infisical run \
  --domain=http://localhost:8085 \
  --env=dev \
  -- pnpm db:seed

# Run catalog validation with the same environment
infisical run \
  --domain=http://localhost:8085 \
  --env=dev \
  -- pnpm catalog:validate
</code></pre>
<p>The child process reads <code>process.env</code> normally. <code>infisical run</code> never touches <code>.env</code> on disk.</p>
<h2>7. Pull secrets into a local <code>.env</code> when you actually need one</h2>
<pre><code class="language-bash">infisical export \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --format=dotenv \
  --output-file=.env \
  --silent \
  &gt;/dev/null

chmod 600 .env
</code></pre>
<p>Confirm the keys and file permissions without displaying values:</p>
<pre><code class="language-bash">awk -F= &#39;/^[A-Za-z_][A-Za-z0-9_]*=/{print $1}&#39; .env | sort
stat -c &#39;mode=%a&#39; .env   # macOS: stat -f &#39;mode=%Lp&#39; .env
</code></pre>
<p>You&#39;re looking for <code>mode=600</code>. Your <code>.gitignore</code> should already exclude <code>.env*</code> except <code>.env.example</code>, but check before every commit anyway:</p>
<pre><code class="language-bash">git status --short
git check-ignore -v .env
</code></pre>
<h2>8. Create or update individual secrets</h2>
<p>For a quick one-off during development:</p>
<pre><code class="language-bash">infisical secrets set \
  EXAMPLE_KEY=example-value \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --type=shared \
  &gt;/dev/null
</code></pre>
<p>For anything sensitive, put the value in a permission-restricted temporary file rather than your shell history, or use the Infisical dashboard directly:</p>
<pre><code class="language-bash">chmod 600 /path/to/update.env

infisical secrets set \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --type=shared \
  --file=/path/to/update.env \
  --silent \
  &gt;/dev/null
</code></pre>
<h2>9. Read a single secret safely</h2>
<p>Printing a secret value should be rare enough that it feels deliberate every time:</p>
<pre><code class="language-bash">infisical secrets get SECRET_NAME \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/
</code></pre>
<p>For a presence check instead, inject it into a process:</p>
<pre><code class="language-bash">infisical run \
  --domain=http://localhost:8085 \
  --env=dev \
  --silent \
  -- node -e &#39;
    const key = &quot;SECRET_NAME&quot;
    console.log(process.env[key] ? `${key}: present` : `${key}: missing`)
    process.exit(process.env[key] ? 0 : 2)
  &#39;
</code></pre>
<h2>10. Delete a secret</h2>
<p>Deletion touches shared external state, so double-check the environment, path, and key first:</p>
<pre><code class="language-bash">infisical secrets delete SECRET_NAME \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/
</code></pre>
<p>Restart anything that depends on the deleted secret and confirm it comes back healthy.</p>
<h2>11. Secret folders</h2>
<p>Most projects keep everything at the root path (<code>/</code>). Folders become useful once a specific integration needs isolation from the rest of the secrets.</p>
<pre><code class="language-bash">infisical secrets folders get \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --output=json
</code></pre>
<p>Whatever <code>--path</code> you use, keep it consistent across <code>secrets set</code>, <code>run</code>, and <code>export</code> for that integration.</p>
<h2>12. Required application keys</h2>
<p>For a Next.js and Payload CMS deployment, staging and production validation typically checks:</p>
<pre><code class="language-text">DATABASE_URL
DATABASE_UNPOOLED_URL
SERVER_URL
NEXT_PUBLIC_SERVER_URL
PAYLOAD_SECRET
CRON_SECRET
S3_BUCKET
S3_ENDPOINT
</code></pre>
<p>The application layer also tends to use:</p>
<pre><code class="language-text">DEFAULT_SITE_SLUG
SITE_HOST_MAP
INTEGRATIONS_MODE
CACHE_REDIS_URL
CACHE_REDIS_PREFIX
CACHE_REDIS_MAX_ENTRY_BYTES
</code></pre>
<p>If your project carries <code>DATABASE_URL_DIRECT</code> from an older setup, rename it to <code>DATABASE_UNPOOLED_URL</code>. Migration scripts written against the newer name won&#39;t find the old one.</p>
<h2>13. Validate staging or production keys</h2>
<p>Run this from an authenticated developer machine, never from a CI runner pointed at <code>localhost</code>:</p>
<pre><code class="language-bash">infisical run \
  --domain=http://localhost:8085 \
  --env=staging \
  -- bash deployment-templates/validate-infisical-env.sh staging runtime

infisical run \
  --domain=http://localhost:8085 \
  --env=prod \
  -- bash deployment-templates/validate-infisical-env.sh production runtime
</code></pre>
<p>A good validator reports missing key names only and flags obviously invalid database hosts, without ever printing a value.</p>
<h2>14. Machine identities for CI and runtime</h2>
<p>Copying your personal login token onto a server defeats the point of having separate access levels. Create three Universal Auth identities instead:</p>
<ol>
<li>Staging CI/build/migration — read-only access to <code>staging</code>, path <code>/</code>.</li>
<li>Production CI/build/migration — read-only access to <code>prod</code>, path <code>/</code>.</li>
<li>Production runtime — read-only access to <code>prod</code>, path <code>/</code>.</li>
</ol>
<p>Each identity gets its own bootstrap file on the target host:</p>
<pre><code class="language-text">/etc/infisical/website-platform-staging.env
/etc/infisical/website-platform-production.env
</code></pre>
<pre><code class="language-dotenv">INFISICAL_API_URL=https://secrets.example.com
INFISICAL_PROJECT_ID=00000000-0000-0000-0000-000000000000
INFISICAL_ENV=staging
INFISICAL_SECRET_PATH=/
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=replace-me
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=replace-me
INFISICAL_DISABLE_UPDATE_CHECK=true
</code></pre>
<p>Lock down permissions on both files, and never commit a completed one:</p>
<pre><code class="language-bash">sudo chown root:&lt;runner-group&gt; /etc/infisical/website-platform-staging.env
sudo chown root:&lt;runner-group&gt; /etc/infisical/website-platform-production.env
sudo chmod 640 /etc/infisical/website-platform-staging.env
sudo chmod 640 /etc/infisical/website-platform-production.env
</code></pre>
<p>Production runtime can go tighter still, with <code>root:root</code> ownership and mode <code>600</code>.</p>
<h2>15. Test a machine identity</h2>
<p>From the target host:</p>
<pre><code class="language-bash">INFISICAL_BOOTSTRAP_FILE=/etc/infisical/website-platform-staging.env \
  sh scripts/infisical-exec.sh \
  bash deployment-templates/validate-infisical-env.sh staging runtime
</code></pre>
<p>A wrapper script like this one reads the root-protected bootstrap file, exchanges the Universal Auth client credential for a short-lived token, strips the long-lived credential out of the child environment, runs <code>infisical run</code>, and hands the application process its values.</p>
<h2>16. Build-time injection with Docker BuildKit</h2>
<p>CI (GitHub Actions or Bitbucket Pipelines) runs BuildKit inside the same exec wrapper. Infisical typically injects three build-time values:</p>
<pre><code class="language-text">PAYLOAD_SECRET
DATABASE_URL
NEXT_PUBLIC_SERVER_URL
</code></pre>
<p>BuildKit receives each one through <code>--secret id=...,env=...</code>, and the Dockerfile mounts them under <code>/run/secrets/</code> only for the duration of <code>pnpm build</code>. They never become build arguments and never land in an image layer. If you&#39;re used to <code>--build-arg</code>, resist the pull toward it here:</p>
<pre><code class="language-text">--build-arg PAYLOAD_SECRET=...
</code></pre>
<p>Build arguments get baked into image metadata, which is the opposite of what a secret mount is for.</p>
<h2>17. Runtime injection</h2>
<p>Staging and production Compose files mount a single bootstrap credential:</p>
<pre><code class="language-text">/run/secrets/infisical_bootstrap
</code></pre>
<p>The image entrypoint authenticates to Infisical, fetches the target environment, drops privileges to a non-root user, and starts the server with the fetched values already in <code>process.env</code>. After changing any runtime secret, restart the container so it picks up the new value:</p>
<pre><code class="language-bash">docker compose restart app
</code></pre>
<h2>18. Rotating a credential</h2>
<h3>Application credentials (database, external API keys)</h3>
<ol>
<li>Rotate the credential at its source.</li>
<li>Update the corresponding Infisical environment.</li>
<li>Restart or redeploy the application.</li>
<li>Check <code>/api/health</code>.</li>
<li>Revoke the old credential once the new one is confirmed working.</li>
</ol>
<p>Updating Infisical without rotating the credential at its source leaves the old value live wherever it was issued.</p>
<h3>Universal Auth client secrets</h3>
<ol>
<li>Generate a new client secret for that exact machine identity.</li>
<li>Update the matching <code>/etc/infisical/*.env</code> bootstrap file.</li>
<li>Restart the workload or rerun the validation wrapper.</li>
<li>Confirm injection succeeded and the application is healthy.</li>
<li>Revoke the old client secret.</li>
</ol>
<h2>Comparing the three secret-delivery paths</h2>
<table>
<thead>
<tr>
<th>Method</th>
<th>When to use</th>
<th>Trade-off</th>
</tr>
</thead>
<tbody><tr>
<td><code>infisical run -- &lt;command&gt;</code></td>
<td>Local dev, CI steps, most day-to-day work</td>
<td>Values live only in the child process; nothing to clean up</td>
</tr>
<tr>
<td><code>infisical export --output-file</code></td>
<td>A tool that only reads dotenv files</td>
<td>Creates a real file on disk that needs <code>chmod 600</code> and careful <code>.gitignore</code> handling</td>
</tr>
<tr>
<td>Bootstrap file + machine identity</td>
<td>CI runners and production containers</td>
<td>Requires provisioning a Universal Auth identity per environment, but keeps long-lived credentials off developer machines</td>
</tr>
</tbody></table>
<h2>Troubleshooting</h2>
<p><strong>&quot;Environment with slug &#39;demo&#39; not found&quot;</strong>
Use one of the environments actually configured for the project: <code>dev</code>, <code>staging</code>, or <code>prod</code>.</p>
<p><strong>&quot;Injecting 0 Infisical secrets&quot;</strong>
The project, environment, and path all resolve, but nothing is accessible from them. Check the project linkage, the environment slug, the path (usually <code>/</code>), that secrets exist there, and that your account or machine identity has read access.</p>
<p><strong>&quot;Folder with path &#39;/&#39; ... was not found&quot;</strong>
Either the environment slug is wrong, or that environment has never had a root secret or folder initialized. Recheck both.</p>
<p><strong>&quot;File does not exist&quot;</strong>
<code>--file=.env</code> needs a local file to already exist. Pull it fresh with <code>infisical export --output-file=.env</code> or recreate it from an approved source.</p>
<p><strong>Login works, but commands hit Infisical Cloud instead of your self-hosted instance</strong>
Pass <code>--domain=http://localhost:8085</code> on every command, or set it once for scripts:</p>
<pre><code class="language-bash">export INFISICAL_API_URL=http://localhost:8085
</code></pre>
<p><strong>The local URL works, but CI or a container can&#39;t connect</strong>
<code>localhost</code> always points at the current process&#39;s own host. CI runners and containers need the actual HTTPS Infisical hostname reachable from where they run, configured in the bootstrap file.</p>
<p><strong>The CLI prints secret values despite <code>--silent</code></strong>
Redirect the command&#39;s output:</p>
<pre><code class="language-bash">infisical secrets set ... --file=.env --silent &gt;/dev/null
infisical export ... --output-file=.env --silent &gt;/dev/null
</code></pre>
<p>If a value already made it into a saved log somewhere, rotate it.</p>
<h2>Command quick reference</h2>
<pre><code class="language-bash"># Login
infisical login --domain=http://localhost:8085

# Link repository
infisical init --domain=http://localhost:8085

# Upload .env to dev
infisical secrets set \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --type=shared \
  --file=.env \
  --silent &gt;/dev/null

# Run without writing .env
infisical run \
  --domain=http://localhost:8085 \
  --env=dev \
  -- pnpm dev

# Pull to a local file
infisical export \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --format=dotenv \
  --output-file=.env \
  --silent &gt;/dev/null
chmod 600 .env

# Validate staging
infisical run \
  --domain=http://localhost:8085 \
  --env=staging \
  -- bash deployment-templates/validate-infisical-env.sh staging runtime
</code></pre>
<h2>FAQ</h2>
<p><strong>Does <code>--silent</code> actually hide secret values in Infisical CLI 0.43.84?</strong>
No, not for <code>secrets set</code>. That version prints a result table containing the values regardless of the flag. Redirect standard output to <code>/dev/null</code> on every upload or export command until you&#39;ve confirmed a fix in a newer release.</p>
<p><strong>Why does my CI runner fail to connect to <code>http://localhost:8085</code>?</strong>
Because <code>localhost</code> resolves to the runner&#39;s own container, not your development machine. CI and production hosts need the real HTTPS Infisical hostname configured in their bootstrap files.</p>
<p><strong>Should I use one Universal Auth identity for both staging and production?</strong>
No. Provision a separate read-only identity for staging CI, production CI, and production runtime. If one leaks, the blast radius stays limited to a single environment.</p>
<p><strong>What&#39;s the difference between <code>infisical run</code> and <code>infisical export</code>?</strong>
<code>infisical run</code> injects secrets into a child process&#39;s memory and never writes them to disk. <code>infisical export</code> writes them into a real file, which is useful only when a tool genuinely requires a dotenv file to read from.</p>
<p><strong>Can I pass a secret as a Docker build argument instead of a secret mount?</strong>
You can, but you shouldn&#39;t. Build arguments get recorded in image metadata. A BuildKit <code>--secret</code> mount exposes the value only inside the build step and leaves nothing behind in the image.</p>
<p>Setting this up once, with the right environment separation and machine identities, removes the temptation to pass a <code>.env</code> file around informally later. Let me know in the comments if you run into a rougher edge with a different CLI version, and subscribe for more practical development guides.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/infisical-cli-nextjs-payload-secrets-workflow</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/infisical-cli-nextjs-payload-secrets-workflow</guid>
            <category><![CDATA[Next.js]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Thu, 16 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;Infisical CLI lets you inject secrets straight into a running process instead of writing them to a &lt;code&gt;.env&lt;/code&gt; file, and that one property shapes almost every command in this guide. Pin the CLI version, link your project once, upload your existing &lt;code&gt;.env&lt;/code&gt; as a starting point, then switch to &lt;code&gt;infisical run&lt;/code&gt; for local development, CI builds, and production runtime. Version &lt;code&gt;0.43.84&lt;/code&gt; has one quirk worth knowing before you touch it: &lt;code&gt;--silent&lt;/code&gt; does not stop the &lt;code&gt;secrets set&lt;/code&gt; command from printing a result table full of secret values, so every upload command in this guide redirects output to &lt;code&gt;/dev/null&lt;/code&gt;. This article walks through the full path, from a fresh clone to a rotated production credential, using a Next.js and Payload CMS project as the running example.&lt;/p&gt;
&lt;p&gt;I set this up for a client project that runs Payload CMS on Next.js across dev, staging, and production, with GitHub Actions building the Docker image and a Compose file handling runtime. The team had been passing &lt;code&gt;.env&lt;/code&gt; files around in Slack before this, which is exactly the failure mode Infisical exists to prevent. What follows is the workflow that replaced it, including the two mistakes that cost me the most time: assuming &lt;code&gt;--silent&lt;/code&gt; actually silenced the CLI, and pointing a CI runner at &lt;code&gt;localhost:8085&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Why process injection instead of a &lt;code&gt;.env&lt;/code&gt; file&lt;/h2&gt;
&lt;p&gt;A &lt;code&gt;.env&lt;/code&gt; file sitting on disk is a file that can get committed, screenshotted, or copied into a support ticket. &lt;code&gt;infisical run -- &amp;lt;command&amp;gt;&lt;/code&gt; skips that step by fetching secrets and setting them as environment variables directly on the child process. The values live in memory for that process only. Reach for &lt;code&gt;infisical export --output-file&lt;/code&gt; when you genuinely need a local file, such as feeding a tool that only reads dotenv files, and treat that as the exception rather than the default.&lt;/p&gt;
&lt;h2&gt;Platform configuration&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Local Infisical URL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://localhost:8085&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Organization&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Admin Org&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Project&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Website Platform&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Project/workspace ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;00000000-0000-0000-0000-000000000000&lt;/code&gt; (yours will differ)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local project configuration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.infisical.json&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supported environments&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, &lt;code&gt;prod&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default secret path&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pinned CLI version&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.43.84&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;localhost:8085&lt;/code&gt; is a developer-facing local forward. CI runners and containers cannot reach it because &lt;code&gt;localhost&lt;/code&gt; always resolves to the current host. Server bootstrap files need the approved HTTPS Infisical hostname instead, which shows up later in the CI and runtime sections.&lt;/p&gt;
&lt;h2&gt;Security rules to follow from the start&lt;/h2&gt;
&lt;p&gt;A few habits prevent most of the incidents that happen with secrets tooling:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Never commit &lt;code&gt;.env&lt;/code&gt;, machine client secrets, access tokens, or exported values.&lt;/li&gt;
&lt;li&gt;Use a personal Infisical account for development, and separate read-only Universal Auth machine identities for staging CI, production CI, and production runtime.&lt;/li&gt;
&lt;li&gt;Keep development values out of staging and production; provision each environment independently.&lt;/li&gt;
&lt;li&gt;Redirect every &lt;code&gt;secrets set&lt;/code&gt; and &lt;code&gt;export&lt;/code&gt; command&amp;#39;s output, because CLI &lt;code&gt;0.43.84&lt;/code&gt; prints values even with &lt;code&gt;--silent&lt;/code&gt; set.&lt;/li&gt;
&lt;li&gt;Skip shell tracing (&lt;code&gt;set -x&lt;/code&gt;) anywhere near a secret command.&lt;/li&gt;
&lt;li&gt;Rotate a credential immediately if it appears in a terminal transcript, CI log, screenshot, ticket, or chat message.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;1. Install the CLI&lt;/h2&gt;
&lt;p&gt;Pin the version for reproducibility across every machine that touches the project.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Persistent installation
pnpm add --global @infisical/cli@0.43.84
infisical --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;infisical version 0.43.84
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a one-off run without a global install:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm dlx @infisical/cli@0.43.84 --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Swap &lt;code&gt;infisical&lt;/code&gt; for &lt;code&gt;pnpm dlx @infisical/cli@0.43.84&lt;/code&gt; in any command below if you&amp;#39;d rather not install globally.&lt;/p&gt;
&lt;h2&gt;2. Authenticate&lt;/h2&gt;
&lt;p&gt;Start the local Infisical forward, then log in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical login --domain=http://localhost:8085
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This opens a browser callback. Complete it with your personal account. The CLI stores the session in its local credential vault and never writes the login token into the repository.&lt;/p&gt;
&lt;p&gt;Confirm the session is active:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical login status --domain=http://localhost:8085
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the browser callback doesn&amp;#39;t fire on its own, open the URL the CLI prints in the terminal.&lt;/p&gt;
&lt;h2&gt;3. Link the repository&lt;/h2&gt;
&lt;p&gt;A checked-in &lt;code&gt;.infisical.json&lt;/code&gt; links the repo to the right project and defaults to &lt;code&gt;dev&lt;/code&gt;. To recreate that linkage from scratch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical init --domain=http://localhost:8085
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pick your organization, then your project, from the prompts. The resulting file holds project metadata only:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;workspaceId&amp;quot;: &amp;quot;00000000-0000-0000-0000-000000000000&amp;quot;,
  &amp;quot;defaultEnvironment&amp;quot;: &amp;quot;dev&amp;quot;,
  &amp;quot;gitBranchToEnvironmentMapping&amp;quot;: null
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. Upload an existing &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;dev&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Check which keys you&amp;#39;re about to upload before you run anything, since this command shows names only:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;awk -F= &amp;#39;/^[A-Za-z_][A-Za-z0-9_]*=/{print $1}&amp;#39; .env | sort
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then upload every valid entry as a shared secret at the root path:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical secrets set \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --type=shared \
  --file=.env \
  --silent \
  &amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates any missing keys and updates the ones that already exist, including empty values. Comment lines in the source file are skipped automatically. The output redirect matters here: without it, the terminal fills with a table of your secret values despite &lt;code&gt;--silent&lt;/code&gt; being set. Keep dev, staging, and production values independent; don&amp;#39;t push the same &lt;code&gt;.env&lt;/code&gt; into more than one environment.&lt;/p&gt;
&lt;h2&gt;5. Verify an upload without exposing values&lt;/h2&gt;
&lt;p&gt;Injecting secrets into a short Node script and checking for presence is safer than printing them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical run \
  --domain=http://localhost:8085 \
  --env=dev \
  --silent \
  -- node -e &amp;#39;
    const expected = [
      &amp;quot;DATABASE_URL&amp;quot;,
      &amp;quot;NEXT_PUBLIC_SERVER_URL&amp;quot;,
      &amp;quot;PAYLOAD_SECRET&amp;quot;,
      &amp;quot;DEFAULT_SITE_SLUG&amp;quot;,
      &amp;quot;SITE_HOST_MAP&amp;quot;,
      &amp;quot;INTEGRATIONS_MODE&amp;quot;,
    ]
    const missing = expected.filter((key) =&amp;gt; !process.env[key])
    console.log(JSON.stringify({
      injection: &amp;quot;ok&amp;quot;,
      expected: expected.length,
      present: expected.length - missing.length,
      missing,
    }))
    process.exit(missing.length ? 2 : 0)
  &amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Steer clear of &lt;code&gt;printenv&lt;/code&gt;, &lt;code&gt;env&lt;/code&gt;, &lt;code&gt;infisical secrets --output=json&lt;/code&gt;, or piping an export to standard output for this check. Each of those prints the actual values.&lt;/p&gt;
&lt;h2&gt;6. Run the application with process-only injection&lt;/h2&gt;
&lt;p&gt;For day-to-day development:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical run \
  --domain=http://localhost:8085 \
  --env=dev \
  -- pnpm dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your &lt;code&gt;package.json&lt;/code&gt; wraps this in a script (for example &lt;code&gt;pnpm dev:infisical&lt;/code&gt;), use that instead. The same pattern works for any command that needs environment access:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Seed development data
infisical run \
  --domain=http://localhost:8085 \
  --env=dev \
  -- pnpm db:seed

# Run catalog validation with the same environment
infisical run \
  --domain=http://localhost:8085 \
  --env=dev \
  -- pnpm catalog:validate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The child process reads &lt;code&gt;process.env&lt;/code&gt; normally. &lt;code&gt;infisical run&lt;/code&gt; never touches &lt;code&gt;.env&lt;/code&gt; on disk.&lt;/p&gt;
&lt;h2&gt;7. Pull secrets into a local &lt;code&gt;.env&lt;/code&gt; when you actually need one&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical export \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --format=dotenv \
  --output-file=.env \
  --silent \
  &amp;gt;/dev/null

chmod 600 .env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Confirm the keys and file permissions without displaying values:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;awk -F= &amp;#39;/^[A-Za-z_][A-Za-z0-9_]*=/{print $1}&amp;#39; .env | sort
stat -c &amp;#39;mode=%a&amp;#39; .env   # macOS: stat -f &amp;#39;mode=%Lp&amp;#39; .env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You&amp;#39;re looking for &lt;code&gt;mode=600&lt;/code&gt;. Your &lt;code&gt;.gitignore&lt;/code&gt; should already exclude &lt;code&gt;.env*&lt;/code&gt; except &lt;code&gt;.env.example&lt;/code&gt;, but check before every commit anyway:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git status --short
git check-ignore -v .env
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;8. Create or update individual secrets&lt;/h2&gt;
&lt;p&gt;For a quick one-off during development:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical secrets set \
  EXAMPLE_KEY=example-value \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --type=shared \
  &amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For anything sensitive, put the value in a permission-restricted temporary file rather than your shell history, or use the Infisical dashboard directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;chmod 600 /path/to/update.env

infisical secrets set \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --type=shared \
  --file=/path/to/update.env \
  --silent \
  &amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;9. Read a single secret safely&lt;/h2&gt;
&lt;p&gt;Printing a secret value should be rare enough that it feels deliberate every time:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical secrets get SECRET_NAME \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a presence check instead, inject it into a process:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical run \
  --domain=http://localhost:8085 \
  --env=dev \
  --silent \
  -- node -e &amp;#39;
    const key = &amp;quot;SECRET_NAME&amp;quot;
    console.log(process.env[key] ? `${key}: present` : `${key}: missing`)
    process.exit(process.env[key] ? 0 : 2)
  &amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;10. Delete a secret&lt;/h2&gt;
&lt;p&gt;Deletion touches shared external state, so double-check the environment, path, and key first:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical secrets delete SECRET_NAME \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Restart anything that depends on the deleted secret and confirm it comes back healthy.&lt;/p&gt;
&lt;h2&gt;11. Secret folders&lt;/h2&gt;
&lt;p&gt;Most projects keep everything at the root path (&lt;code&gt;/&lt;/code&gt;). Folders become useful once a specific integration needs isolation from the rest of the secrets.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical secrets folders get \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --output=json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Whatever &lt;code&gt;--path&lt;/code&gt; you use, keep it consistent across &lt;code&gt;secrets set&lt;/code&gt;, &lt;code&gt;run&lt;/code&gt;, and &lt;code&gt;export&lt;/code&gt; for that integration.&lt;/p&gt;
&lt;h2&gt;12. Required application keys&lt;/h2&gt;
&lt;p&gt;For a Next.js and Payload CMS deployment, staging and production validation typically checks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DATABASE_URL
DATABASE_UNPOOLED_URL
SERVER_URL
NEXT_PUBLIC_SERVER_URL
PAYLOAD_SECRET
CRON_SECRET
S3_BUCKET
S3_ENDPOINT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application layer also tends to use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DEFAULT_SITE_SLUG
SITE_HOST_MAP
INTEGRATIONS_MODE
CACHE_REDIS_URL
CACHE_REDIS_PREFIX
CACHE_REDIS_MAX_ENTRY_BYTES
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your project carries &lt;code&gt;DATABASE_URL_DIRECT&lt;/code&gt; from an older setup, rename it to &lt;code&gt;DATABASE_UNPOOLED_URL&lt;/code&gt;. Migration scripts written against the newer name won&amp;#39;t find the old one.&lt;/p&gt;
&lt;h2&gt;13. Validate staging or production keys&lt;/h2&gt;
&lt;p&gt;Run this from an authenticated developer machine, never from a CI runner pointed at &lt;code&gt;localhost&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical run \
  --domain=http://localhost:8085 \
  --env=staging \
  -- bash deployment-templates/validate-infisical-env.sh staging runtime

infisical run \
  --domain=http://localhost:8085 \
  --env=prod \
  -- bash deployment-templates/validate-infisical-env.sh production runtime
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A good validator reports missing key names only and flags obviously invalid database hosts, without ever printing a value.&lt;/p&gt;
&lt;h2&gt;14. Machine identities for CI and runtime&lt;/h2&gt;
&lt;p&gt;Copying your personal login token onto a server defeats the point of having separate access levels. Create three Universal Auth identities instead:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Staging CI/build/migration — read-only access to &lt;code&gt;staging&lt;/code&gt;, path &lt;code&gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Production CI/build/migration — read-only access to &lt;code&gt;prod&lt;/code&gt;, path &lt;code&gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Production runtime — read-only access to &lt;code&gt;prod&lt;/code&gt;, path &lt;code&gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each identity gets its own bootstrap file on the target host:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/etc/infisical/website-platform-staging.env
/etc/infisical/website-platform-production.env
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;INFISICAL_API_URL=https://secrets.example.com
INFISICAL_PROJECT_ID=00000000-0000-0000-0000-000000000000
INFISICAL_ENV=staging
INFISICAL_SECRET_PATH=/
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=replace-me
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=replace-me
INFISICAL_DISABLE_UPDATE_CHECK=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lock down permissions on both files, and never commit a completed one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo chown root:&amp;lt;runner-group&amp;gt; /etc/infisical/website-platform-staging.env
sudo chown root:&amp;lt;runner-group&amp;gt; /etc/infisical/website-platform-production.env
sudo chmod 640 /etc/infisical/website-platform-staging.env
sudo chmod 640 /etc/infisical/website-platform-production.env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Production runtime can go tighter still, with &lt;code&gt;root:root&lt;/code&gt; ownership and mode &lt;code&gt;600&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;15. Test a machine identity&lt;/h2&gt;
&lt;p&gt;From the target host:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;INFISICAL_BOOTSTRAP_FILE=/etc/infisical/website-platform-staging.env \
  sh scripts/infisical-exec.sh \
  bash deployment-templates/validate-infisical-env.sh staging runtime
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A wrapper script like this one reads the root-protected bootstrap file, exchanges the Universal Auth client credential for a short-lived token, strips the long-lived credential out of the child environment, runs &lt;code&gt;infisical run&lt;/code&gt;, and hands the application process its values.&lt;/p&gt;
&lt;h2&gt;16. Build-time injection with Docker BuildKit&lt;/h2&gt;
&lt;p&gt;CI (GitHub Actions or Bitbucket Pipelines) runs BuildKit inside the same exec wrapper. Infisical typically injects three build-time values:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;PAYLOAD_SECRET
DATABASE_URL
NEXT_PUBLIC_SERVER_URL
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;BuildKit receives each one through &lt;code&gt;--secret id=...,env=...&lt;/code&gt;, and the Dockerfile mounts them under &lt;code&gt;/run/secrets/&lt;/code&gt; only for the duration of &lt;code&gt;pnpm build&lt;/code&gt;. They never become build arguments and never land in an image layer. If you&amp;#39;re used to &lt;code&gt;--build-arg&lt;/code&gt;, resist the pull toward it here:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;--build-arg PAYLOAD_SECRET=...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Build arguments get baked into image metadata, which is the opposite of what a secret mount is for.&lt;/p&gt;
&lt;h2&gt;17. Runtime injection&lt;/h2&gt;
&lt;p&gt;Staging and production Compose files mount a single bootstrap credential:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/run/secrets/infisical_bootstrap
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The image entrypoint authenticates to Infisical, fetches the target environment, drops privileges to a non-root user, and starts the server with the fetched values already in &lt;code&gt;process.env&lt;/code&gt;. After changing any runtime secret, restart the container so it picks up the new value:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose restart app
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;18. Rotating a credential&lt;/h2&gt;
&lt;h3&gt;Application credentials (database, external API keys)&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Rotate the credential at its source.&lt;/li&gt;
&lt;li&gt;Update the corresponding Infisical environment.&lt;/li&gt;
&lt;li&gt;Restart or redeploy the application.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;/api/health&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Revoke the old credential once the new one is confirmed working.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Updating Infisical without rotating the credential at its source leaves the old value live wherever it was issued.&lt;/p&gt;
&lt;h3&gt;Universal Auth client secrets&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Generate a new client secret for that exact machine identity.&lt;/li&gt;
&lt;li&gt;Update the matching &lt;code&gt;/etc/infisical/*.env&lt;/code&gt; bootstrap file.&lt;/li&gt;
&lt;li&gt;Restart the workload or rerun the validation wrapper.&lt;/li&gt;
&lt;li&gt;Confirm injection succeeded and the application is healthy.&lt;/li&gt;
&lt;li&gt;Revoke the old client secret.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Comparing the three secret-delivery paths&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;When to use&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;infisical run -- &amp;lt;command&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Local dev, CI steps, most day-to-day work&lt;/td&gt;
&lt;td&gt;Values live only in the child process; nothing to clean up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;infisical export --output-file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A tool that only reads dotenv files&lt;/td&gt;
&lt;td&gt;Creates a real file on disk that needs &lt;code&gt;chmod 600&lt;/code&gt; and careful &lt;code&gt;.gitignore&lt;/code&gt; handling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bootstrap file + machine identity&lt;/td&gt;
&lt;td&gt;CI runners and production containers&lt;/td&gt;
&lt;td&gt;Requires provisioning a Universal Auth identity per environment, but keeps long-lived credentials off developer machines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;Troubleshooting&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;Environment with slug &amp;#39;demo&amp;#39; not found&amp;quot;&lt;/strong&gt;
Use one of the environments actually configured for the project: &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, or &lt;code&gt;prod&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;Injecting 0 Infisical secrets&amp;quot;&lt;/strong&gt;
The project, environment, and path all resolve, but nothing is accessible from them. Check the project linkage, the environment slug, the path (usually &lt;code&gt;/&lt;/code&gt;), that secrets exist there, and that your account or machine identity has read access.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;Folder with path &amp;#39;/&amp;#39; ... was not found&amp;quot;&lt;/strong&gt;
Either the environment slug is wrong, or that environment has never had a root secret or folder initialized. Recheck both.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;File does not exist&amp;quot;&lt;/strong&gt;
&lt;code&gt;--file=.env&lt;/code&gt; needs a local file to already exist. Pull it fresh with &lt;code&gt;infisical export --output-file=.env&lt;/code&gt; or recreate it from an approved source.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Login works, but commands hit Infisical Cloud instead of your self-hosted instance&lt;/strong&gt;
Pass &lt;code&gt;--domain=http://localhost:8085&lt;/code&gt; on every command, or set it once for scripts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export INFISICAL_API_URL=http://localhost:8085
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The local URL works, but CI or a container can&amp;#39;t connect&lt;/strong&gt;
&lt;code&gt;localhost&lt;/code&gt; always points at the current process&amp;#39;s own host. CI runners and containers need the actual HTTPS Infisical hostname reachable from where they run, configured in the bootstrap file.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The CLI prints secret values despite &lt;code&gt;--silent&lt;/code&gt;&lt;/strong&gt;
Redirect the command&amp;#39;s output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical secrets set ... --file=.env --silent &amp;gt;/dev/null
infisical export ... --output-file=.env --silent &amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If a value already made it into a saved log somewhere, rotate it.&lt;/p&gt;
&lt;h2&gt;Command quick reference&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Login
infisical login --domain=http://localhost:8085

# Link repository
infisical init --domain=http://localhost:8085

# Upload .env to dev
infisical secrets set \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --type=shared \
  --file=.env \
  --silent &amp;gt;/dev/null

# Run without writing .env
infisical run \
  --domain=http://localhost:8085 \
  --env=dev \
  -- pnpm dev

# Pull to a local file
infisical export \
  --domain=http://localhost:8085 \
  --env=dev \
  --path=/ \
  --format=dotenv \
  --output-file=.env \
  --silent &amp;gt;/dev/null
chmod 600 .env

# Validate staging
infisical run \
  --domain=http://localhost:8085 \
  --env=staging \
  -- bash deployment-templates/validate-infisical-env.sh staging runtime
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;--silent&lt;/code&gt; actually hide secret values in Infisical CLI 0.43.84?&lt;/strong&gt;
No, not for &lt;code&gt;secrets set&lt;/code&gt;. That version prints a result table containing the values regardless of the flag. Redirect standard output to &lt;code&gt;/dev/null&lt;/code&gt; on every upload or export command until you&amp;#39;ve confirmed a fix in a newer release.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why does my CI runner fail to connect to &lt;code&gt;http://localhost:8085&lt;/code&gt;?&lt;/strong&gt;
Because &lt;code&gt;localhost&lt;/code&gt; resolves to the runner&amp;#39;s own container, not your development machine. CI and production hosts need the real HTTPS Infisical hostname configured in their bootstrap files.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Should I use one Universal Auth identity for both staging and production?&lt;/strong&gt;
No. Provision a separate read-only identity for staging CI, production CI, and production runtime. If one leaks, the blast radius stays limited to a single environment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What&amp;#39;s the difference between &lt;code&gt;infisical run&lt;/code&gt; and &lt;code&gt;infisical export&lt;/code&gt;?&lt;/strong&gt;
&lt;code&gt;infisical run&lt;/code&gt; injects secrets into a child process&amp;#39;s memory and never writes them to disk. &lt;code&gt;infisical export&lt;/code&gt; writes them into a real file, which is useful only when a tool genuinely requires a dotenv file to read from.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can I pass a secret as a Docker build argument instead of a secret mount?&lt;/strong&gt;
You can, but you shouldn&amp;#39;t. Build arguments get recorded in image metadata. A BuildKit &lt;code&gt;--secret&lt;/code&gt; mount exposes the value only inside the build step and leaves nothing behind in the image.&lt;/p&gt;
&lt;p&gt;Setting this up once, with the right environment separation and machine identities, removes the temptation to pass a &lt;code&gt;.env&lt;/code&gt; file around informally later. Let me know in the comments if you run into a rougher edge with a different CLI version, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/infisical-cli-nextjs-payload-secrets-workflow"/>
        </item>
        <item>
            <title><![CDATA[Self-Host Infisical: Ultimate VPS Guide for Secrets]]></title>
            <description><![CDATA[<p>Self-hosting Infisical gives any application a central, provider-neutral place to store secrets, separate development, staging, and production values, and issue separate identities to developers and servers, all without tying your setup to Vercel, a single cloud provider, or a stack-specific SDK. This guide covers the complete implementation: standing up Infisical on a dedicated VPS behind HTTPS, structuring projects and environments, creating machine identities for staging and production, and injecting secrets into systemd services and Docker containers. Everything here works for any application that reads configuration from environment variables, and I&#39;ll use my own Next.js and Payload CMS stack as the running example throughout.</p>
<p>In the previous article, I covered why <code>.env</code> files stop being a sufficient secrets-management system once an application picks up multiple developers, deployment environments, servers, and external services. This one covers what replaces them. I wanted something that felt close to managing environment variables in Vercel, without being tied to Vercel, and that would work across local development, staging, production, Docker containers, background workers, and plain VPS deployments regardless of framework. Infisical is what I landed on, and this guide walks through the full setup.</p>
<h2>What this guide covers</h2>
<ul>
<li>Self-hosting Infisical on a dedicated VPS</li>
<li>Putting it behind HTTPS</li>
<li>Creating development, staging, and production environments</li>
<li>Connecting a local project, whatever it&#39;s built with</li>
<li>Giving staging and production servers separate machine identities</li>
<li>Injecting secrets into a systemd service</li>
<li>Injecting secrets into Docker containers</li>
<li>Handling build-time variables correctly (using Next.js as the example)</li>
<li>Backing up the Infisical database and encryption keys</li>
<li>Rotating secrets without falling back to copied <code>.env</code> files</li>
</ul>
<p>The end result looks like this regardless of what you&#39;re running:</p>
<pre><code class="language-text">Developer laptop
  └── Infisical CLI
        └── development secrets

Staging VPS
  └── staging machine identity
        └── staging secrets

Production VPS
  └── production machine identity
        └── production secrets

Infisical VPS
  ├── Infisical
  ├── PostgreSQL
  ├── Redis
  └── HTTPS reverse proxy
</code></pre>
<p>Your application keeps reading ordinary environment variables through whatever mechanism your language or framework already uses, <code>process.env</code> in Node.js being the obvious one. Infisical controls where those values are stored, who may retrieve them, and which environment each application receives. The application code itself doesn&#39;t need to know Infisical exists.</p>
<h2>What this guide assumes</h2>
<p>This guide assumes Ubuntu or Debian on the servers, Docker and Docker Compose on the Infisical VPS, a domain such as <code>secrets.example.com</code>, separate staging and production servers, and a small team or small-scale production deployment. The application examples use Next.js with Payload CMS and <code>pnpm</code>, since that&#39;s the stack I run most often, but nothing about Infisical itself is specific to that combination. Swap in a Django app, a Go service, a Rails app, or anything else that reads configuration from the environment, and the Infisical side of this guide stays the same.</p>
<p>Infisical&#39;s official Docker Compose template runs the application, PostgreSQL, and Redis on a single Docker host. Infisical documents this deployment model as suitable for development, testing, proofs of concept, and smaller deployments that don&#39;t require high availability, with a documented minimum of two CPU cores, 4 GB RAM, and 20 GB disk for the complete stack, and higher resources recommended beyond that.</p>
<p>For this guide, I&#39;d use at least:</p>
<pre><code class="language-text">4 vCPU
8 GB RAM
50 GB SSD
</code></pre>
<p>A smaller server works fine for testing, but a secrets manager is infrastructure that every one of your applications may depend on during startup, so I&#39;d rather give it comfortable headroom from the start. For a larger organization or a high-availability requirement, run Infisical against external PostgreSQL and Redis services or a more resilient orchestration setup.</p>
<h2>The target architecture</h2>
<p>The Infisical server should sit separately from your application servers, regardless of what those application servers run.</p>
<pre><code class="language-text">Internet
  │
  ▼
secrets.example.com
  │
  ▼
Nginx with TLS
  │
  ▼
Infisical container
  ├── PostgreSQL container
  └── Redis container
</code></pre>
<p>Application servers connect to the Infisical API over HTTPS:</p>
<pre><code class="language-text">Production VPS
  │
  ├── Client ID
  ├── Client Secret
  │
  ▼
Infisical Universal Auth
  │
  ▼
Short-lived access token
  │
  ▼
Production secrets
  │
  ▼
Application process
</code></pre>
<p>The application server never stores the database password, application secret, storage credentials, SMTP password, and every external API key in a local <code>.env.production</code> file. It stores one restricted bootstrap credential that lets it request the correct secrets from Infisical, and that&#39;s true whether the process at the bottom of that chain is Next.js, Payload, or something else entirely.</p>
<h2>Step 1: Prepare the Infisical VPS</h2>
<p>Start by creating a dedicated installation directory:</p>
<pre><code class="language-bash">sudo mkdir -p /opt/infisical
sudo chown &quot;$USER&quot;:&quot;$USER&quot; /opt/infisical
cd /opt/infisical
</code></pre>
<p>Verify that Docker and Docker Compose are installed:</p>
<pre><code class="language-bash">docker --version
docker compose version
</code></pre>
<p>Download the official Docker Compose template and example configuration:</p>
<pre><code class="language-bash">curl -fsSL \
  -o docker-compose.prod.yml \
  https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml

curl -fsSL \
  -o .env \
  https://raw.githubusercontent.com/Infisical/infisical/main/.env.example
</code></pre>
<p>Protect the configuration file:</p>
<pre><code class="language-bash">chmod 600 .env
</code></pre>
<p>The downloaded <code>.env</code> contains example credentials that must not be used in production. Infisical explicitly marks the example encryption and authentication values as unsafe for production use.</p>
<h2>Step 2: Pin the Infisical image</h2>
<p>The current official Compose template contains:</p>
<pre><code class="language-yaml">image: infisical/infisical:latest
</code></pre>
<p>It also includes a comment telling operators to pin the image to a specific tag. Open the file:</p>
<pre><code class="language-bash">nano docker-compose.prod.yml
</code></pre>
<p>Change:</p>
<pre><code class="language-yaml">image: infisical/infisical:latest
</code></pre>
<p>To a specific version that you&#39;ve reviewed:</p>
<pre><code class="language-yaml">image: infisical/infisical:&lt;PINNED_VERSION&gt;
</code></pre>
<p>A floating <code>latest</code> tag has no place in infrastructure that stores the credentials for every other application you run. The same principle applies to PostgreSQL and Redis: pin versions, test upgrades in a non-production environment, back up the database, and upgrade deliberately.</p>
<h2>Step 3: Bind Infisical to localhost</h2>
<p>The official template publishes the backend directly on port 80:</p>
<pre><code class="language-yaml">ports:
  - 80:8080
</code></pre>
<p>I prefer to expose Infisical only through the reverse proxy. Change the backend port mapping to:</p>
<pre><code class="language-yaml">services:
  backend:
    ports:
      - &quot;127.0.0.1:8080:8080&quot;
</code></pre>
<p>The service is now reachable from the VPS itself at <code>http://127.0.0.1:8080</code> and stays off the public network interface. Nginx will terminate TLS and forward requests to this local port.</p>
<h2>Step 4: Generate the platform secrets</h2>
<p>Infisical requires an encryption key and an authentication secret. Generate the encryption key:</p>
<pre><code class="language-bash">openssl rand -hex 16
</code></pre>
<p>Generate the authentication secret:</p>
<pre><code class="language-bash">openssl rand -base64 32
</code></pre>
<p>Generate a PostgreSQL password using hexadecimal characters so it can safely appear inside a connection URI:</p>
<pre><code class="language-bash">openssl rand -hex 24
</code></pre>
<p>Infisical currently documents <code>ENCRYPTION_KEY</code> as a random 16-byte hexadecimal string and <code>AUTH_SECRET</code> as a random 32-byte Base64 string. Open the configuration:</p>
<pre><code class="language-bash">nano /opt/infisical/.env
</code></pre>
<p>Replace the sample values with your generated values:</p>
<pre><code class="language-dotenv"># File: /opt/infisical/.env
ENCRYPTION_KEY=&lt;GENERATED_HEX_ENCRYPTION_KEY&gt;
AUTH_SECRET=&lt;GENERATED_BASE64_AUTH_SECRET&gt;

POSTGRES_USER=infisical
POSTGRES_PASSWORD=&lt;GENERATED_POSTGRES_PASSWORD&gt;
POSTGRES_DB=infisical

DB_CONNECTION_URI=postgresql://infisical:&lt;GENERATED_POSTGRES_PASSWORD&gt;@db:5432/infisical
REDIS_URL=redis://redis:6379

SITE_URL=https://secrets.example.com

TELEMETRY_ENABLED=false
</code></pre>
<p>Leave the rest of the optional integration settings empty unless you need them. The platform requires an absolute <code>SITE_URL</code>, including the protocol. PostgreSQL is the persistent data layer here, and Redis handles caching and background work.</p>
<p>Check the permissions again:</p>
<pre><code class="language-bash">chmod 600 /opt/infisical/.env
</code></pre>
<h2>Step 5: Start Infisical</h2>
<p>Start the stack:</p>
<pre><code class="language-bash">cd /opt/infisical

docker compose \
  -f docker-compose.prod.yml \
  up -d
</code></pre>
<p>Check the containers:</p>
<pre><code class="language-bash">docker compose \
  -f docker-compose.prod.yml \
  ps
</code></pre>
<p>Inspect the logs if a container isn&#39;t healthy:</p>
<pre><code class="language-bash">docker compose \
  -f docker-compose.prod.yml \
  logs --tail=100
</code></pre>
<p>Test the local API:</p>
<pre><code class="language-bash">curl -fsS http://127.0.0.1:8080/api/status
</code></pre>
<p>You should get a successful response from the Infisical backend.</p>
<h2>Step 6: Configure Nginx and HTTPS</h2>
<p>Install Nginx and Certbot:</p>
<pre><code class="language-bash">sudo apt-get update
sudo apt-get install -y nginx certbot python3-certbot-nginx
</code></pre>
<p>Create the Nginx virtual host:</p>
<pre><code class="language-bash">sudo nano /etc/nginx/sites-available/secrets.example.com
</code></pre>
<p>Add:</p>
<pre><code class="language-nginx"># File: /etc/nginx/sites-available/secrets.example.com
server {
    listen 80;
    listen [::]:80;

    server_name secrets.example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_read_timeout 300;
        proxy_send_timeout 300;
    }
}
</code></pre>
<p>Enable the site:</p>
<pre><code class="language-bash">sudo ln -s \
  /etc/nginx/sites-available/secrets.example.com \
  /etc/nginx/sites-enabled/secrets.example.com
</code></pre>
<p>Test the configuration and reload Nginx:</p>
<pre><code class="language-bash">sudo nginx -t
sudo systemctl reload nginx
</code></pre>
<p>Make sure the DNS record points to the Infisical VPS:</p>
<pre><code class="language-text">secrets.example.com    A    &lt;INFISICAL_VPS_IP&gt;
</code></pre>
<p>Then request the certificate:</p>
<pre><code class="language-bash">sudo certbot \
  --nginx \
  -d secrets.example.com
</code></pre>
<p>Verify the result:</p>
<pre><code class="language-bash">curl -fsS https://secrets.example.com/api/status
</code></pre>
<p>Running the public Infisical interface over plain HTTP isn&#39;t an option here. Infisical uses secure cookies for authenticated sessions, and its own documentation recommends SSL for self-hosted deployments.</p>
<h2>Step 7: Create the administrator account</h2>
<p>Open <code>https://secrets.example.com</code> and create your account as soon as the installation becomes reachable. In the Docker Compose installation flow, the first registered user becomes the instance administrator, so this step needs to happen before the instance is shared or widely exposed. For a stricter initial setup, temporarily restrict port 443 to your IP address while creating the first account.</p>
<p>After creating the administrator, configure SMTP for password resets and invitations, invite the developers who need access, enable multi-factor authentication, review whether public sign-up should stay available, and confirm that only the reverse proxy can reach the backend port.</p>
<h2>Step 8: Configure SMTP</h2>
<p>Infisical uses email for invitations, password resets, and notifications. Add the SMTP configuration to <code>/opt/infisical/.env</code>:</p>
<pre><code class="language-dotenv"># File: /opt/infisical/.env
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=&lt;SMTP_USERNAME&gt;
SMTP_PASSWORD=&lt;SMTP_PASSWORD&gt;
SMTP_FROM_ADDRESS=secrets@example.com
SMTP_FROM_NAME=Infisical
</code></pre>
<p>Restart the backend:</p>
<pre><code class="language-bash">cd /opt/infisical

docker compose \
  -f docker-compose.prod.yml \
  restart backend
</code></pre>
<p>Test the password-reset flow before relying on the instance for production infrastructure.</p>
<h2>Step 9: Create the application project</h2>
<p>In Infisical, create a Secrets Management project:</p>
<pre><code class="language-text">Project name: My Application
</code></pre>
<p>Configure three environments:</p>
<pre><code class="language-text">Development    dev
Staging        staging
Production     prod
</code></pre>
<p>Infisical projects organize secrets into environments, folders, and individual values, and the same key can exist in each environment with a different value. Using a Next.js and Payload stack as an example:</p>
<table>
<thead>
<tr>
<th>Key</th>
<th>Development</th>
<th>Staging</th>
<th>Production</th>
</tr>
</thead>
<tbody><tr>
<td><code>DATABASE_URL</code></td>
<td>Local database</td>
<td>Staging database</td>
<td>Production database</td>
</tr>
<tr>
<td><code>PAYLOAD_SECRET</code></td>
<td>Development value</td>
<td>Staging value</td>
<td>Production value</td>
</tr>
<tr>
<td><code>S3_BUCKET</code></td>
<td>Development bucket</td>
<td>Staging bucket</td>
<td>Production bucket</td>
</tr>
<tr>
<td><code>BREVO_API_KEY</code></td>
<td>Test or development key</td>
<td>Staging key</td>
<td>Production key</td>
</tr>
</tbody></table>
<p>Keep the key names consistent across environments regardless of what framework is consuming them. A starting set for a Payload application might look like:</p>
<pre><code class="language-dotenv"># File: Infisical → My Application → dev/staging/prod
NODE_ENV=production
APP_ENV=
SERVER_URL=

DATABASE_URL=
DATABASE_URL_UNPOOLED=

PAYLOAD_SECRET=

S3_ENDPOINT=
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=

REDIS_URL=

SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=

BREVO_API_KEY=

CRON_SECRET=
PREVIEW_SECRET=
</code></pre>
<p>A different stack would list different keys, but the structure, one project, three environments, consistent naming, stays the same. You can import an existing <code>.env</code> file through the Infisical interface, but review every value first and make sure it lands in the correct environment.</p>
<h2>Step 10: Keep <code>.env.example</code> in Git</h2>
<p>Infisical becomes the source of truth for the actual secret values, but the repository should still document which variables the application expects. Create:</p>
<pre><code class="language-dotenv"># File: .env.example

NODE_ENV=
APP_ENV=
SERVER_URL=

DATABASE_URL=
DATABASE_URL_UNPOOLED=

PAYLOAD_SECRET=

S3_ENDPOINT=
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=

REDIS_URL=

SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=

BREVO_API_KEY=

CRON_SECRET=
PREVIEW_SECRET=
</code></pre>
<p>This documents the configuration contract without containing credentials, and it&#39;s worth keeping around no matter which application reads it.</p>
<h2>Step 11: Install the Infisical CLI locally</h2>
<p>On macOS:</p>
<pre><code class="language-bash">brew install infisical/get-cli/infisical
</code></pre>
<p>On Debian or Ubuntu:</p>
<pre><code class="language-bash">curl -1sLf \
  &quot;https://artifacts-cli.infisical.com/setup.deb.sh&quot; \
  | sudo -E bash

sudo apt-get update
sudo apt-get install -y infisical
</code></pre>
<p>Infisical also distributes the CLI through NPM:</p>
<pre><code class="language-bash">npm install -g @infisical/cli
</code></pre>
<p>For a production server, pin the CLI version instead of silently installing an arbitrary future release. Infisical explicitly recommends version pinning for production environments.</p>
<h2>Step 12: Connect your local project</h2>
<p>Navigate to the application directory, whatever it contains:</p>
<pre><code class="language-bash">cd /path/to/my-application
</code></pre>
<p>Log in:</p>
<pre><code class="language-bash">infisical login
</code></pre>
<p>The current CLI can interactively select a self-hosted instance, or you can define your domain explicitly:</p>
<pre><code class="language-bash">export INFISICAL_DOMAIN=&quot;https://secrets.example.com&quot;
</code></pre>
<p>Initialize the project:</p>
<pre><code class="language-bash">infisical init
</code></pre>
<p>Select the Infisical project when prompted. This creates <code>.infisical.json</code>. Update it to include the self-hosted domain and default environment:</p>
<pre><code class="language-json">{
  &quot;workspaceId&quot;: &quot;&lt;INFISICAL_PROJECT_ID&gt;&quot;,
  &quot;defaultEnvironment&quot;: &quot;dev&quot;,
  &quot;domain&quot;: &quot;https://secrets.example.com&quot;
}
</code></pre>
<p>Infisical&#39;s project configuration file stores the project reference, default environment, and optional self-hosted domain. The documentation notes that this file is usually committed to the repository, so review the configured domain carefully before committing it. The file itself never contains the secret values.</p>
<h2>Step 13: Run your application locally through Infisical</h2>
<p>Your application code doesn&#39;t need to import an Infisical SDK. For a Next.js and Payload project, Payload keeps reading its configuration from <code>process.env</code> exactly as before:</p>
<pre><code class="language-ts">// File: src/payload.config.ts

import { buildConfig } from &#39;payload&#39;
import { postgresAdapter } from &#39;@payloadcms/db-postgres&#39;

export default buildConfig({
  secret: process.env.PAYLOAD_SECRET ?? &#39;&#39;,
  serverURL: process.env.SERVER_URL,
  db: postgresAdapter({
    pool: {
      connectionString: process.env.DATABASE_URL,
    },
  }),
})
</code></pre>
<p>Payload officially supports reading environment variables directly from <code>process.env</code> inside its configuration, and the same is true for essentially any framework that reads configuration from the process environment. Start the application through Infisical:</p>
<pre><code class="language-bash">infisical run --env=dev -- pnpm dev
</code></pre>
<p>Infisical retrieves the development values and injects them into the <code>pnpm dev</code> process. Swap <code>pnpm dev</code> for whatever your stack uses to start locally, <code>python manage.py runserver</code>, <code>go run .</code>, <code>rails server</code>, and the pattern holds. You can add a convenience script:</p>
<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;next dev&quot;,
    &quot;dev:secrets&quot;: &quot;infisical run --env=dev -- pnpm dev&quot;,
    &quot;build&quot;: &quot;next build&quot;,
    &quot;start&quot;: &quot;next start&quot;
  }
}
</code></pre>
<p>Run:</p>
<pre><code class="language-bash">pnpm dev:secrets
</code></pre>
<p>Verify that a value exists without printing the value itself:</p>
<pre><code class="language-bash">infisical run --env=dev -- \
  node -e &quot;console.log(&#39;DATABASE_URL available:&#39;, Boolean(process.env.DATABASE_URL))&quot;
</code></pre>
<p>Expected output:</p>
<pre><code class="language-text">DATABASE_URL available: true
</code></pre>
<h2>Step 14: Handle build-time variables (the Next.js case)</h2>
<p>Runtime injection through <code>infisical run</code> covers most frameworks completely, but a few tools also embed configuration into a build artifact rather than reading it purely at runtime. Next.js is the clearest example, so it&#39;s worth walking through even in an otherwise stack-agnostic setup.</p>
<p>Server-side values like these should never be prefixed with <code>NEXT_PUBLIC_</code>:</p>
<pre><code class="language-text">DATABASE_URL
PAYLOAD_SECRET
S3_SECRET_ACCESS_KEY
SMTP_PASSWORD
BREVO_API_KEY
</code></pre>
<p>A value prefixed with <code>NEXT_PUBLIC_</code> is meant for browser access:</p>
<pre><code class="language-text">NEXT_PUBLIC_SITE_URL
NEXT_PUBLIC_ANALYTICS_ID
NEXT_PUBLIC_MAPBOX_TOKEN
</code></pre>
<p>Next.js replaces <code>NEXT_PUBLIC_</code> references during <code>next build</code>, embedding the values directly into the browser bundle, where they stay fixed after the build completes. If your framework has an equivalent build-time embedding step, check its documentation for the same distinction before wiring up Infisical, since the fix is always the same: run the build itself through <code>infisical run</code> so the values are present at build time, not just at process startup. Never solve a missing browser variable by marking a secret public.</p>
<h2>Step 15: Create a production machine identity</h2>
<p>Developers should log in as themselves. Servers, regardless of what they run, shouldn&#39;t use a developer account. For production, create a machine identity:</p>
<pre><code class="language-text">Organization Settings
  → Access Control
  → Identities
  → Create identity
</code></pre>
<p>Name it clearly:</p>
<pre><code class="language-text">my-application-production
</code></pre>
<p>Configure Universal Auth, which gives the machine identity a Client ID and Client Secret. The server exchanges these for a short-lived access token. Next, add the identity to the project:</p>
<pre><code class="language-text">Project Settings
  → Access Control
  → Machine Identities
  → Add identity
</code></pre>
<p>Grant it read-only access to:</p>
<pre><code class="language-text">Project: My Application
Environment: Production
Path: /
</code></pre>
<p>The production identity should never read development, staging, or unrelated client projects. Create a separate identity for staging:</p>
<pre><code class="language-text">my-application-staging
</code></pre>
<p>Give it access only to the staging environment. The identities stay separate because their permission sets are different, and that separation matters regardless of what&#39;s actually running on either server.</p>
<h2>Step 16: Store the bootstrap credential on the VPS</h2>
<p>The production server still needs an initial credential that lets it authenticate to Infisical. Create a dedicated application user if one doesn&#39;t already exist:</p>
<pre><code class="language-bash">sudo adduser --system \
  --group \
  --home /srv/my-application \
  my-application
</code></pre>
<p>Create the configuration directory:</p>
<pre><code class="language-bash">sudo install \
  -d \
  -m 750 \
  -o root \
  -g my-application \
  /etc/my-application
</code></pre>
<p>Create the bootstrap file:</p>
<pre><code class="language-bash">sudo nano /etc/my-application/infisical.env
</code></pre>
<p>Add:</p>
<pre><code class="language-dotenv"># File: /etc/my-application/infisical.env
INFISICAL_DOMAIN=https://secrets.example.com
INFISICAL_PROJECT_ID=&lt;PROJECT_ID&gt;
INFISICAL_ENV=prod

INFISICAL_CLIENT_ID=&lt;PRODUCTION_CLIENT_ID&gt;
INFISICAL_CLIENT_SECRET=&lt;PRODUCTION_CLIENT_SECRET&gt;

INFISICAL_DISABLE_UPDATE_CHECK=true
</code></pre>
<p>Protect it:</p>
<pre><code class="language-bash">sudo chown root:my-application \
  /etc/my-application/infisical.env

sudo chmod 640 \
  /etc/my-application/infisical.env
</code></pre>
<p>This file is the remaining secret-zero credential. It&#39;s still sensitive, but it&#39;s far narrower than a complete production <code>.env</code> file: it can be revoked independently, and its permissions can be limited to one project and one environment, no matter what application ends up consuming the secrets it unlocks.</p>
<h2>Step 17: Create a reusable Infisical execution wrapper</h2>
<p>Create:</p>
<pre><code class="language-bash">sudo nano /usr/local/bin/my-application-infisical
</code></pre>
<p>Add:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# File: /usr/local/bin/my-application-infisical

set -euo pipefail

set -a
source /etc/my-application/infisical.env
set +a

export PATH=&quot;/usr/local/bin:/usr/bin:/bin&quot;

export INFISICAL_TOKEN=&quot;$(
  infisical login \
    --method=universal-auth \
    --client-id=&quot;$INFISICAL_CLIENT_ID&quot; \
    --client-secret=&quot;$INFISICAL_CLIENT_SECRET&quot; \
    --silent \
    --plain
)&quot;

exec infisical run \
  --projectId=&quot;$INFISICAL_PROJECT_ID&quot; \
  --env=&quot;$INFISICAL_ENV&quot; \
  --path=&quot;/&quot; \
  -- &quot;$@&quot;
</code></pre>
<p>Make it executable:</p>
<pre><code class="language-bash">sudo chmod 750 \
  /usr/local/bin/my-application-infisical

sudo chown root:my-application \
  /usr/local/bin/my-application-infisical
</code></pre>
<p>The wrapper does two things: it exchanges the Universal Auth credentials for a short-lived access token, then starts whatever command you hand it with the correct secrets injected. Infisical&#39;s CLI requires <code>--projectId</code> when authenticating through a machine identity, and it selects the environment and path through <code>--env</code> and <code>--path</code>. Because the wrapper just wraps a command, it works identically whether that command is <code>pnpm start</code>, <code>gunicorn</code>, or a compiled Go binary.</p>
<p>Test it:</p>
<pre><code class="language-bash">sudo -u my-application \
  /usr/local/bin/my-application-infisical \
  node -e &quot;console.log(Boolean(process.env.PAYLOAD_SECRET))&quot;
</code></pre>
<p>Expected output:</p>
<pre><code class="language-text">true
</code></pre>
<h2>Step 18: Build and migrate with production secrets</h2>
<p>Some frameworks, Next.js and Payload among them, need environment variables present while evaluating configuration during the build or a migration step, not just at process startup. Run the build through the wrapper:</p>
<pre><code class="language-bash">cd /srv/my-application/current

sudo -u my-application \
  /usr/local/bin/my-application-infisical \
  pnpm build
</code></pre>
<p>Run Payload migrations through the same wrapper:</p>
<pre><code class="language-bash">cd /srv/my-application/current

sudo -u my-application \
  /usr/local/bin/my-application-infisical \
  pnpm payload migrate
</code></pre>
<p>The migration now receives the production database connection from Infisical rather than from a local file. Run migrations before restarting the production application, and confirm a recent database backup exists first.</p>
<h2>Step 19: Run the application with systemd</h2>
<p>Create the service:</p>
<pre><code class="language-bash">sudo nano /etc/systemd/system/my-application.service
</code></pre>
<p>Add:</p>
<pre><code class="language-ini"># File: /etc/systemd/system/my-application.service
[Unit]
Description=My application
After=network-online.target
Wants=network-online.target

[Service]
Type=simple

User=my-application
Group=my-application

WorkingDirectory=/srv/my-application/current

ExecStart=/usr/local/bin/my-application-infisical pnpm start

Restart=on-failure
RestartSec=5

Environment=NODE_ENV=production
Environment=PATH=/usr/local/bin:/usr/bin:/bin

NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=full

[Install]
WantedBy=multi-user.target
</code></pre>
<p>Swap <code>pnpm start</code> in <code>ExecStart</code> for whatever command actually starts your application. Reload systemd, then enable and start it:</p>
<pre><code class="language-bash">sudo systemctl daemon-reload
sudo systemctl enable --now my-application
</code></pre>
<p>Check the status and follow the logs:</p>
<pre><code class="language-bash">sudo systemctl status my-application
sudo journalctl -u my-application -f
</code></pre>
<p>The startup flow is now the same regardless of stack: systemd calls the execution wrapper, which logs in through Universal Auth, gets a short-lived access token, runs <code>infisical run</code>, and finally starts your application.</p>
<h2>Step 20: Apply a changed secret</h2>
<p>Suppose you replace <code>BREVO_API_KEY</code>. Update the value in the production environment inside Infisical. The currently running process will still have the old value in memory, since environment variables don&#39;t update inside an already running process. Restart the service:</p>
<pre><code class="language-bash">sudo systemctl restart my-application
</code></pre>
<p>Infisical supports a <code>--watch</code> option that restarts a command automatically when secrets change, but its documentation recommends that feature for development rather than production. I prefer explicit production restarts tied to a reviewed configuration change, and that preference holds regardless of what&#39;s running behind the wrapper.</p>
<h2>Step 21: Use Infisical with Docker</h2>
<p>For a Docker deployment, install the Infisical CLI inside the application image. For an Alpine-based image:</p>
<pre><code class="language-dockerfile"># File: Dockerfile

RUN apk add --no-cache bash curl

RUN curl -1sLf \
  &quot;https://artifacts-cli.infisical.com/setup.apk.sh&quot; \
  | sh

RUN apk add --no-cache infisical
</code></pre>
<p>Create an entrypoint:</p>
<pre><code class="language-bash"># File: docker/infisical-entrypoint.sh

#!/usr/bin/env bash

set -euo pipefail

export INFISICAL_TOKEN=&quot;$(
  infisical login \
    --method=universal-auth \
    --client-id=&quot;$INFISICAL_CLIENT_ID&quot; \
    --client-secret=&quot;$INFISICAL_CLIENT_SECRET&quot; \
    --silent \
    --plain
)&quot;

exec infisical run \
  --projectId=&quot;$INFISICAL_PROJECT_ID&quot; \
  --env=&quot;$INFISICAL_ENV&quot; \
  --path=&quot;${INFISICAL_PATH:-/}&quot; \
  -- &quot;$@&quot;
</code></pre>
<p>Copy it into the image:</p>
<pre><code class="language-dockerfile"># File: Dockerfile

COPY docker/infisical-entrypoint.sh \
  /usr/local/bin/infisical-entrypoint

RUN chmod +x \
  /usr/local/bin/infisical-entrypoint

ENTRYPOINT [&quot;/usr/local/bin/infisical-entrypoint&quot;]

CMD [&quot;pnpm&quot;, &quot;start&quot;]
</code></pre>
<p>The application image still starts its normal command; the entrypoint adds the authentication and secret-injection step in front of it. Infisical&#39;s Docker integration follows this same general pattern for any base image: install the CLI in the container and use <code>infisical run</code> as the wrapper around whatever the container was already going to run.</p>
<h2>Step 22: Configure Docker Compose</h2>
<p>Store the bootstrap credentials on the host:</p>
<pre><code class="language-bash">sudo mkdir -p /etc/my-application

sudo nano \
  /etc/my-application/infisical.env
</code></pre>
<p>Add:</p>
<pre><code class="language-dotenv"># File: /etc/my-application/infisical.env
INFISICAL_DOMAIN=https://secrets.example.com
INFISICAL_PROJECT_ID=&lt;PROJECT_ID&gt;
INFISICAL_ENV=prod
INFISICAL_CLIENT_ID=&lt;CLIENT_ID&gt;
INFISICAL_CLIENT_SECRET=&lt;CLIENT_SECRET&gt;
</code></pre>
<p>Protect the file:</p>
<pre><code class="language-bash">sudo chmod 600 \
  /etc/my-application/infisical.env
</code></pre>
<p>The Compose service can use it:</p>
<pre><code class="language-yaml"># File: docker-compose.production.yml

services:
  app:
    image: my-application:&lt;VERSION&gt;
    restart: unless-stopped

    env_file:
      - /etc/my-application/infisical.env

    environment:
      NODE_ENV: production
      INFISICAL_DISABLE_UPDATE_CHECK: &quot;true&quot;

    ports:
      - &quot;127.0.0.1:3000:3000&quot;
</code></pre>
<p>Start it:</p>
<pre><code class="language-bash">docker compose \
  -f docker-compose.production.yml \
  up -d
</code></pre>
<p>Check the logs:</p>
<pre><code class="language-bash">docker compose \
  -f docker-compose.production.yml \
  logs --tail=100 app
</code></pre>
<p>The container receives only the bootstrap configuration through the host file, and the actual application secrets get retrieved when the container starts. A user with unrestricted access to the Docker daemon effectively has root-level control over every container and its configuration, so access to Docker itself needs to stay restricted regardless of which secrets manager sits behind it.</p>
<h2>Step 23: Handle Docker build-time values</h2>
<p>Runtime injection doesn&#39;t automatically cover build-time variables. This matters most for anything like Next.js&#39;s <code>NEXT_PUBLIC_*</code> values, which get embedded during the build step rather than read at container startup.</p>
<p>When building the image, provide the Infisical access token as a BuildKit secret rather than a normal Docker build argument. Authenticate on the build machine:</p>
<pre><code class="language-bash">export INFISICAL_DOMAIN=&quot;https://secrets.example.com&quot;

export INFISICAL_TOKEN=&quot;$(
  infisical login \
    --method=universal-auth \
    --client-id=&quot;$INFISICAL_CLIENT_ID&quot; \
    --client-secret=&quot;$INFISICAL_CLIENT_SECRET&quot; \
    --silent \
    --plain
)&quot;
</code></pre>
<p>Build:</p>
<pre><code class="language-bash">DOCKER_BUILDKIT=1 docker build \
  --secret id=infisical_token,env=INFISICAL_TOKEN \
  --build-arg INFISICAL_PROJECT_ID=&quot;$INFISICAL_PROJECT_ID&quot; \
  --build-arg INFISICAL_ENV=&quot;$INFISICAL_ENV&quot; \
  --build-arg INFISICAL_DOMAIN=&quot;$INFISICAL_DOMAIN&quot; \
  -t my-application:&lt;VERSION&gt; \
  .
</code></pre>
<p>In the builder stage:</p>
<pre><code class="language-dockerfile"># File: Dockerfile

ARG INFISICAL_PROJECT_ID
ARG INFISICAL_ENV
ARG INFISICAL_DOMAIN

RUN --mount=type=secret,id=infisical_token,required=true \
    export INFISICAL_TOKEN=&quot;$(cat /run/secrets/infisical_token)&quot; &amp;&amp; \
    export INFISICAL_DOMAIN=&quot;$INFISICAL_DOMAIN&quot; &amp;&amp; \
    infisical run \
      --projectId=&quot;$INFISICAL_PROJECT_ID&quot; \
      --env=&quot;$INFISICAL_ENV&quot; \
      --path=&quot;/&quot; \
      -- pnpm build
</code></pre>
<p>The access token gets mounted only for that build instruction instead of landing in a normal image layer or build argument. Keep in mind that any value your framework deliberately marks as public, like <code>NEXT_PUBLIC_*</code> in Next.js, is meant to end up in the browser bundle. BuildKit protects the Infisical token here, not values you&#39;ve already decided should be public.</p>
<h2>Step 24: Separate application paths when necessary</h2>
<p>For a single application process, storing secrets at the project root is usually enough:</p>
<pre><code class="language-text">/
</code></pre>
<p>As the architecture grows, folders can separate access:</p>
<pre><code class="language-text">/
├── app
├── workers
├── migrations
└── automation
</code></pre>
<p>The web process might receive <code>/app</code>, a background worker <code>/workers</code>, and a migration job <code>/migrations</code>, regardless of what language each of those components is written in. Infisical supports injecting secrets from one or several paths:</p>
<pre><code class="language-bash">infisical run \
  --path=&quot;/app&quot; \
  --path=&quot;/shared&quot; \
  -- pnpm start
</code></pre>
<p>Don&#39;t build out a complicated folder hierarchy before you actually have different permission requirements. Folders earn their place once genuinely separate workloads need different subsets of secrets.</p>
<h2>Step 25: Rotate a machine identity credential</h2>
<p>To rotate the production Client Secret, generate a new Client Secret for the existing machine identity, add it to <code>/etc/my-application/infisical.env</code>, restart the application, confirm it starts correctly, and revoke the old Client Secret. The new process authenticates with the new bootstrap credential immediately. None of this requires rotating the database password, storage key, or any other application secret just because the machine identity credential changed, which is one of the main benefits of separating application secrets from machine authentication in the first place.</p>
<h2>Step 26: Back up Infisical correctly</h2>
<p>The PostgreSQL database contains the persistent Infisical data. Create a dump:</p>
<pre><code class="language-bash">cd /opt/infisical

docker compose \
  -f docker-compose.prod.yml \
  exec -T db \
  pg_dump \
    -U infisical \
    infisical \
  | gzip \
  &gt; &quot;infisical-$(date +%F-%H%M%S).sql.gz&quot;
</code></pre>
<p>The official Docker Compose guide identifies the PostgreSQL volume as the critical persistent store and provides <code>pg_dump</code> as the backup mechanism, but the database alone isn&#39;t the complete recovery plan. Your deployment also depends on <code>/opt/infisical/.env</code>, which contains <code>ENCRYPTION_KEY</code>, <code>AUTH_SECRET</code>, <code>POSTGRES_PASSWORD</code>, and <code>SITE_URL</code>. Because <code>ENCRYPTION_KEY</code> is required for Infisical&#39;s own encryption and decryption operations, a working recovery plan needs both the database backup and the original platform encryption key together.</p>
<p>Back up the following through an encrypted backup system: the PostgreSQL dump, <code>/opt/infisical/.env</code>, <code>docker-compose.prod.yml</code>, the Nginx configuration, TLS and DNS recovery information, and the pinned Infisical version. Keep the only copy off the Infisical VPS itself, and actually test restoring it. An untested backup is a guess, not a recovery plan.</p>
<h2>Step 27: Plan for Infisical availability</h2>
<p>Without <code>--watch</code>, <code>infisical run</code> retrieves secrets when the application starts and injects them into the new process. Once an application is running, its environment variables already sit in process memory, so a temporary Infisical outage won&#39;t stop that existing process. It can, however, block a new deployment, an application restart, a container replacement, a server reboot, a migration command, or a new worker from starting, no matter what that worker happens to run.</p>
<p>That&#39;s why the Infisical instance itself needs monitoring, database backups, resource headroom, controlled upgrades, a documented restore process, and a stable domain with a valid TLS certificate. A secrets manager removes configuration sprawl, and it also becomes part of your deployment control plane, so it deserves the same operational care as anything else on that critical path.</p>
<h2>Common problems</h2>
<h3>The CLI connects to Infisical Cloud instead of your instance</h3>
<p>Set the self-hosted domain:</p>
<pre><code class="language-bash">export INFISICAL_DOMAIN=&quot;https://secrets.example.com&quot;
</code></pre>
<p>Or add it to <code>.infisical.json</code>:</p>
<pre><code class="language-json">{
  &quot;domain&quot;: &quot;https://secrets.example.com&quot;
}
</code></pre>
<h3>The server receives <code>401</code> or <code>403</code></h3>
<p>Check whether the Client ID is correct, whether the Client Secret is current, whether the identity was actually added to the project, and whether it has access to the selected environment and folder path. Authentication and project access are separate steps, so a machine identity can authenticate successfully and still lack access to a project.</p>
<h3>Local development works, but production doesn&#39;t</h3>
<p>Check <code>INFISICAL_PROJECT_ID</code>, <code>INFISICAL_ENV</code>, <code>INFISICAL_DOMAIN</code>, <code>INFISICAL_PATH</code>, the production machine identity&#39;s permissions, and outbound HTTPS connectivity from the server. The staging identity shouldn&#39;t be assumed to have production access just because it exists in the same project.</p>
<h3>A changed secret isn&#39;t being picked up</h3>
<p>Restart the process:</p>
<pre><code class="language-bash">sudo systemctl restart my-application
</code></pre>
<p>Or recreate the container:</p>
<pre><code class="language-bash">docker compose \
  -f docker-compose.production.yml \
  up -d \
  --force-recreate
</code></pre>
<p>Environment variables don&#39;t mutate inside an already running process, in Node.js or anywhere else.</p>
<h3>A build-time value like <code>NEXT_PUBLIC_*</code> is still old</h3>
<p>Rebuild the application. Next.js freezes <code>NEXT_PUBLIC_</code> values during <code>next build</code>, so restarting the existing build artifact won&#39;t pick up a new value; the build has to run again.</p>
<h3>The database was restored, but secrets can&#39;t be read</h3>
<p>Confirm the restored deployment uses the original <code>ENCRYPTION_KEY</code>. A database dump without the matching encryption configuration is an incomplete recovery package.</p>
<h3>Infisical works locally but not behind Nginx</h3>
<p>Check:</p>
<pre><code class="language-bash">curl -fsS http://127.0.0.1:8080/api/status
curl -fsS https://secrets.example.com/api/status
sudo nginx -t
sudo journalctl -u nginx --tail=100
</code></pre>
<p>Also confirm that <code>SITE_URL=https://secrets.example.com</code> matches the actual public URL.</p>
<h2>What changed compared with <code>.env</code> files</h2>
<p>Before this setup, a developer laptop held <code>.env.local</code>, the staging VPS held <code>.env.staging</code>, the production VPS held <code>.env.production</code>, and the CI runner held its own copy of the production values, all independently. After this setup, Infisical holds development, staging, and production centrally, developers authenticate with a personal login, and the staging and production servers authenticate through their own machine identities.</p>
<p>The application code stays just as simple as it was before:</p>
<pre><code class="language-ts">process.env.DATABASE_URL
process.env.PAYLOAD_SECRET
process.env.S3_SECRET_ACCESS_KEY
</code></pre>
<p>What changed is entirely operational: one source of truth, consistent key names, explicit environment separation, separate human and machine access, revocable server credentials, a documented startup workflow, and a repeatable recovery model, none of which depends on which framework or language sits on top of it.</p>
<h2>FAQ</h2>
<p><strong>Does Infisical only work with Node.js applications?</strong>
No. Infisical&#39;s CLI wraps any command through <code>infisical run -- &lt;command&gt;</code>, so it works the same way for a Python, Go, Ruby, or PHP process as it does for Next.js or Payload. The examples here use Node.js because that&#39;s the stack I run, but the pattern generalizes directly.</p>
<p><strong>Do I need separate Infisical projects for separate applications?</strong>
Usually yes, one project per application keeps environments and access boundaries clean, especially when different applications belong to different clients. A small internal toolset with shared ownership can sometimes live in one project with folders separating the pieces, but that&#39;s a judgment call based on who needs access to what.</p>
<p><strong>What&#39;s the actual difference between Infisical and a tool like Vault?</strong>
Infisical focuses on a simpler, Vercel-like mental model of projects, environments, and secrets, with a CLI and dashboard that get you running quickly. HashiCorp Vault and OpenBao add dynamic credentials, PKI, and more advanced policy engines, at the cost of more operational complexity. Most small-to-mid-size teams get everything they need from Infisical without touching Vault&#39;s extra surface area.</p>
<p><strong>Can I run Infisical without Docker?</strong>
The official deployment path is Docker Compose, which is also the path documented and supported by Infisical itself. Running it another way is possible in principle but puts you outside the tested deployment model, so I&#39;d stick with Docker Compose unless you have a specific reason not to.</p>
<p><strong>What happens to my running application if the Infisical server goes down?</strong>
Nothing, as long as the application is already running, since its environment variables live in process memory once the process has started. An Infisical outage blocks new deployments, restarts, and container replacements until the instance comes back, which is exactly why it needs its own monitoring and backup plan.</p>
<h2>Conclusion</h2>
<p>Self-hosting Infisical doesn&#39;t remove environment variables from the picture. It gives them a proper management and delivery system that works the same way no matter what&#39;s consuming them. Developers authenticate as people, application servers authenticate as machine identities, staging and production receive separate values, and the application itself keeps reading standard environment variables without getting tightly coupled to a vendor-specific SDK.</p>
<p>The critical implementation decisions carry across any stack: run Infisical on dedicated infrastructure, put it behind HTTPS, pin the deployed versions, keep the backend port private, separate development, staging, and production, use Universal Auth for VPS workloads, store only restricted bootstrap credentials locally, restart production explicitly after secret changes, handle build-time values where your framework requires it, and back up both PostgreSQL and the Infisical encryption configuration together.</p>
<p>The result is a provider-neutral secrets workflow that works across local development, systemd, Docker, and whatever application framework you&#39;re actually running, Next.js and Payload CMS included.</p>
<p>For the broader reasoning behind this setup, read <a href="/blog/how-to-manage-environment-variables-and-secrets">How to Manage Environment Variables and Secrets Across Environments</a>. For the complete application deployment layer, read <a href="/blog/self-host-nextjs-payload-vps-github-ci-cd">Self-Host Next.js and Payload on VPS: Complete Guide</a>.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/self-host-infisical-vps-secrets-management</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/self-host-infisical-vps-secrets-management</guid>
            <category><![CDATA[Docker]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Wed, 15 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;Self-hosting Infisical gives any application a central, provider-neutral place to store secrets, separate development, staging, and production values, and issue separate identities to developers and servers, all without tying your setup to Vercel, a single cloud provider, or a stack-specific SDK. This guide covers the complete implementation: standing up Infisical on a dedicated VPS behind HTTPS, structuring projects and environments, creating machine identities for staging and production, and injecting secrets into systemd services and Docker containers. Everything here works for any application that reads configuration from environment variables, and I&amp;#39;ll use my own Next.js and Payload CMS stack as the running example throughout.&lt;/p&gt;
&lt;p&gt;In the previous article, I covered why &lt;code&gt;.env&lt;/code&gt; files stop being a sufficient secrets-management system once an application picks up multiple developers, deployment environments, servers, and external services. This one covers what replaces them. I wanted something that felt close to managing environment variables in Vercel, without being tied to Vercel, and that would work across local development, staging, production, Docker containers, background workers, and plain VPS deployments regardless of framework. Infisical is what I landed on, and this guide walks through the full setup.&lt;/p&gt;
&lt;h2&gt;What this guide covers&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Self-hosting Infisical on a dedicated VPS&lt;/li&gt;
&lt;li&gt;Putting it behind HTTPS&lt;/li&gt;
&lt;li&gt;Creating development, staging, and production environments&lt;/li&gt;
&lt;li&gt;Connecting a local project, whatever it&amp;#39;s built with&lt;/li&gt;
&lt;li&gt;Giving staging and production servers separate machine identities&lt;/li&gt;
&lt;li&gt;Injecting secrets into a systemd service&lt;/li&gt;
&lt;li&gt;Injecting secrets into Docker containers&lt;/li&gt;
&lt;li&gt;Handling build-time variables correctly (using Next.js as the example)&lt;/li&gt;
&lt;li&gt;Backing up the Infisical database and encryption keys&lt;/li&gt;
&lt;li&gt;Rotating secrets without falling back to copied &lt;code&gt;.env&lt;/code&gt; files&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The end result looks like this regardless of what you&amp;#39;re running:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Developer laptop
  └── Infisical CLI
        └── development secrets

Staging VPS
  └── staging machine identity
        └── staging secrets

Production VPS
  └── production machine identity
        └── production secrets

Infisical VPS
  ├── Infisical
  ├── PostgreSQL
  ├── Redis
  └── HTTPS reverse proxy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your application keeps reading ordinary environment variables through whatever mechanism your language or framework already uses, &lt;code&gt;process.env&lt;/code&gt; in Node.js being the obvious one. Infisical controls where those values are stored, who may retrieve them, and which environment each application receives. The application code itself doesn&amp;#39;t need to know Infisical exists.&lt;/p&gt;
&lt;h2&gt;What this guide assumes&lt;/h2&gt;
&lt;p&gt;This guide assumes Ubuntu or Debian on the servers, Docker and Docker Compose on the Infisical VPS, a domain such as &lt;code&gt;secrets.example.com&lt;/code&gt;, separate staging and production servers, and a small team or small-scale production deployment. The application examples use Next.js with Payload CMS and &lt;code&gt;pnpm&lt;/code&gt;, since that&amp;#39;s the stack I run most often, but nothing about Infisical itself is specific to that combination. Swap in a Django app, a Go service, a Rails app, or anything else that reads configuration from the environment, and the Infisical side of this guide stays the same.&lt;/p&gt;
&lt;p&gt;Infisical&amp;#39;s official Docker Compose template runs the application, PostgreSQL, and Redis on a single Docker host. Infisical documents this deployment model as suitable for development, testing, proofs of concept, and smaller deployments that don&amp;#39;t require high availability, with a documented minimum of two CPU cores, 4 GB RAM, and 20 GB disk for the complete stack, and higher resources recommended beyond that.&lt;/p&gt;
&lt;p&gt;For this guide, I&amp;#39;d use at least:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;4 vCPU
8 GB RAM
50 GB SSD
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A smaller server works fine for testing, but a secrets manager is infrastructure that every one of your applications may depend on during startup, so I&amp;#39;d rather give it comfortable headroom from the start. For a larger organization or a high-availability requirement, run Infisical against external PostgreSQL and Redis services or a more resilient orchestration setup.&lt;/p&gt;
&lt;h2&gt;The target architecture&lt;/h2&gt;
&lt;p&gt;The Infisical server should sit separately from your application servers, regardless of what those application servers run.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Internet
  │
  ▼
secrets.example.com
  │
  ▼
Nginx with TLS
  │
  ▼
Infisical container
  ├── PostgreSQL container
  └── Redis container
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Application servers connect to the Infisical API over HTTPS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Production VPS
  │
  ├── Client ID
  ├── Client Secret
  │
  ▼
Infisical Universal Auth
  │
  ▼
Short-lived access token
  │
  ▼
Production secrets
  │
  ▼
Application process
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application server never stores the database password, application secret, storage credentials, SMTP password, and every external API key in a local &lt;code&gt;.env.production&lt;/code&gt; file. It stores one restricted bootstrap credential that lets it request the correct secrets from Infisical, and that&amp;#39;s true whether the process at the bottom of that chain is Next.js, Payload, or something else entirely.&lt;/p&gt;
&lt;h2&gt;Step 1: Prepare the Infisical VPS&lt;/h2&gt;
&lt;p&gt;Start by creating a dedicated installation directory:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo mkdir -p /opt/infisical
sudo chown &amp;quot;$USER&amp;quot;:&amp;quot;$USER&amp;quot; /opt/infisical
cd /opt/infisical
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify that Docker and Docker Compose are installed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker --version
docker compose version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Download the official Docker Compose template and example configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsSL \
  -o docker-compose.prod.yml \
  https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml

curl -fsSL \
  -o .env \
  https://raw.githubusercontent.com/Infisical/infisical/main/.env.example
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Protect the configuration file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;chmod 600 .env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The downloaded &lt;code&gt;.env&lt;/code&gt; contains example credentials that must not be used in production. Infisical explicitly marks the example encryption and authentication values as unsafe for production use.&lt;/p&gt;
&lt;h2&gt;Step 2: Pin the Infisical image&lt;/h2&gt;
&lt;p&gt;The current official Compose template contains:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;image: infisical/infisical:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It also includes a comment telling operators to pin the image to a specific tag. Open the file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nano docker-compose.prod.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Change:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;image: infisical/infisical:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To a specific version that you&amp;#39;ve reviewed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;image: infisical/infisical:&amp;lt;PINNED_VERSION&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A floating &lt;code&gt;latest&lt;/code&gt; tag has no place in infrastructure that stores the credentials for every other application you run. The same principle applies to PostgreSQL and Redis: pin versions, test upgrades in a non-production environment, back up the database, and upgrade deliberately.&lt;/p&gt;
&lt;h2&gt;Step 3: Bind Infisical to localhost&lt;/h2&gt;
&lt;p&gt;The official template publishes the backend directly on port 80:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;ports:
  - 80:8080
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I prefer to expose Infisical only through the reverse proxy. Change the backend port mapping to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  backend:
    ports:
      - &amp;quot;127.0.0.1:8080:8080&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The service is now reachable from the VPS itself at &lt;code&gt;http://127.0.0.1:8080&lt;/code&gt; and stays off the public network interface. Nginx will terminate TLS and forward requests to this local port.&lt;/p&gt;
&lt;h2&gt;Step 4: Generate the platform secrets&lt;/h2&gt;
&lt;p&gt;Infisical requires an encryption key and an authentication secret. Generate the encryption key:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;openssl rand -hex 16
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Generate the authentication secret:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;openssl rand -base64 32
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Generate a PostgreSQL password using hexadecimal characters so it can safely appear inside a connection URI:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;openssl rand -hex 24
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Infisical currently documents &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt; as a random 16-byte hexadecimal string and &lt;code&gt;AUTH_SECRET&lt;/code&gt; as a random 32-byte Base64 string. Open the configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nano /opt/infisical/.env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace the sample values with your generated values:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;# File: /opt/infisical/.env
ENCRYPTION_KEY=&amp;lt;GENERATED_HEX_ENCRYPTION_KEY&amp;gt;
AUTH_SECRET=&amp;lt;GENERATED_BASE64_AUTH_SECRET&amp;gt;

POSTGRES_USER=infisical
POSTGRES_PASSWORD=&amp;lt;GENERATED_POSTGRES_PASSWORD&amp;gt;
POSTGRES_DB=infisical

DB_CONNECTION_URI=postgresql://infisical:&amp;lt;GENERATED_POSTGRES_PASSWORD&amp;gt;@db:5432/infisical
REDIS_URL=redis://redis:6379

SITE_URL=https://secrets.example.com

TELEMETRY_ENABLED=false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Leave the rest of the optional integration settings empty unless you need them. The platform requires an absolute &lt;code&gt;SITE_URL&lt;/code&gt;, including the protocol. PostgreSQL is the persistent data layer here, and Redis handles caching and background work.&lt;/p&gt;
&lt;p&gt;Check the permissions again:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;chmod 600 /opt/infisical/.env
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 5: Start Infisical&lt;/h2&gt;
&lt;p&gt;Start the stack:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /opt/infisical

docker compose \
  -f docker-compose.prod.yml \
  up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check the containers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose \
  -f docker-compose.prod.yml \
  ps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inspect the logs if a container isn&amp;#39;t healthy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose \
  -f docker-compose.prod.yml \
  logs --tail=100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Test the local API:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsS http://127.0.0.1:8080/api/status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should get a successful response from the Infisical backend.&lt;/p&gt;
&lt;h2&gt;Step 6: Configure Nginx and HTTPS&lt;/h2&gt;
&lt;p&gt;Install Nginx and Certbot:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt-get update
sudo apt-get install -y nginx certbot python3-certbot-nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create the Nginx virtual host:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nano /etc/nginx/sites-available/secrets.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;# File: /etc/nginx/sites-available/secrets.example.com
server {
    listen 80;
    listen [::]:80;

    server_name secrets.example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_read_timeout 300;
        proxy_send_timeout 300;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Enable the site:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ln -s \
  /etc/nginx/sites-available/secrets.example.com \
  /etc/nginx/sites-enabled/secrets.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Test the configuration and reload Nginx:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nginx -t
sudo systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure the DNS record points to the Infisical VPS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;secrets.example.com    A    &amp;lt;INFISICAL_VPS_IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then request the certificate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo certbot \
  --nginx \
  -d secrets.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify the result:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsS https://secrets.example.com/api/status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Running the public Infisical interface over plain HTTP isn&amp;#39;t an option here. Infisical uses secure cookies for authenticated sessions, and its own documentation recommends SSL for self-hosted deployments.&lt;/p&gt;
&lt;h2&gt;Step 7: Create the administrator account&lt;/h2&gt;
&lt;p&gt;Open &lt;code&gt;https://secrets.example.com&lt;/code&gt; and create your account as soon as the installation becomes reachable. In the Docker Compose installation flow, the first registered user becomes the instance administrator, so this step needs to happen before the instance is shared or widely exposed. For a stricter initial setup, temporarily restrict port 443 to your IP address while creating the first account.&lt;/p&gt;
&lt;p&gt;After creating the administrator, configure SMTP for password resets and invitations, invite the developers who need access, enable multi-factor authentication, review whether public sign-up should stay available, and confirm that only the reverse proxy can reach the backend port.&lt;/p&gt;
&lt;h2&gt;Step 8: Configure SMTP&lt;/h2&gt;
&lt;p&gt;Infisical uses email for invitations, password resets, and notifications. Add the SMTP configuration to &lt;code&gt;/opt/infisical/.env&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;# File: /opt/infisical/.env
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=&amp;lt;SMTP_USERNAME&amp;gt;
SMTP_PASSWORD=&amp;lt;SMTP_PASSWORD&amp;gt;
SMTP_FROM_ADDRESS=secrets@example.com
SMTP_FROM_NAME=Infisical
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Restart the backend:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /opt/infisical

docker compose \
  -f docker-compose.prod.yml \
  restart backend
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Test the password-reset flow before relying on the instance for production infrastructure.&lt;/p&gt;
&lt;h2&gt;Step 9: Create the application project&lt;/h2&gt;
&lt;p&gt;In Infisical, create a Secrets Management project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Project name: My Application
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Configure three environments:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Development    dev
Staging        staging
Production     prod
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Infisical projects organize secrets into environments, folders, and individual values, and the same key can exist in each environment with a different value. Using a Next.js and Payload stack as an example:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Development&lt;/th&gt;
&lt;th&gt;Staging&lt;/th&gt;
&lt;th&gt;Production&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DATABASE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Local database&lt;/td&gt;
&lt;td&gt;Staging database&lt;/td&gt;
&lt;td&gt;Production database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PAYLOAD_SECRET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Development value&lt;/td&gt;
&lt;td&gt;Staging value&lt;/td&gt;
&lt;td&gt;Production value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;S3_BUCKET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Development bucket&lt;/td&gt;
&lt;td&gt;Staging bucket&lt;/td&gt;
&lt;td&gt;Production bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BREVO_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Test or development key&lt;/td&gt;
&lt;td&gt;Staging key&lt;/td&gt;
&lt;td&gt;Production key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Keep the key names consistent across environments regardless of what framework is consuming them. A starting set for a Payload application might look like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;# File: Infisical → My Application → dev/staging/prod
NODE_ENV=production
APP_ENV=
SERVER_URL=

DATABASE_URL=
DATABASE_URL_UNPOOLED=

PAYLOAD_SECRET=

S3_ENDPOINT=
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=

REDIS_URL=

SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=

BREVO_API_KEY=

CRON_SECRET=
PREVIEW_SECRET=
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A different stack would list different keys, but the structure, one project, three environments, consistent naming, stays the same. You can import an existing &lt;code&gt;.env&lt;/code&gt; file through the Infisical interface, but review every value first and make sure it lands in the correct environment.&lt;/p&gt;
&lt;h2&gt;Step 10: Keep &lt;code&gt;.env.example&lt;/code&gt; in Git&lt;/h2&gt;
&lt;p&gt;Infisical becomes the source of truth for the actual secret values, but the repository should still document which variables the application expects. Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;# File: .env.example

NODE_ENV=
APP_ENV=
SERVER_URL=

DATABASE_URL=
DATABASE_URL_UNPOOLED=

PAYLOAD_SECRET=

S3_ENDPOINT=
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=

REDIS_URL=

SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=

BREVO_API_KEY=

CRON_SECRET=
PREVIEW_SECRET=
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This documents the configuration contract without containing credentials, and it&amp;#39;s worth keeping around no matter which application reads it.&lt;/p&gt;
&lt;h2&gt;Step 11: Install the Infisical CLI locally&lt;/h2&gt;
&lt;p&gt;On macOS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;brew install infisical/get-cli/infisical
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On Debian or Ubuntu:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -1sLf \
  &amp;quot;https://artifacts-cli.infisical.com/setup.deb.sh&amp;quot; \
  | sudo -E bash

sudo apt-get update
sudo apt-get install -y infisical
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Infisical also distributes the CLI through NPM:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install -g @infisical/cli
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a production server, pin the CLI version instead of silently installing an arbitrary future release. Infisical explicitly recommends version pinning for production environments.&lt;/p&gt;
&lt;h2&gt;Step 12: Connect your local project&lt;/h2&gt;
&lt;p&gt;Navigate to the application directory, whatever it contains:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /path/to/my-application
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Log in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical login
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The current CLI can interactively select a self-hosted instance, or you can define your domain explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export INFISICAL_DOMAIN=&amp;quot;https://secrets.example.com&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Initialize the project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Select the Infisical project when prompted. This creates &lt;code&gt;.infisical.json&lt;/code&gt;. Update it to include the self-hosted domain and default environment:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;workspaceId&amp;quot;: &amp;quot;&amp;lt;INFISICAL_PROJECT_ID&amp;gt;&amp;quot;,
  &amp;quot;defaultEnvironment&amp;quot;: &amp;quot;dev&amp;quot;,
  &amp;quot;domain&amp;quot;: &amp;quot;https://secrets.example.com&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Infisical&amp;#39;s project configuration file stores the project reference, default environment, and optional self-hosted domain. The documentation notes that this file is usually committed to the repository, so review the configured domain carefully before committing it. The file itself never contains the secret values.&lt;/p&gt;
&lt;h2&gt;Step 13: Run your application locally through Infisical&lt;/h2&gt;
&lt;p&gt;Your application code doesn&amp;#39;t need to import an Infisical SDK. For a Next.js and Payload project, Payload keeps reading its configuration from &lt;code&gt;process.env&lt;/code&gt; exactly as before:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/payload.config.ts

import { buildConfig } from &amp;#39;payload&amp;#39;
import { postgresAdapter } from &amp;#39;@payloadcms/db-postgres&amp;#39;

export default buildConfig({
  secret: process.env.PAYLOAD_SECRET ?? &amp;#39;&amp;#39;,
  serverURL: process.env.SERVER_URL,
  db: postgresAdapter({
    pool: {
      connectionString: process.env.DATABASE_URL,
    },
  }),
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Payload officially supports reading environment variables directly from &lt;code&gt;process.env&lt;/code&gt; inside its configuration, and the same is true for essentially any framework that reads configuration from the process environment. Start the application through Infisical:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical run --env=dev -- pnpm dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Infisical retrieves the development values and injects them into the &lt;code&gt;pnpm dev&lt;/code&gt; process. Swap &lt;code&gt;pnpm dev&lt;/code&gt; for whatever your stack uses to start locally, &lt;code&gt;python manage.py runserver&lt;/code&gt;, &lt;code&gt;go run .&lt;/code&gt;, &lt;code&gt;rails server&lt;/code&gt;, and the pattern holds. You can add a convenience script:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;scripts&amp;quot;: {
    &amp;quot;dev&amp;quot;: &amp;quot;next dev&amp;quot;,
    &amp;quot;dev:secrets&amp;quot;: &amp;quot;infisical run --env=dev -- pnpm dev&amp;quot;,
    &amp;quot;build&amp;quot;: &amp;quot;next build&amp;quot;,
    &amp;quot;start&amp;quot;: &amp;quot;next start&amp;quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pnpm dev:secrets
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify that a value exists without printing the value itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical run --env=dev -- \
  node -e &amp;quot;console.log(&amp;#39;DATABASE_URL available:&amp;#39;, Boolean(process.env.DATABASE_URL))&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DATABASE_URL available: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 14: Handle build-time variables (the Next.js case)&lt;/h2&gt;
&lt;p&gt;Runtime injection through &lt;code&gt;infisical run&lt;/code&gt; covers most frameworks completely, but a few tools also embed configuration into a build artifact rather than reading it purely at runtime. Next.js is the clearest example, so it&amp;#39;s worth walking through even in an otherwise stack-agnostic setup.&lt;/p&gt;
&lt;p&gt;Server-side values like these should never be prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DATABASE_URL
PAYLOAD_SECRET
S3_SECRET_ACCESS_KEY
SMTP_PASSWORD
BREVO_API_KEY
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A value prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; is meant for browser access:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;NEXT_PUBLIC_SITE_URL
NEXT_PUBLIC_ANALYTICS_ID
NEXT_PUBLIC_MAPBOX_TOKEN
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next.js replaces &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; references during &lt;code&gt;next build&lt;/code&gt;, embedding the values directly into the browser bundle, where they stay fixed after the build completes. If your framework has an equivalent build-time embedding step, check its documentation for the same distinction before wiring up Infisical, since the fix is always the same: run the build itself through &lt;code&gt;infisical run&lt;/code&gt; so the values are present at build time, not just at process startup. Never solve a missing browser variable by marking a secret public.&lt;/p&gt;
&lt;h2&gt;Step 15: Create a production machine identity&lt;/h2&gt;
&lt;p&gt;Developers should log in as themselves. Servers, regardless of what they run, shouldn&amp;#39;t use a developer account. For production, create a machine identity:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Organization Settings
  → Access Control
  → Identities
  → Create identity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Name it clearly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;my-application-production
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Configure Universal Auth, which gives the machine identity a Client ID and Client Secret. The server exchanges these for a short-lived access token. Next, add the identity to the project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Project Settings
  → Access Control
  → Machine Identities
  → Add identity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Grant it read-only access to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Project: My Application
Environment: Production
Path: /
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The production identity should never read development, staging, or unrelated client projects. Create a separate identity for staging:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;my-application-staging
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Give it access only to the staging environment. The identities stay separate because their permission sets are different, and that separation matters regardless of what&amp;#39;s actually running on either server.&lt;/p&gt;
&lt;h2&gt;Step 16: Store the bootstrap credential on the VPS&lt;/h2&gt;
&lt;p&gt;The production server still needs an initial credential that lets it authenticate to Infisical. Create a dedicated application user if one doesn&amp;#39;t already exist:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo adduser --system \
  --group \
  --home /srv/my-application \
  my-application
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create the configuration directory:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo install \
  -d \
  -m 750 \
  -o root \
  -g my-application \
  /etc/my-application
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create the bootstrap file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nano /etc/my-application/infisical.env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;# File: /etc/my-application/infisical.env
INFISICAL_DOMAIN=https://secrets.example.com
INFISICAL_PROJECT_ID=&amp;lt;PROJECT_ID&amp;gt;
INFISICAL_ENV=prod

INFISICAL_CLIENT_ID=&amp;lt;PRODUCTION_CLIENT_ID&amp;gt;
INFISICAL_CLIENT_SECRET=&amp;lt;PRODUCTION_CLIENT_SECRET&amp;gt;

INFISICAL_DISABLE_UPDATE_CHECK=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Protect it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo chown root:my-application \
  /etc/my-application/infisical.env

sudo chmod 640 \
  /etc/my-application/infisical.env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This file is the remaining secret-zero credential. It&amp;#39;s still sensitive, but it&amp;#39;s far narrower than a complete production &lt;code&gt;.env&lt;/code&gt; file: it can be revoked independently, and its permissions can be limited to one project and one environment, no matter what application ends up consuming the secrets it unlocks.&lt;/p&gt;
&lt;h2&gt;Step 17: Create a reusable Infisical execution wrapper&lt;/h2&gt;
&lt;p&gt;Create:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nano /usr/local/bin/my-application-infisical
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
# File: /usr/local/bin/my-application-infisical

set -euo pipefail

set -a
source /etc/my-application/infisical.env
set +a

export PATH=&amp;quot;/usr/local/bin:/usr/bin:/bin&amp;quot;

export INFISICAL_TOKEN=&amp;quot;$(
  infisical login \
    --method=universal-auth \
    --client-id=&amp;quot;$INFISICAL_CLIENT_ID&amp;quot; \
    --client-secret=&amp;quot;$INFISICAL_CLIENT_SECRET&amp;quot; \
    --silent \
    --plain
)&amp;quot;

exec infisical run \
  --projectId=&amp;quot;$INFISICAL_PROJECT_ID&amp;quot; \
  --env=&amp;quot;$INFISICAL_ENV&amp;quot; \
  --path=&amp;quot;/&amp;quot; \
  -- &amp;quot;$@&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make it executable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo chmod 750 \
  /usr/local/bin/my-application-infisical

sudo chown root:my-application \
  /usr/local/bin/my-application-infisical
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The wrapper does two things: it exchanges the Universal Auth credentials for a short-lived access token, then starts whatever command you hand it with the correct secrets injected. Infisical&amp;#39;s CLI requires &lt;code&gt;--projectId&lt;/code&gt; when authenticating through a machine identity, and it selects the environment and path through &lt;code&gt;--env&lt;/code&gt; and &lt;code&gt;--path&lt;/code&gt;. Because the wrapper just wraps a command, it works identically whether that command is &lt;code&gt;pnpm start&lt;/code&gt;, &lt;code&gt;gunicorn&lt;/code&gt;, or a compiled Go binary.&lt;/p&gt;
&lt;p&gt;Test it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo -u my-application \
  /usr/local/bin/my-application-infisical \
  node -e &amp;quot;console.log(Boolean(process.env.PAYLOAD_SECRET))&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;true
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 18: Build and migrate with production secrets&lt;/h2&gt;
&lt;p&gt;Some frameworks, Next.js and Payload among them, need environment variables present while evaluating configuration during the build or a migration step, not just at process startup. Run the build through the wrapper:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /srv/my-application/current

sudo -u my-application \
  /usr/local/bin/my-application-infisical \
  pnpm build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run Payload migrations through the same wrapper:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /srv/my-application/current

sudo -u my-application \
  /usr/local/bin/my-application-infisical \
  pnpm payload migrate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The migration now receives the production database connection from Infisical rather than from a local file. Run migrations before restarting the production application, and confirm a recent database backup exists first.&lt;/p&gt;
&lt;h2&gt;Step 19: Run the application with systemd&lt;/h2&gt;
&lt;p&gt;Create the service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nano /etc/systemd/system/my-application.service
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;# File: /etc/systemd/system/my-application.service
[Unit]
Description=My application
After=network-online.target
Wants=network-online.target

[Service]
Type=simple

User=my-application
Group=my-application

WorkingDirectory=/srv/my-application/current

ExecStart=/usr/local/bin/my-application-infisical pnpm start

Restart=on-failure
RestartSec=5

Environment=NODE_ENV=production
Environment=PATH=/usr/local/bin:/usr/bin:/bin

NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=full

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Swap &lt;code&gt;pnpm start&lt;/code&gt; in &lt;code&gt;ExecStart&lt;/code&gt; for whatever command actually starts your application. Reload systemd, then enable and start it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl daemon-reload
sudo systemctl enable --now my-application
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check the status and follow the logs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl status my-application
sudo journalctl -u my-application -f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The startup flow is now the same regardless of stack: systemd calls the execution wrapper, which logs in through Universal Auth, gets a short-lived access token, runs &lt;code&gt;infisical run&lt;/code&gt;, and finally starts your application.&lt;/p&gt;
&lt;h2&gt;Step 20: Apply a changed secret&lt;/h2&gt;
&lt;p&gt;Suppose you replace &lt;code&gt;BREVO_API_KEY&lt;/code&gt;. Update the value in the production environment inside Infisical. The currently running process will still have the old value in memory, since environment variables don&amp;#39;t update inside an already running process. Restart the service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl restart my-application
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Infisical supports a &lt;code&gt;--watch&lt;/code&gt; option that restarts a command automatically when secrets change, but its documentation recommends that feature for development rather than production. I prefer explicit production restarts tied to a reviewed configuration change, and that preference holds regardless of what&amp;#39;s running behind the wrapper.&lt;/p&gt;
&lt;h2&gt;Step 21: Use Infisical with Docker&lt;/h2&gt;
&lt;p&gt;For a Docker deployment, install the Infisical CLI inside the application image. For an Alpine-based image:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;# File: Dockerfile

RUN apk add --no-cache bash curl

RUN curl -1sLf \
  &amp;quot;https://artifacts-cli.infisical.com/setup.apk.sh&amp;quot; \
  | sh

RUN apk add --no-cache infisical
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create an entrypoint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# File: docker/infisical-entrypoint.sh

#!/usr/bin/env bash

set -euo pipefail

export INFISICAL_TOKEN=&amp;quot;$(
  infisical login \
    --method=universal-auth \
    --client-id=&amp;quot;$INFISICAL_CLIENT_ID&amp;quot; \
    --client-secret=&amp;quot;$INFISICAL_CLIENT_SECRET&amp;quot; \
    --silent \
    --plain
)&amp;quot;

exec infisical run \
  --projectId=&amp;quot;$INFISICAL_PROJECT_ID&amp;quot; \
  --env=&amp;quot;$INFISICAL_ENV&amp;quot; \
  --path=&amp;quot;${INFISICAL_PATH:-/}&amp;quot; \
  -- &amp;quot;$@&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Copy it into the image:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;# File: Dockerfile

COPY docker/infisical-entrypoint.sh \
  /usr/local/bin/infisical-entrypoint

RUN chmod +x \
  /usr/local/bin/infisical-entrypoint

ENTRYPOINT [&amp;quot;/usr/local/bin/infisical-entrypoint&amp;quot;]

CMD [&amp;quot;pnpm&amp;quot;, &amp;quot;start&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application image still starts its normal command; the entrypoint adds the authentication and secret-injection step in front of it. Infisical&amp;#39;s Docker integration follows this same general pattern for any base image: install the CLI in the container and use &lt;code&gt;infisical run&lt;/code&gt; as the wrapper around whatever the container was already going to run.&lt;/p&gt;
&lt;h2&gt;Step 22: Configure Docker Compose&lt;/h2&gt;
&lt;p&gt;Store the bootstrap credentials on the host:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo mkdir -p /etc/my-application

sudo nano \
  /etc/my-application/infisical.env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;# File: /etc/my-application/infisical.env
INFISICAL_DOMAIN=https://secrets.example.com
INFISICAL_PROJECT_ID=&amp;lt;PROJECT_ID&amp;gt;
INFISICAL_ENV=prod
INFISICAL_CLIENT_ID=&amp;lt;CLIENT_ID&amp;gt;
INFISICAL_CLIENT_SECRET=&amp;lt;CLIENT_SECRET&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Protect the file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo chmod 600 \
  /etc/my-application/infisical.env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Compose service can use it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# File: docker-compose.production.yml

services:
  app:
    image: my-application:&amp;lt;VERSION&amp;gt;
    restart: unless-stopped

    env_file:
      - /etc/my-application/infisical.env

    environment:
      NODE_ENV: production
      INFISICAL_DISABLE_UPDATE_CHECK: &amp;quot;true&amp;quot;

    ports:
      - &amp;quot;127.0.0.1:3000:3000&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Start it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose \
  -f docker-compose.production.yml \
  up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check the logs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose \
  -f docker-compose.production.yml \
  logs --tail=100 app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The container receives only the bootstrap configuration through the host file, and the actual application secrets get retrieved when the container starts. A user with unrestricted access to the Docker daemon effectively has root-level control over every container and its configuration, so access to Docker itself needs to stay restricted regardless of which secrets manager sits behind it.&lt;/p&gt;
&lt;h2&gt;Step 23: Handle Docker build-time values&lt;/h2&gt;
&lt;p&gt;Runtime injection doesn&amp;#39;t automatically cover build-time variables. This matters most for anything like Next.js&amp;#39;s &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; values, which get embedded during the build step rather than read at container startup.&lt;/p&gt;
&lt;p&gt;When building the image, provide the Infisical access token as a BuildKit secret rather than a normal Docker build argument. Authenticate on the build machine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export INFISICAL_DOMAIN=&amp;quot;https://secrets.example.com&amp;quot;

export INFISICAL_TOKEN=&amp;quot;$(
  infisical login \
    --method=universal-auth \
    --client-id=&amp;quot;$INFISICAL_CLIENT_ID&amp;quot; \
    --client-secret=&amp;quot;$INFISICAL_CLIENT_SECRET&amp;quot; \
    --silent \
    --plain
)&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Build:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;DOCKER_BUILDKIT=1 docker build \
  --secret id=infisical_token,env=INFISICAL_TOKEN \
  --build-arg INFISICAL_PROJECT_ID=&amp;quot;$INFISICAL_PROJECT_ID&amp;quot; \
  --build-arg INFISICAL_ENV=&amp;quot;$INFISICAL_ENV&amp;quot; \
  --build-arg INFISICAL_DOMAIN=&amp;quot;$INFISICAL_DOMAIN&amp;quot; \
  -t my-application:&amp;lt;VERSION&amp;gt; \
  .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the builder stage:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;# File: Dockerfile

ARG INFISICAL_PROJECT_ID
ARG INFISICAL_ENV
ARG INFISICAL_DOMAIN

RUN --mount=type=secret,id=infisical_token,required=true \
    export INFISICAL_TOKEN=&amp;quot;$(cat /run/secrets/infisical_token)&amp;quot; &amp;amp;&amp;amp; \
    export INFISICAL_DOMAIN=&amp;quot;$INFISICAL_DOMAIN&amp;quot; &amp;amp;&amp;amp; \
    infisical run \
      --projectId=&amp;quot;$INFISICAL_PROJECT_ID&amp;quot; \
      --env=&amp;quot;$INFISICAL_ENV&amp;quot; \
      --path=&amp;quot;/&amp;quot; \
      -- pnpm build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The access token gets mounted only for that build instruction instead of landing in a normal image layer or build argument. Keep in mind that any value your framework deliberately marks as public, like &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; in Next.js, is meant to end up in the browser bundle. BuildKit protects the Infisical token here, not values you&amp;#39;ve already decided should be public.&lt;/p&gt;
&lt;h2&gt;Step 24: Separate application paths when necessary&lt;/h2&gt;
&lt;p&gt;For a single application process, storing secrets at the project root is usually enough:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As the architecture grows, folders can separate access:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/
├── app
├── workers
├── migrations
└── automation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The web process might receive &lt;code&gt;/app&lt;/code&gt;, a background worker &lt;code&gt;/workers&lt;/code&gt;, and a migration job &lt;code&gt;/migrations&lt;/code&gt;, regardless of what language each of those components is written in. Infisical supports injecting secrets from one or several paths:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical run \
  --path=&amp;quot;/app&amp;quot; \
  --path=&amp;quot;/shared&amp;quot; \
  -- pnpm start
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Don&amp;#39;t build out a complicated folder hierarchy before you actually have different permission requirements. Folders earn their place once genuinely separate workloads need different subsets of secrets.&lt;/p&gt;
&lt;h2&gt;Step 25: Rotate a machine identity credential&lt;/h2&gt;
&lt;p&gt;To rotate the production Client Secret, generate a new Client Secret for the existing machine identity, add it to &lt;code&gt;/etc/my-application/infisical.env&lt;/code&gt;, restart the application, confirm it starts correctly, and revoke the old Client Secret. The new process authenticates with the new bootstrap credential immediately. None of this requires rotating the database password, storage key, or any other application secret just because the machine identity credential changed, which is one of the main benefits of separating application secrets from machine authentication in the first place.&lt;/p&gt;
&lt;h2&gt;Step 26: Back up Infisical correctly&lt;/h2&gt;
&lt;p&gt;The PostgreSQL database contains the persistent Infisical data. Create a dump:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /opt/infisical

docker compose \
  -f docker-compose.prod.yml \
  exec -T db \
  pg_dump \
    -U infisical \
    infisical \
  | gzip \
  &amp;gt; &amp;quot;infisical-$(date +%F-%H%M%S).sql.gz&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The official Docker Compose guide identifies the PostgreSQL volume as the critical persistent store and provides &lt;code&gt;pg_dump&lt;/code&gt; as the backup mechanism, but the database alone isn&amp;#39;t the complete recovery plan. Your deployment also depends on &lt;code&gt;/opt/infisical/.env&lt;/code&gt;, which contains &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt;, &lt;code&gt;AUTH_SECRET&lt;/code&gt;, &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt;, and &lt;code&gt;SITE_URL&lt;/code&gt;. Because &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt; is required for Infisical&amp;#39;s own encryption and decryption operations, a working recovery plan needs both the database backup and the original platform encryption key together.&lt;/p&gt;
&lt;p&gt;Back up the following through an encrypted backup system: the PostgreSQL dump, &lt;code&gt;/opt/infisical/.env&lt;/code&gt;, &lt;code&gt;docker-compose.prod.yml&lt;/code&gt;, the Nginx configuration, TLS and DNS recovery information, and the pinned Infisical version. Keep the only copy off the Infisical VPS itself, and actually test restoring it. An untested backup is a guess, not a recovery plan.&lt;/p&gt;
&lt;h2&gt;Step 27: Plan for Infisical availability&lt;/h2&gt;
&lt;p&gt;Without &lt;code&gt;--watch&lt;/code&gt;, &lt;code&gt;infisical run&lt;/code&gt; retrieves secrets when the application starts and injects them into the new process. Once an application is running, its environment variables already sit in process memory, so a temporary Infisical outage won&amp;#39;t stop that existing process. It can, however, block a new deployment, an application restart, a container replacement, a server reboot, a migration command, or a new worker from starting, no matter what that worker happens to run.&lt;/p&gt;
&lt;p&gt;That&amp;#39;s why the Infisical instance itself needs monitoring, database backups, resource headroom, controlled upgrades, a documented restore process, and a stable domain with a valid TLS certificate. A secrets manager removes configuration sprawl, and it also becomes part of your deployment control plane, so it deserves the same operational care as anything else on that critical path.&lt;/p&gt;
&lt;h2&gt;Common problems&lt;/h2&gt;
&lt;h3&gt;The CLI connects to Infisical Cloud instead of your instance&lt;/h3&gt;
&lt;p&gt;Set the self-hosted domain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export INFISICAL_DOMAIN=&amp;quot;https://secrets.example.com&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or add it to &lt;code&gt;.infisical.json&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;domain&amp;quot;: &amp;quot;https://secrets.example.com&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The server receives &lt;code&gt;401&lt;/code&gt; or &lt;code&gt;403&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Check whether the Client ID is correct, whether the Client Secret is current, whether the identity was actually added to the project, and whether it has access to the selected environment and folder path. Authentication and project access are separate steps, so a machine identity can authenticate successfully and still lack access to a project.&lt;/p&gt;
&lt;h3&gt;Local development works, but production doesn&amp;#39;t&lt;/h3&gt;
&lt;p&gt;Check &lt;code&gt;INFISICAL_PROJECT_ID&lt;/code&gt;, &lt;code&gt;INFISICAL_ENV&lt;/code&gt;, &lt;code&gt;INFISICAL_DOMAIN&lt;/code&gt;, &lt;code&gt;INFISICAL_PATH&lt;/code&gt;, the production machine identity&amp;#39;s permissions, and outbound HTTPS connectivity from the server. The staging identity shouldn&amp;#39;t be assumed to have production access just because it exists in the same project.&lt;/p&gt;
&lt;h3&gt;A changed secret isn&amp;#39;t being picked up&lt;/h3&gt;
&lt;p&gt;Restart the process:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl restart my-application
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or recreate the container:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose \
  -f docker-compose.production.yml \
  up -d \
  --force-recreate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Environment variables don&amp;#39;t mutate inside an already running process, in Node.js or anywhere else.&lt;/p&gt;
&lt;h3&gt;A build-time value like &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; is still old&lt;/h3&gt;
&lt;p&gt;Rebuild the application. Next.js freezes &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; values during &lt;code&gt;next build&lt;/code&gt;, so restarting the existing build artifact won&amp;#39;t pick up a new value; the build has to run again.&lt;/p&gt;
&lt;h3&gt;The database was restored, but secrets can&amp;#39;t be read&lt;/h3&gt;
&lt;p&gt;Confirm the restored deployment uses the original &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt;. A database dump without the matching encryption configuration is an incomplete recovery package.&lt;/p&gt;
&lt;h3&gt;Infisical works locally but not behind Nginx&lt;/h3&gt;
&lt;p&gt;Check:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsS http://127.0.0.1:8080/api/status
curl -fsS https://secrets.example.com/api/status
sudo nginx -t
sudo journalctl -u nginx --tail=100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also confirm that &lt;code&gt;SITE_URL=https://secrets.example.com&lt;/code&gt; matches the actual public URL.&lt;/p&gt;
&lt;h2&gt;What changed compared with &lt;code&gt;.env&lt;/code&gt; files&lt;/h2&gt;
&lt;p&gt;Before this setup, a developer laptop held &lt;code&gt;.env.local&lt;/code&gt;, the staging VPS held &lt;code&gt;.env.staging&lt;/code&gt;, the production VPS held &lt;code&gt;.env.production&lt;/code&gt;, and the CI runner held its own copy of the production values, all independently. After this setup, Infisical holds development, staging, and production centrally, developers authenticate with a personal login, and the staging and production servers authenticate through their own machine identities.&lt;/p&gt;
&lt;p&gt;The application code stays just as simple as it was before:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;process.env.DATABASE_URL
process.env.PAYLOAD_SECRET
process.env.S3_SECRET_ACCESS_KEY
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What changed is entirely operational: one source of truth, consistent key names, explicit environment separation, separate human and machine access, revocable server credentials, a documented startup workflow, and a repeatable recovery model, none of which depends on which framework or language sits on top of it.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Does Infisical only work with Node.js applications?&lt;/strong&gt;
No. Infisical&amp;#39;s CLI wraps any command through &lt;code&gt;infisical run -- &amp;lt;command&amp;gt;&lt;/code&gt;, so it works the same way for a Python, Go, Ruby, or PHP process as it does for Next.js or Payload. The examples here use Node.js because that&amp;#39;s the stack I run, but the pattern generalizes directly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do I need separate Infisical projects for separate applications?&lt;/strong&gt;
Usually yes, one project per application keeps environments and access boundaries clean, especially when different applications belong to different clients. A small internal toolset with shared ownership can sometimes live in one project with folders separating the pieces, but that&amp;#39;s a judgment call based on who needs access to what.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What&amp;#39;s the actual difference between Infisical and a tool like Vault?&lt;/strong&gt;
Infisical focuses on a simpler, Vercel-like mental model of projects, environments, and secrets, with a CLI and dashboard that get you running quickly. HashiCorp Vault and OpenBao add dynamic credentials, PKI, and more advanced policy engines, at the cost of more operational complexity. Most small-to-mid-size teams get everything they need from Infisical without touching Vault&amp;#39;s extra surface area.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can I run Infisical without Docker?&lt;/strong&gt;
The official deployment path is Docker Compose, which is also the path documented and supported by Infisical itself. Running it another way is possible in principle but puts you outside the tested deployment model, so I&amp;#39;d stick with Docker Compose unless you have a specific reason not to.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens to my running application if the Infisical server goes down?&lt;/strong&gt;
Nothing, as long as the application is already running, since its environment variables live in process memory once the process has started. An Infisical outage blocks new deployments, restarts, and container replacements until the instance comes back, which is exactly why it needs its own monitoring and backup plan.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Self-hosting Infisical doesn&amp;#39;t remove environment variables from the picture. It gives them a proper management and delivery system that works the same way no matter what&amp;#39;s consuming them. Developers authenticate as people, application servers authenticate as machine identities, staging and production receive separate values, and the application itself keeps reading standard environment variables without getting tightly coupled to a vendor-specific SDK.&lt;/p&gt;
&lt;p&gt;The critical implementation decisions carry across any stack: run Infisical on dedicated infrastructure, put it behind HTTPS, pin the deployed versions, keep the backend port private, separate development, staging, and production, use Universal Auth for VPS workloads, store only restricted bootstrap credentials locally, restart production explicitly after secret changes, handle build-time values where your framework requires it, and back up both PostgreSQL and the Infisical encryption configuration together.&lt;/p&gt;
&lt;p&gt;The result is a provider-neutral secrets workflow that works across local development, systemd, Docker, and whatever application framework you&amp;#39;re actually running, Next.js and Payload CMS included.&lt;/p&gt;
&lt;p&gt;For the broader reasoning behind this setup, read &lt;a href=&quot;/blog/how-to-manage-environment-variables-and-secrets&quot;&gt;How to Manage Environment Variables and Secrets Across Environments&lt;/a&gt;. For the complete application deployment layer, read &lt;a href=&quot;/blog/self-host-nextjs-payload-vps-github-ci-cd&quot;&gt;Self-Host Next.js and Payload on VPS: Complete Guide&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/self-host-infisical-vps-secrets-management"/>
        </item>
        <item>
            <title><![CDATA[Secrets Management: Stop .env Sprawl Across Environments]]></title>
            <description><![CDATA[<p>The fix for secret sprawl is a central secrets manager that becomes the single source of truth for every environment, gives humans and machines separate scoped identities, and delivers values into <code>process.env</code> at runtime instead of living inside copied <code>.env</code> files. This guide walks through why <code>.env</code> files stop scaling once a project grows past one developer and one server, what a proper secrets-management setup provides, and how to choose between platform variables, cloud-native secret managers, managed cross-platform tools, self-hosted options like Infisical, and Git-based encryption with SOPS.</p>
<p>I run a mix of Next.js and Payload CMS projects for different clients, and almost none of them live on a single host anymore. A typical engagement now spans a Vercel-hosted frontend, a self-hosted Payload instance on a VPS, a managed Postgres database, S3-compatible storage, and an n8n automation server, each with its own staging and production credentials. Once I hit four or five projects with that shape, keeping track of which <code>.env.production</code> file had the current database password stopped being realistic. This article covers the reasoning behind moving to a real secrets manager, and the next one in the series covers the actual Infisical setup for Next.js and Payload.</p>
<h2>The problem starts small and stays small for a while</h2>
<p>Every project begins the same way. You create a <code>.env</code> file, drop in a database connection string, generate an application secret, paste in a few API keys, and start building.</p>
<pre><code class="language-dotenv"># File: .env.local
DATABASE_URL=postgresql://localhost/my-app
PAYLOAD_SECRET=development-secret
S3_ACCESS_KEY_ID=example
S3_SECRET_ACCESS_KEY=example
</code></pre>
<p>For one developer running one application on one machine, this setup does the job completely.</p>
<p>The trouble shows up once the application grows a staging environment, a production server, a second developer, a CI pipeline, background workers, or a scheduled job hosted somewhere else. Copying <code>.env</code> files between machines works for a while, and then it quietly turns into a liability, because nobody can say with confidence which file on which machine holds the value that is actually live in production.</p>
<h2>Environment variables are a delivery mechanism, not a management system</h2>
<p>An environment variable is just a value handed to a running process. In a Node.js app you read it through <code>process.env</code>:</p>
<pre><code class="language-ts">// File: src/lib/env.ts
const databaseUrl = process.env.DATABASE_URL
const payloadSecret = process.env.PAYLOAD_SECRET
</code></pre>
<p>The application itself has no idea where that value came from. It could have arrived through a local <code>.env</code> file, Docker Compose, a systemd unit, Vercel, Cloudflare, AWS Secrets Manager, a CI pipeline, or a self-hosted secrets manager. <code>process.env</code> looks identical either way.</p>
<p>That&#39;s the part worth internalizing early: environment variables describe how configuration reaches your code. The governance around where that configuration is stored, who can see it, and how it gets rotated is a separate concern that a <code>.env</code> file was never built to handle.</p>
<h2>Configuration and secrets need different treatment</h2>
<p>Not every environment variable deserves the same level of protection. Values like these are normal configuration:</p>
<pre><code class="language-dotenv"># File: .env
APP_NAME=CanPrev
DEFAULT_LOCALE=en
LOG_LEVEL=info
PUBLIC_SITE_URL=https://www.example.com
</code></pre>
<p>These can live in Git if the team wants them versioned alongside the code. Values like these are secrets:</p>
<pre><code class="language-dotenv"># File: .env (do not commit)
DATABASE_URL=postgresql://user:password@database/app
PAYLOAD_SECRET=...
BREVO_API_KEY=...
S3_SECRET_ACCESS_KEY=...
STRIPE_SECRET_KEY=...
</code></pre>
<p>A secret grants access, signs data, decrypts something, or proves identity. Someone who gets hold of one can reach a database, send email under your name, modify storage, or impersonate a service. Configuration benefits from visibility and version history. Secrets need restricted access, encryption, rotation, and a controlled delivery path. Managing both categories through a pile of manually copied <code>.env</code> files tends to blur that distinction until it disappears entirely.</p>
<h2>How <code>.env</code> files turn into secret sprawl</h2>
<p>There&#39;s nothing wrong with using a <code>.env</code> file for local development. The trouble is relying on it as the source of truth once the system looks like this:</p>
<pre><code class="language-text">Application
├── Developer laptop
├── Staging VPS
├── Production VPS
├── CI pipeline
├── Background worker
└── n8n automation
</code></pre>
<p>Each of those needs some combination of <code>DATABASE_URL</code>, <code>PAYLOAD_SECRET</code>, <code>REDIS_URL</code>, <code>S3_ACCESS_KEY_ID</code>, <code>S3_SECRET_ACCESS_KEY</code>, <code>SMTP_PASSWORD</code>, <code>BREVO_API_KEY</code>, and <code>INTERNAL_WEBHOOK_SECRET</code>. Without a central system, the same secrets end up copied into <code>.env.local</code>, <code>.env.staging</code>, <code>.env.production</code>, <code>docker-compose.yml</code>, GitHub Actions secrets, VPS shell configuration, systemd service files, deployment scripts, password managers, Slack messages, and internal docs. At that point nobody can say for certain which copy is current. That&#39;s secret sprawl, and it grows quietly because each individual copy felt reasonable when someone made it.</p>
<h2>The failure modes are mostly boring, not dramatic</h2>
<p>Committing a production <code>.env</code> file to Git gets attention because it&#39;s an obvious mistake, and it&#39;s a real one since the secret can linger in repository history and in clones long after the file is deleted. Most of the damage from poor secret management shows up in quieter, everyday ways instead.</p>
<p><strong>Environment drift.</strong> Staging uses <code>S3_BUCKET=project-staging</code> while production uses <code>AWS_BUCKET=project-production</code>, and a deployment that works fine in staging fails in production because the configuration model drifted without anyone noticing.</p>
<p><strong>Unknown ownership.</strong> An API key stops working and nobody can say who created it, which account owns it, which services depend on it, or whether rotating it will break something else.</p>
<p><strong>Stale access.</strong> A contractor leaves the project but still has last quarter&#39;s production <code>.env</code> file sitting on a laptop. Revoking a GitHub permission does nothing to claw back credentials that were already copied elsewhere.</p>
<p><strong>Shared production credentials.</strong> Several servers and developers all authenticate with the same database password or storage key, so revoking access for one person means rotating a credential everyone depends on.</p>
<p><strong>Unreliable deployments.</strong> A new VPS goes up and someone has to rebuild its configuration from an old server, a password manager, and a scattering of chat messages, which makes the deployment depend on memory rather than a repeatable process.</p>
<p><strong>Inconsistent local environments.</strong> A developer loses hours debugging an app before realizing their <code>.env.local</code> still points at an endpoint that was decommissioned months ago.</p>
<p><strong>Build-time versus runtime confusion.</strong> This one shows up constantly in Next.js. A server-only value like <code>DATABASE_URL</code> gets injected when the process starts. A value prefixed with <code>NEXT_PUBLIC_</code> gets baked into the client bundle at build time, and needs to exist when <code>next build</code> runs rather than when the server starts. Treating both the same way produces broken deployments and occasionally leaks a secret into a public bundle.</p>
<h2>What a proper secrets-management setup actually provides</h2>
<p>A good system does more than hide values behind a login. It builds a workflow around storing, accessing, and delivering them.</p>
<p>A single authoritative location holds each secret, so instead of asking a teammate to send over the current production <code>.env</code>, everyone opens the project&#39;s production environment inside the secrets manager.</p>
<p>Environments stay explicitly separated:</p>
<pre><code class="language-text">My application
├── Development
├── Staging
└── Production
</code></pre>
<p>The same key can exist in each environment with a different value:</p>
<pre><code class="language-text">Development
DATABASE_URL=postgresql://localhost/app

Staging
DATABASE_URL=postgresql://staging-database/app

Production
DATABASE_URL=postgresql://production-database/app
</code></pre>
<p>Humans and machines get distinct identities. A developer might have permission to view and edit development secrets. A production server authenticates as itself with read-only access to production secrets, rather than borrowing a developer&#39;s personal login.</p>
<p>Access follows least privilege: a CanPrev staging server can read CanPrev staging secrets and nothing beyond that, so compromising one server doesn&#39;t hand over every other project the team runs.</p>
<p>Access is revocable per identity. When a server gets replaced or a teammate leaves, that one identity gets revoked without rotating every credential across the organization.</p>
<p>Delivery stays simple on the application side. Code keeps reading <code>process.env.DATABASE_URL</code> exactly as before; the secrets manager handles fetching the right value and injecting it when the process starts, so the application never gets coupled to a specific secrets platform.</p>
<p>Rotation and history are tracked, so the team knows which value is current and which services need a restart when it changes. More advanced platforms add version history, approval workflows, audit logs, and automated rotation on top of that.</p>
<p>Recovery gets planned for too. The secrets manager becomes important infrastructure in its own right, and its encryption keys, database, and backups need a recovery plan just like anything else you&#39;d call critical.</p>
<h2>What this looks like once it&#39;s running</h2>
<p>A central setup for a small agency might look like this:</p>
<pre><code class="language-text">Secrets manager
├── CanPrev website
│   ├── Development
│   ├── Staging
│   └── Production
│
├── CanPrev DAM
│   ├── Development
│   ├── Staging
│   └── Production
│
└── Internal automation
    ├── Development
    ├── Staging
    └── Production
</code></pre>
<p>A developer authenticates through the CLI and runs:</p>
<pre><code class="language-bash">secrets-tool run --environment=development -- pnpm dev
</code></pre>
<p>The development secrets get fetched and injected into that new process. A production VPS authenticates with its own machine identity, something like <code>canprev-website-production-vps</code>, scoped to read-only access on the CanPrev website production environment. When the application starts:</p>
<pre><code class="language-bash">secrets-tool run --environment=production -- node server.js
</code></pre>
<p>The app still reads <code>process.env</code> the same way it always did. There&#39;s no custom secret-fetching logic scattered through the codebase.</p>
<h2>You may already be using a secrets manager without the name</h2>
<p>Managed hosting platforms made this workflow familiar long before anyone called it &quot;secrets management.&quot;</p>
<p>Vercel lets you define variables per development, preview, and production environment, and because it owns the build and runtime, it injects the right values automatically during deployment. That&#39;s convenient and usually plenty when the entire application runs on Vercel. It starts to feel thin once the system also includes a self-hosted Payload instance, separate worker servers, n8n, or anything deployed outside Vercel, since Vercel&#39;s variable management is scoped to Vercel projects rather than an organization&#39;s whole infrastructure.</p>
<p>Cloudflare offers a similar model for Workers and Pages, and it works well for the same reason: Cloudflare owns the runtime that receives the values.</p>
<p>AWS Secrets Manager centralizes secrets and integrates with AWS IAM, so applications authenticate through AWS identities and pull only what they&#39;re permitted to see. That&#39;s a strong option when most of the infrastructure already runs inside AWS, and the same logic applies to Google Secret Manager and Azure Key Vault. It becomes less convenient once applications are spread across unrelated VPS providers, local machines, and client-owned environments that have no natural AWS identity to authenticate with.</p>
<h2>Comparing the main categories of tools</h2>
<p>There&#39;s no universally correct choice here. The right tool depends on where your applications run, how granular your access control needs to be, and how much infrastructure your team is willing to operate.</p>
<table>
<thead>
<tr>
<th>Approach</th>
<th>Examples</th>
<th>Best suited for</th>
</tr>
</thead>
<tbody><tr>
<td>Hosting-platform variables</td>
<td>Vercel, Cloudflare, Render</td>
<td>Applications running mostly on one hosting platform</td>
</tr>
<tr>
<td>Cloud-native secret managers</td>
<td>AWS Secrets Manager, Google Secret Manager, Azure Key Vault</td>
<td>Infrastructure concentrated in one cloud provider</td>
</tr>
<tr>
<td>Managed cross-platform platforms</td>
<td>Doppler, managed Infisical</td>
<td>Teams wanting one hosted system across providers</td>
</tr>
<tr>
<td>Self-hosted secret managers</td>
<td>Infisical, OpenBao, HashiCorp Vault</td>
<td>Teams wanting provider-neutral control on their own infrastructure</td>
</tr>
<tr>
<td>Encrypted configuration in Git</td>
<td>SOPS with age or a cloud KMS</td>
<td>Infrastructure-as-code workflows and smaller teams comfortable with Git-based operations</td>
</tr>
</tbody></table>
<h3>Staying with the hosting platform</h3>
<p>An application fully hosted on Vercel may not need anything beyond Vercel&#39;s own environment variables, since the platform already covers environment separation, build integration, runtime injection, team permissions, and CLI access. Layering an external secrets manager on top can add more overhead than value here. That calculation changes once the system crosses platform boundaries, for example a Next.js frontend on Vercel, Payload on DigitalOcean, a managed Postgres database, S3-compatible storage, and an n8n VPS running automation. At that point secrets live across several dashboards and no single system sees the whole picture.</p>
<h3>Using the cloud provider&#39;s secret manager</h3>
<p>AWS Secrets Manager, Google Secret Manager, and Azure Key Vault fit naturally for a company already committed to one cloud ecosystem, mainly because of identity integration: an AWS workload can receive an IAM role and pull secrets without a permanent access key sitting on the server. That&#39;s a strong security model as long as the workload runs inside that cloud. Pointing an external DigitalOcean or Hetzner server at AWS Secrets Manager works technically, but that server still needs its own secure way to authenticate into AWS, which means the architecture now depends on a cloud provider that isn&#39;t hosting the application.</p>
<h3>Using a managed provider-neutral platform</h3>
<p>A managed secrets platform can serve as the common source of truth across local development, GitHub Actions, Vercel, DigitalOcean, Hetzner, AWS, Kubernetes, and plain Node.js processes, with the vendor handling the central service, database, updates, and uptime. That takes operational work off your plate in exchange for a subscription cost and an external dependency, which is a reasonable trade for a lot of teams.</p>
<h3>Self-hosting a secrets manager</h3>
<p>A self-hosted platform gives you a Vercel-like interface for environments and secrets while staying independent of whatever hosts the application. This fits organizations running multiple applications across different VPS providers who want to keep control of the underlying platform. Infisical, OpenBao, and HashiCorp Vault are the common options here.</p>
<p>Infisical provides projects, environments, a dashboard, a CLI, machine identities, and integrations for delivering secrets into applications, and its mental model tracks closely with Vercel&#39;s:</p>
<pre><code class="language-text">Project
├── Development
├── Staging
└── Production
</code></pre>
<p>Infisical doesn&#39;t host your application; you connect it explicitly to Docker, systemd, CI, or a local dev command. That makes it a practical choice for teams that want a central, provider-neutral secrets manager without taking on the full operational weight of a Vault-style system.</p>
<p>OpenBao and HashiCorp Vault go further, supporting dynamic database credentials, time-limited secret leases, PKI, certificate issuance, encryption services, and advanced authentication policies. Those capabilities matter in larger or more security-sensitive environments, and they come with real design, maintenance, and operational overhead. For a team mainly trying to stop copying <code>.env</code> files between VPSs, that overhead is often more infrastructure than the problem calls for.</p>
<h3>Encrypting secrets in Git with SOPS</h3>
<p>SOPS skips the central server entirely. You store encrypted configuration files in Git instead:</p>
<pre><code class="language-text">secrets/
├── development.enc.env
├── staging.enc.env
└── production.enc.env
</code></pre>
<p>The encrypted files version safely in the repository, while approved developers and servers hold the keys needed to decrypt them, using age, AWS KMS, Google Cloud KMS, Azure Key Vault, or PGP. This suits infrastructure-as-code workflows and smaller teams comfortable operating close to Git. Its friction shows up operationally: managing access, revoking machines, rotating encryption recipients, and delivering secrets dynamically all take more manual effort than a central platform handles for you. SOPS also pairs well alongside a full secrets manager, protecting the bootstrap credentials needed to deploy that manager in the first place.</p>
<h2>How to choose</h2>
<p>The simplest rule is to pick the system closest to where your application already runs. Reach for platform environment variables when the application runs entirely on one managed platform and you don&#39;t need a central view across external services. Reach for a cloud-native secrets manager when most workloads sit in one cloud provider and its identity system already matters more to you than portability. Reach for a managed provider-neutral platform when applications span several providers, you&#39;d rather not operate the secrets service yourself, and the subscription cost is acceptable. Reach for a self-hosted secrets manager when applications span multiple VPSs, you want one central source of truth that you own, and you&#39;re prepared to operate and back it up. Reach for SOPS when Git already anchors your infrastructure workflow, deployments happen through controlled changes, and your team is comfortable managing encryption keys directly.</p>
<h2>Why Infisical fits the projects I run</h2>
<p>Most of what I build now includes more than one deployment target: a Next.js frontend, Payload CMS, Postgres, Redis, object storage, background jobs, n8n, external APIs, a staging VPS, a production VPS, and local development, often owned by different clients and hosted by different providers. The Vercel-style mental model of projects and environments is still the right one, it just needs to exist independently of Vercel itself.</p>
<p>Infisical fits that gap well. It can be self-hosted, gives you a central dashboard, separates projects and environments cleanly, issues separate identities to humans and servers, and its CLI injects variables into ordinary processes. Next.js and Payload keep reading <code>process.env</code> exactly as before, and none of it requires the application to run on a particular cloud.</p>
<p>The workflow ends up feeling familiar. Locally:</p>
<pre><code class="language-bash">infisical run --env=dev -- pnpm dev
</code></pre>
<p>On staging:</p>
<pre><code class="language-bash">infisical run --env=staging -- node server.js
</code></pre>
<p>In production:</p>
<pre><code class="language-bash">infisical run --env=prod -- node server.js
</code></pre>
<p>The application code stays conventional:</p>
<pre><code class="language-ts">// File: src/lib/env.ts
const databaseUrl = process.env.DATABASE_URL
</code></pre>
<p>Infisical handles where that value lives, who can see it, and which environment gets it.</p>
<h2>The secret-zero problem doesn&#39;t fully disappear</h2>
<p>There&#39;s one detail worth naming directly. A generic VPS still needs some initial way to authenticate to the secrets manager itself. Rather than storing every application secret locally, the server holds one restricted machine credential:</p>
<pre><code class="language-dotenv"># File: .env (VPS bootstrap only)
INFISICAL_CLIENT_ID=...
INFISICAL_CLIENT_SECRET=...
</code></pre>
<p>That identity might be scoped to read-only access on the CanPrev website production environment and nothing else. This is called the secret-zero problem, and it can&#39;t be eliminated entirely on an ordinary VPS. It can be narrowed down to one revocable, tightly scoped credential, which is a far smaller blast radius than a complete production <code>.env</code> file carrying database, storage, email, and API credentials all at once.</p>
<h2>What still belongs in the repository</h2>
<p>Moving to a secrets manager doesn&#39;t mean every setting leaves Git. Keep a <code>.env.example</code> documenting the variables the application expects:</p>
<pre><code class="language-dotenv"># File: .env.example
DATABASE_URL=
PAYLOAD_SECRET=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
BREVO_API_KEY=
</code></pre>
<p>Use names and safe placeholder values here, never real credentials. Non-sensitive configuration can stay in the repo too:</p>
<pre><code class="language-dotenv"># File: .env
DEFAULT_LOCALE=en
LOG_LEVEL=info
FEATURE_SEARCH_ENABLED=true
</code></pre>
<p>The point isn&#39;t hiding every setting from Git. It&#39;s centralizing the sensitive values and building a predictable, repeatable way to deliver them.</p>
<h2>A migration path that doesn&#39;t require a big-bang cutover</h2>
<p>Moving to a secrets manager works best as a sequence rather than one large infrastructure change:</p>
<ol>
<li>Inventory the current environment variables.</li>
<li>Separate secrets from public configuration.</li>
<li>Identify where every current value is stored today.</li>
<li>Create development, staging, and production environments centrally.</li>
<li>Migrate local development first.</li>
<li>Integrate the staging deployment.</li>
<li>Test application restarts and failed secret retrieval.</li>
<li>Integrate production.</li>
<li>Remove old plaintext files.</li>
<li>Rotate credentials that were previously shared widely.</li>
</ol>
<p>Start in a non-critical environment and confirm the workflow holds up before touching production. Importing a <code>.env</code> file into a new dashboard is the easy part; the actual improvement comes from defining ownership, access boundaries, rotation, and recovery around it.</p>
<h2>FAQ</h2>
<p><strong>Do I need a secrets manager if I only deploy to Vercel?</strong>
Probably not yet. Vercel already separates development, preview, and production variables and injects them automatically at build and runtime. A separate secrets manager earns its place once your infrastructure spreads beyond what Vercel controls, like a self-hosted Payload instance or a background worker on its own VPS.</p>
<p><strong>What&#39;s the difference between a secret and regular configuration?</strong>
A secret grants access, signs data, decrypts something, or proves identity, so exposure lets someone reach a resource they shouldn&#39;t. Configuration values like a locale code or a log level carry no access risk on their own and can usually live safely in Git.</p>
<p><strong>Can NEXT_PUBLIC_ variables come from a secrets manager too?</strong>
They can, but remember that anything prefixed <code>NEXT_PUBLIC_</code> gets baked into the client bundle at build time rather than read at runtime. Your secrets manager needs to inject those values into the <code>next build</code> step specifically, not just into the running server process.</p>
<p><strong>Does self-hosting Infisical remove all local secrets from my servers?</strong>
No. Each server still needs one bootstrap credential to authenticate to Infisical itself, which is the secret-zero problem. What changes is the blast radius: that one credential can be scoped to read-only access on a single environment, instead of a full <code>.env</code> file carrying every credential the application uses.</p>
<p><strong>Is SOPS a replacement for a secrets manager like Infisical or Vault?</strong>
It can be, especially for smaller teams already running an infrastructure-as-code workflow through Git. It can also complement a full secrets manager by protecting the bootstrap credentials needed to deploy that manager in the first place.</p>
<h2>Wrapping up</h2>
<p>Copying <code>.env</code> files between machines feels harmless right up until a second developer, a staging server, or a CI pipeline enters the picture, at which point it turns into an undocumented deployment process with weak access control. A proper secrets-management setup gives every environment a defined source of truth, lets developers authenticate as themselves and servers authenticate as machines, and grants each identity only the access it actually needs. The application code doesn&#39;t change; it keeps reading <code>process.env</code> exactly as before. What changes is how those values get governed and delivered.</p>
<p>The next article in this series moves from strategy to implementation: self-hosting Infisical on a VPS, setting up projects and environments, creating human and machine identities, connecting a local Next.js and Payload dev environment, injecting secrets into Docker and systemd, separating build-time from runtime variables, and migrating away from existing production <code>.env</code> files.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<p>Thanks, Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/secrets-management-stop-env-sprawl</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/secrets-management-stop-env-sprawl</guid>
            <category><![CDATA[Next.js]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Tue, 14 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;The fix for secret sprawl is a central secrets manager that becomes the single source of truth for every environment, gives humans and machines separate scoped identities, and delivers values into &lt;code&gt;process.env&lt;/code&gt; at runtime instead of living inside copied &lt;code&gt;.env&lt;/code&gt; files. This guide walks through why &lt;code&gt;.env&lt;/code&gt; files stop scaling once a project grows past one developer and one server, what a proper secrets-management setup provides, and how to choose between platform variables, cloud-native secret managers, managed cross-platform tools, self-hosted options like Infisical, and Git-based encryption with SOPS.&lt;/p&gt;
&lt;p&gt;I run a mix of Next.js and Payload CMS projects for different clients, and almost none of them live on a single host anymore. A typical engagement now spans a Vercel-hosted frontend, a self-hosted Payload instance on a VPS, a managed Postgres database, S3-compatible storage, and an n8n automation server, each with its own staging and production credentials. Once I hit four or five projects with that shape, keeping track of which &lt;code&gt;.env.production&lt;/code&gt; file had the current database password stopped being realistic. This article covers the reasoning behind moving to a real secrets manager, and the next one in the series covers the actual Infisical setup for Next.js and Payload.&lt;/p&gt;
&lt;h2&gt;The problem starts small and stays small for a while&lt;/h2&gt;
&lt;p&gt;Every project begins the same way. You create a &lt;code&gt;.env&lt;/code&gt; file, drop in a database connection string, generate an application secret, paste in a few API keys, and start building.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;# File: .env.local
DATABASE_URL=postgresql://localhost/my-app
PAYLOAD_SECRET=development-secret
S3_ACCESS_KEY_ID=example
S3_SECRET_ACCESS_KEY=example
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For one developer running one application on one machine, this setup does the job completely.&lt;/p&gt;
&lt;p&gt;The trouble shows up once the application grows a staging environment, a production server, a second developer, a CI pipeline, background workers, or a scheduled job hosted somewhere else. Copying &lt;code&gt;.env&lt;/code&gt; files between machines works for a while, and then it quietly turns into a liability, because nobody can say with confidence which file on which machine holds the value that is actually live in production.&lt;/p&gt;
&lt;h2&gt;Environment variables are a delivery mechanism, not a management system&lt;/h2&gt;
&lt;p&gt;An environment variable is just a value handed to a running process. In a Node.js app you read it through &lt;code&gt;process.env&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/lib/env.ts
const databaseUrl = process.env.DATABASE_URL
const payloadSecret = process.env.PAYLOAD_SECRET
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application itself has no idea where that value came from. It could have arrived through a local &lt;code&gt;.env&lt;/code&gt; file, Docker Compose, a systemd unit, Vercel, Cloudflare, AWS Secrets Manager, a CI pipeline, or a self-hosted secrets manager. &lt;code&gt;process.env&lt;/code&gt; looks identical either way.&lt;/p&gt;
&lt;p&gt;That&amp;#39;s the part worth internalizing early: environment variables describe how configuration reaches your code. The governance around where that configuration is stored, who can see it, and how it gets rotated is a separate concern that a &lt;code&gt;.env&lt;/code&gt; file was never built to handle.&lt;/p&gt;
&lt;h2&gt;Configuration and secrets need different treatment&lt;/h2&gt;
&lt;p&gt;Not every environment variable deserves the same level of protection. Values like these are normal configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;# File: .env
APP_NAME=CanPrev
DEFAULT_LOCALE=en
LOG_LEVEL=info
PUBLIC_SITE_URL=https://www.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These can live in Git if the team wants them versioned alongside the code. Values like these are secrets:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;# File: .env (do not commit)
DATABASE_URL=postgresql://user:password@database/app
PAYLOAD_SECRET=...
BREVO_API_KEY=...
S3_SECRET_ACCESS_KEY=...
STRIPE_SECRET_KEY=...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A secret grants access, signs data, decrypts something, or proves identity. Someone who gets hold of one can reach a database, send email under your name, modify storage, or impersonate a service. Configuration benefits from visibility and version history. Secrets need restricted access, encryption, rotation, and a controlled delivery path. Managing both categories through a pile of manually copied &lt;code&gt;.env&lt;/code&gt; files tends to blur that distinction until it disappears entirely.&lt;/p&gt;
&lt;h2&gt;How &lt;code&gt;.env&lt;/code&gt; files turn into secret sprawl&lt;/h2&gt;
&lt;p&gt;There&amp;#39;s nothing wrong with using a &lt;code&gt;.env&lt;/code&gt; file for local development. The trouble is relying on it as the source of truth once the system looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Application
├── Developer laptop
├── Staging VPS
├── Production VPS
├── CI pipeline
├── Background worker
└── n8n automation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each of those needs some combination of &lt;code&gt;DATABASE_URL&lt;/code&gt;, &lt;code&gt;PAYLOAD_SECRET&lt;/code&gt;, &lt;code&gt;REDIS_URL&lt;/code&gt;, &lt;code&gt;S3_ACCESS_KEY_ID&lt;/code&gt;, &lt;code&gt;S3_SECRET_ACCESS_KEY&lt;/code&gt;, &lt;code&gt;SMTP_PASSWORD&lt;/code&gt;, &lt;code&gt;BREVO_API_KEY&lt;/code&gt;, and &lt;code&gt;INTERNAL_WEBHOOK_SECRET&lt;/code&gt;. Without a central system, the same secrets end up copied into &lt;code&gt;.env.local&lt;/code&gt;, &lt;code&gt;.env.staging&lt;/code&gt;, &lt;code&gt;.env.production&lt;/code&gt;, &lt;code&gt;docker-compose.yml&lt;/code&gt;, GitHub Actions secrets, VPS shell configuration, systemd service files, deployment scripts, password managers, Slack messages, and internal docs. At that point nobody can say for certain which copy is current. That&amp;#39;s secret sprawl, and it grows quietly because each individual copy felt reasonable when someone made it.&lt;/p&gt;
&lt;h2&gt;The failure modes are mostly boring, not dramatic&lt;/h2&gt;
&lt;p&gt;Committing a production &lt;code&gt;.env&lt;/code&gt; file to Git gets attention because it&amp;#39;s an obvious mistake, and it&amp;#39;s a real one since the secret can linger in repository history and in clones long after the file is deleted. Most of the damage from poor secret management shows up in quieter, everyday ways instead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Environment drift.&lt;/strong&gt; Staging uses &lt;code&gt;S3_BUCKET=project-staging&lt;/code&gt; while production uses &lt;code&gt;AWS_BUCKET=project-production&lt;/code&gt;, and a deployment that works fine in staging fails in production because the configuration model drifted without anyone noticing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Unknown ownership.&lt;/strong&gt; An API key stops working and nobody can say who created it, which account owns it, which services depend on it, or whether rotating it will break something else.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stale access.&lt;/strong&gt; A contractor leaves the project but still has last quarter&amp;#39;s production &lt;code&gt;.env&lt;/code&gt; file sitting on a laptop. Revoking a GitHub permission does nothing to claw back credentials that were already copied elsewhere.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Shared production credentials.&lt;/strong&gt; Several servers and developers all authenticate with the same database password or storage key, so revoking access for one person means rotating a credential everyone depends on.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Unreliable deployments.&lt;/strong&gt; A new VPS goes up and someone has to rebuild its configuration from an old server, a password manager, and a scattering of chat messages, which makes the deployment depend on memory rather than a repeatable process.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Inconsistent local environments.&lt;/strong&gt; A developer loses hours debugging an app before realizing their &lt;code&gt;.env.local&lt;/code&gt; still points at an endpoint that was decommissioned months ago.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Build-time versus runtime confusion.&lt;/strong&gt; This one shows up constantly in Next.js. A server-only value like &lt;code&gt;DATABASE_URL&lt;/code&gt; gets injected when the process starts. A value prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; gets baked into the client bundle at build time, and needs to exist when &lt;code&gt;next build&lt;/code&gt; runs rather than when the server starts. Treating both the same way produces broken deployments and occasionally leaks a secret into a public bundle.&lt;/p&gt;
&lt;h2&gt;What a proper secrets-management setup actually provides&lt;/h2&gt;
&lt;p&gt;A good system does more than hide values behind a login. It builds a workflow around storing, accessing, and delivering them.&lt;/p&gt;
&lt;p&gt;A single authoritative location holds each secret, so instead of asking a teammate to send over the current production &lt;code&gt;.env&lt;/code&gt;, everyone opens the project&amp;#39;s production environment inside the secrets manager.&lt;/p&gt;
&lt;p&gt;Environments stay explicitly separated:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;My application
├── Development
├── Staging
└── Production
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same key can exist in each environment with a different value:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Development
DATABASE_URL=postgresql://localhost/app

Staging
DATABASE_URL=postgresql://staging-database/app

Production
DATABASE_URL=postgresql://production-database/app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Humans and machines get distinct identities. A developer might have permission to view and edit development secrets. A production server authenticates as itself with read-only access to production secrets, rather than borrowing a developer&amp;#39;s personal login.&lt;/p&gt;
&lt;p&gt;Access follows least privilege: a CanPrev staging server can read CanPrev staging secrets and nothing beyond that, so compromising one server doesn&amp;#39;t hand over every other project the team runs.&lt;/p&gt;
&lt;p&gt;Access is revocable per identity. When a server gets replaced or a teammate leaves, that one identity gets revoked without rotating every credential across the organization.&lt;/p&gt;
&lt;p&gt;Delivery stays simple on the application side. Code keeps reading &lt;code&gt;process.env.DATABASE_URL&lt;/code&gt; exactly as before; the secrets manager handles fetching the right value and injecting it when the process starts, so the application never gets coupled to a specific secrets platform.&lt;/p&gt;
&lt;p&gt;Rotation and history are tracked, so the team knows which value is current and which services need a restart when it changes. More advanced platforms add version history, approval workflows, audit logs, and automated rotation on top of that.&lt;/p&gt;
&lt;p&gt;Recovery gets planned for too. The secrets manager becomes important infrastructure in its own right, and its encryption keys, database, and backups need a recovery plan just like anything else you&amp;#39;d call critical.&lt;/p&gt;
&lt;h2&gt;What this looks like once it&amp;#39;s running&lt;/h2&gt;
&lt;p&gt;A central setup for a small agency might look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Secrets manager
├── CanPrev website
│   ├── Development
│   ├── Staging
│   └── Production
│
├── CanPrev DAM
│   ├── Development
│   ├── Staging
│   └── Production
│
└── Internal automation
    ├── Development
    ├── Staging
    └── Production
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A developer authenticates through the CLI and runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;secrets-tool run --environment=development -- pnpm dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The development secrets get fetched and injected into that new process. A production VPS authenticates with its own machine identity, something like &lt;code&gt;canprev-website-production-vps&lt;/code&gt;, scoped to read-only access on the CanPrev website production environment. When the application starts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;secrets-tool run --environment=production -- node server.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The app still reads &lt;code&gt;process.env&lt;/code&gt; the same way it always did. There&amp;#39;s no custom secret-fetching logic scattered through the codebase.&lt;/p&gt;
&lt;h2&gt;You may already be using a secrets manager without the name&lt;/h2&gt;
&lt;p&gt;Managed hosting platforms made this workflow familiar long before anyone called it &amp;quot;secrets management.&amp;quot;&lt;/p&gt;
&lt;p&gt;Vercel lets you define variables per development, preview, and production environment, and because it owns the build and runtime, it injects the right values automatically during deployment. That&amp;#39;s convenient and usually plenty when the entire application runs on Vercel. It starts to feel thin once the system also includes a self-hosted Payload instance, separate worker servers, n8n, or anything deployed outside Vercel, since Vercel&amp;#39;s variable management is scoped to Vercel projects rather than an organization&amp;#39;s whole infrastructure.&lt;/p&gt;
&lt;p&gt;Cloudflare offers a similar model for Workers and Pages, and it works well for the same reason: Cloudflare owns the runtime that receives the values.&lt;/p&gt;
&lt;p&gt;AWS Secrets Manager centralizes secrets and integrates with AWS IAM, so applications authenticate through AWS identities and pull only what they&amp;#39;re permitted to see. That&amp;#39;s a strong option when most of the infrastructure already runs inside AWS, and the same logic applies to Google Secret Manager and Azure Key Vault. It becomes less convenient once applications are spread across unrelated VPS providers, local machines, and client-owned environments that have no natural AWS identity to authenticate with.&lt;/p&gt;
&lt;h2&gt;Comparing the main categories of tools&lt;/h2&gt;
&lt;p&gt;There&amp;#39;s no universally correct choice here. The right tool depends on where your applications run, how granular your access control needs to be, and how much infrastructure your team is willing to operate.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Best suited for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Hosting-platform variables&lt;/td&gt;
&lt;td&gt;Vercel, Cloudflare, Render&lt;/td&gt;
&lt;td&gt;Applications running mostly on one hosting platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud-native secret managers&lt;/td&gt;
&lt;td&gt;AWS Secrets Manager, Google Secret Manager, Azure Key Vault&lt;/td&gt;
&lt;td&gt;Infrastructure concentrated in one cloud provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed cross-platform platforms&lt;/td&gt;
&lt;td&gt;Doppler, managed Infisical&lt;/td&gt;
&lt;td&gt;Teams wanting one hosted system across providers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted secret managers&lt;/td&gt;
&lt;td&gt;Infisical, OpenBao, HashiCorp Vault&lt;/td&gt;
&lt;td&gt;Teams wanting provider-neutral control on their own infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encrypted configuration in Git&lt;/td&gt;
&lt;td&gt;SOPS with age or a cloud KMS&lt;/td&gt;
&lt;td&gt;Infrastructure-as-code workflows and smaller teams comfortable with Git-based operations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;Staying with the hosting platform&lt;/h3&gt;
&lt;p&gt;An application fully hosted on Vercel may not need anything beyond Vercel&amp;#39;s own environment variables, since the platform already covers environment separation, build integration, runtime injection, team permissions, and CLI access. Layering an external secrets manager on top can add more overhead than value here. That calculation changes once the system crosses platform boundaries, for example a Next.js frontend on Vercel, Payload on DigitalOcean, a managed Postgres database, S3-compatible storage, and an n8n VPS running automation. At that point secrets live across several dashboards and no single system sees the whole picture.&lt;/p&gt;
&lt;h3&gt;Using the cloud provider&amp;#39;s secret manager&lt;/h3&gt;
&lt;p&gt;AWS Secrets Manager, Google Secret Manager, and Azure Key Vault fit naturally for a company already committed to one cloud ecosystem, mainly because of identity integration: an AWS workload can receive an IAM role and pull secrets without a permanent access key sitting on the server. That&amp;#39;s a strong security model as long as the workload runs inside that cloud. Pointing an external DigitalOcean or Hetzner server at AWS Secrets Manager works technically, but that server still needs its own secure way to authenticate into AWS, which means the architecture now depends on a cloud provider that isn&amp;#39;t hosting the application.&lt;/p&gt;
&lt;h3&gt;Using a managed provider-neutral platform&lt;/h3&gt;
&lt;p&gt;A managed secrets platform can serve as the common source of truth across local development, GitHub Actions, Vercel, DigitalOcean, Hetzner, AWS, Kubernetes, and plain Node.js processes, with the vendor handling the central service, database, updates, and uptime. That takes operational work off your plate in exchange for a subscription cost and an external dependency, which is a reasonable trade for a lot of teams.&lt;/p&gt;
&lt;h3&gt;Self-hosting a secrets manager&lt;/h3&gt;
&lt;p&gt;A self-hosted platform gives you a Vercel-like interface for environments and secrets while staying independent of whatever hosts the application. This fits organizations running multiple applications across different VPS providers who want to keep control of the underlying platform. Infisical, OpenBao, and HashiCorp Vault are the common options here.&lt;/p&gt;
&lt;p&gt;Infisical provides projects, environments, a dashboard, a CLI, machine identities, and integrations for delivering secrets into applications, and its mental model tracks closely with Vercel&amp;#39;s:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Project
├── Development
├── Staging
└── Production
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Infisical doesn&amp;#39;t host your application; you connect it explicitly to Docker, systemd, CI, or a local dev command. That makes it a practical choice for teams that want a central, provider-neutral secrets manager without taking on the full operational weight of a Vault-style system.&lt;/p&gt;
&lt;p&gt;OpenBao and HashiCorp Vault go further, supporting dynamic database credentials, time-limited secret leases, PKI, certificate issuance, encryption services, and advanced authentication policies. Those capabilities matter in larger or more security-sensitive environments, and they come with real design, maintenance, and operational overhead. For a team mainly trying to stop copying &lt;code&gt;.env&lt;/code&gt; files between VPSs, that overhead is often more infrastructure than the problem calls for.&lt;/p&gt;
&lt;h3&gt;Encrypting secrets in Git with SOPS&lt;/h3&gt;
&lt;p&gt;SOPS skips the central server entirely. You store encrypted configuration files in Git instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;secrets/
├── development.enc.env
├── staging.enc.env
└── production.enc.env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The encrypted files version safely in the repository, while approved developers and servers hold the keys needed to decrypt them, using age, AWS KMS, Google Cloud KMS, Azure Key Vault, or PGP. This suits infrastructure-as-code workflows and smaller teams comfortable operating close to Git. Its friction shows up operationally: managing access, revoking machines, rotating encryption recipients, and delivering secrets dynamically all take more manual effort than a central platform handles for you. SOPS also pairs well alongside a full secrets manager, protecting the bootstrap credentials needed to deploy that manager in the first place.&lt;/p&gt;
&lt;h2&gt;How to choose&lt;/h2&gt;
&lt;p&gt;The simplest rule is to pick the system closest to where your application already runs. Reach for platform environment variables when the application runs entirely on one managed platform and you don&amp;#39;t need a central view across external services. Reach for a cloud-native secrets manager when most workloads sit in one cloud provider and its identity system already matters more to you than portability. Reach for a managed provider-neutral platform when applications span several providers, you&amp;#39;d rather not operate the secrets service yourself, and the subscription cost is acceptable. Reach for a self-hosted secrets manager when applications span multiple VPSs, you want one central source of truth that you own, and you&amp;#39;re prepared to operate and back it up. Reach for SOPS when Git already anchors your infrastructure workflow, deployments happen through controlled changes, and your team is comfortable managing encryption keys directly.&lt;/p&gt;
&lt;h2&gt;Why Infisical fits the projects I run&lt;/h2&gt;
&lt;p&gt;Most of what I build now includes more than one deployment target: a Next.js frontend, Payload CMS, Postgres, Redis, object storage, background jobs, n8n, external APIs, a staging VPS, a production VPS, and local development, often owned by different clients and hosted by different providers. The Vercel-style mental model of projects and environments is still the right one, it just needs to exist independently of Vercel itself.&lt;/p&gt;
&lt;p&gt;Infisical fits that gap well. It can be self-hosted, gives you a central dashboard, separates projects and environments cleanly, issues separate identities to humans and servers, and its CLI injects variables into ordinary processes. Next.js and Payload keep reading &lt;code&gt;process.env&lt;/code&gt; exactly as before, and none of it requires the application to run on a particular cloud.&lt;/p&gt;
&lt;p&gt;The workflow ends up feeling familiar. Locally:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical run --env=dev -- pnpm dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On staging:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical run --env=staging -- node server.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In production:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;infisical run --env=prod -- node server.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application code stays conventional:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// File: src/lib/env.ts
const databaseUrl = process.env.DATABASE_URL
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Infisical handles where that value lives, who can see it, and which environment gets it.&lt;/p&gt;
&lt;h2&gt;The secret-zero problem doesn&amp;#39;t fully disappear&lt;/h2&gt;
&lt;p&gt;There&amp;#39;s one detail worth naming directly. A generic VPS still needs some initial way to authenticate to the secrets manager itself. Rather than storing every application secret locally, the server holds one restricted machine credential:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;# File: .env (VPS bootstrap only)
INFISICAL_CLIENT_ID=...
INFISICAL_CLIENT_SECRET=...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That identity might be scoped to read-only access on the CanPrev website production environment and nothing else. This is called the secret-zero problem, and it can&amp;#39;t be eliminated entirely on an ordinary VPS. It can be narrowed down to one revocable, tightly scoped credential, which is a far smaller blast radius than a complete production &lt;code&gt;.env&lt;/code&gt; file carrying database, storage, email, and API credentials all at once.&lt;/p&gt;
&lt;h2&gt;What still belongs in the repository&lt;/h2&gt;
&lt;p&gt;Moving to a secrets manager doesn&amp;#39;t mean every setting leaves Git. Keep a &lt;code&gt;.env.example&lt;/code&gt; documenting the variables the application expects:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;# File: .env.example
DATABASE_URL=
PAYLOAD_SECRET=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
BREVO_API_KEY=
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use names and safe placeholder values here, never real credentials. Non-sensitive configuration can stay in the repo too:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dotenv&quot;&gt;# File: .env
DEFAULT_LOCALE=en
LOG_LEVEL=info
FEATURE_SEARCH_ENABLED=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The point isn&amp;#39;t hiding every setting from Git. It&amp;#39;s centralizing the sensitive values and building a predictable, repeatable way to deliver them.&lt;/p&gt;
&lt;h2&gt;A migration path that doesn&amp;#39;t require a big-bang cutover&lt;/h2&gt;
&lt;p&gt;Moving to a secrets manager works best as a sequence rather than one large infrastructure change:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Inventory the current environment variables.&lt;/li&gt;
&lt;li&gt;Separate secrets from public configuration.&lt;/li&gt;
&lt;li&gt;Identify where every current value is stored today.&lt;/li&gt;
&lt;li&gt;Create development, staging, and production environments centrally.&lt;/li&gt;
&lt;li&gt;Migrate local development first.&lt;/li&gt;
&lt;li&gt;Integrate the staging deployment.&lt;/li&gt;
&lt;li&gt;Test application restarts and failed secret retrieval.&lt;/li&gt;
&lt;li&gt;Integrate production.&lt;/li&gt;
&lt;li&gt;Remove old plaintext files.&lt;/li&gt;
&lt;li&gt;Rotate credentials that were previously shared widely.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Start in a non-critical environment and confirm the workflow holds up before touching production. Importing a &lt;code&gt;.env&lt;/code&gt; file into a new dashboard is the easy part; the actual improvement comes from defining ownership, access boundaries, rotation, and recovery around it.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Do I need a secrets manager if I only deploy to Vercel?&lt;/strong&gt;
Probably not yet. Vercel already separates development, preview, and production variables and injects them automatically at build and runtime. A separate secrets manager earns its place once your infrastructure spreads beyond what Vercel controls, like a self-hosted Payload instance or a background worker on its own VPS.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What&amp;#39;s the difference between a secret and regular configuration?&lt;/strong&gt;
A secret grants access, signs data, decrypts something, or proves identity, so exposure lets someone reach a resource they shouldn&amp;#39;t. Configuration values like a locale code or a log level carry no access risk on their own and can usually live safely in Git.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can NEXT_PUBLIC_ variables come from a secrets manager too?&lt;/strong&gt;
They can, but remember that anything prefixed &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; gets baked into the client bundle at build time rather than read at runtime. Your secrets manager needs to inject those values into the &lt;code&gt;next build&lt;/code&gt; step specifically, not just into the running server process.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does self-hosting Infisical remove all local secrets from my servers?&lt;/strong&gt;
No. Each server still needs one bootstrap credential to authenticate to Infisical itself, which is the secret-zero problem. What changes is the blast radius: that one credential can be scoped to read-only access on a single environment, instead of a full &lt;code&gt;.env&lt;/code&gt; file carrying every credential the application uses.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is SOPS a replacement for a secrets manager like Infisical or Vault?&lt;/strong&gt;
It can be, especially for smaller teams already running an infrastructure-as-code workflow through Git. It can also complement a full secrets manager by protecting the bootstrap credentials needed to deploy that manager in the first place.&lt;/p&gt;
&lt;h2&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;Copying &lt;code&gt;.env&lt;/code&gt; files between machines feels harmless right up until a second developer, a staging server, or a CI pipeline enters the picture, at which point it turns into an undocumented deployment process with weak access control. A proper secrets-management setup gives every environment a defined source of truth, lets developers authenticate as themselves and servers authenticate as machines, and grants each identity only the access it actually needs. The application code doesn&amp;#39;t change; it keeps reading &lt;code&gt;process.env&lt;/code&gt; exactly as before. What changes is how those values get governed and delivered.&lt;/p&gt;
&lt;p&gt;The next article in this series moves from strategy to implementation: self-hosting Infisical on a VPS, setting up projects and environments, creating human and machine identities, connecting a local Next.js and Payload dev environment, injecting secrets into Docker and systemd, separating build-time from runtime variables, and migrating away from existing production &lt;code&gt;.env&lt;/code&gt; files.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks, Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/secrets-management-stop-env-sprawl"/>
        </item>
        <item>
            <title><![CDATA[Push to Multiple Git Remotes — Mirror GitHub & Bitbucket]]></title>
            <description><![CDATA[<p>If a client requires their code on Bitbucket while your team collaborates on GitHub, you can run both from a single local repository. This guide walks through adding multiple remotes, pushing to each one selectively, and keeping one host as the canonical source for pull requests while the other stays a mirror.</p>
<p>I set this up recently on a client project where the collaboration and review process happens on GitHub, but the client&#39;s internal tooling requires a mirror on Bitbucket. Rather than maintaining two separate clones or relying on a manual copy-paste workflow, I configured one local repo with two remotes and a couple of scripted push commands. This guide documents that exact setup, including the SSH key routing, the branch-naming quirks between hosts, and the commands I actually run day to day.</p>
<h2>Why One Repo Can Talk to Several Hosts</h2>
<p>Git itself has no concept of a &quot;home&quot; host. Your local <code>.git</code> directory holds the full history, and GitHub, Bitbucket, and GitLab are simply named bookmarks pointing at other copies of that same history. A remote is just a label attached to a URL.</p>
<pre><code>Local repo (main branch)
      |
      ├── origin     → GitHub
      ├── bitbucket  → Bitbucket
      └── gitlab     → GitLab (optional)
</code></pre>
<p>Fetching pulls commits from a remote into your machine. Pushing sends commits from your machine to a remote. The remotes never talk to each other directly, so if you push to GitHub and forget Bitbucket, Bitbucket simply falls behind until you push there too.</p>
<h2>Checking What You Already Have</h2>
<p>Before adding anything, see what&#39;s configured:</p>
<pre><code class="language-bash">git remote -v
git branch -vv
git status
</code></pre>
<p>A repo with two remotes looks like this:</p>
<pre><code class="language-text">origin     https://github.com/org/repo.git (fetch)
origin     https://github.com/org/repo.git (push)
bitbucket  git@bitbucket.org:workspace/repo.git (fetch)
bitbucket  git@bitbucket.org:workspace/repo.git (push)

* main  abc1234 [origin/main] latest commit message
</code></pre>
<p>The <code>[origin/main]</code> marker tells you which remote branch your local <code>main</code> tracks. That&#39;s the target for a plain <code>git pull</code> or <code>git push</code> with no remote name specified.</p>
<h2>Adding a Second (or Third) Remote</h2>
<p>Create the empty repository on each host first, then point your local project at it.</p>
<pre><code class="language-bash"># HTTPS
git remote add origin https://github.com/ORG/REPO.git
git remote add bitbucket https://bitbucket.org/WORKSPACE/REPO.git

# SSH (recommended for daily use)
git remote add origin git@github.com:ORG/REPO.git
git remote add bitbucket git@bitbucket.org:WORKSPACE/REPO.git
</code></pre>
<p>To change a URL later, or rename or remove a remote:</p>
<pre><code class="language-bash">git remote set-url bitbucket git@bitbucket.org:WORKSPACE/REPO.git
git remote rename origin github
git remote remove gitlab
</code></pre>
<p>The convention I use: GitHub stays <code>origin</code> because it&#39;s canonical for pull requests and daily pushes, and every other host gets named after itself (<code>bitbucket</code>, <code>gitlab</code>).</p>
<h2>Routing SSH Keys per Host</h2>
<p>If you use separate accounts across hosts, or you just want clean revocation, generate a dedicated key per host and route it through <code>~/.ssh/config</code>:</p>
<pre><code class="language-sshconfig">Host github.com
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_ed25519_github
  IdentitiesOnly yes

Host bitbucket.org
  HostName bitbucket.org
  User git
  IdentityFile ~/.ssh/id_ed25519_bitbucket
  IdentitiesOnly yes
</code></pre>
<p>Generate a new key and add the public half to the host&#39;s UI:</p>
<pre><code class="language-bash">ssh-keygen -t ed25519 -C &quot;bitbucket-work&quot; -f ~/.ssh/id_ed25519_bitbucket
</code></pre>
<p>Confirm each connection works before you push anything:</p>
<pre><code class="language-bash">ssh -T git@github.com
ssh -T git@bitbucket.org
</code></pre>
<p>If you see a host key warning, register the host once:</p>
<pre><code class="language-bash">ssh-keyscan -t ed25519,rsa bitbucket.org &gt;&gt; ~/.ssh/known_hosts
</code></pre>
<h2>Pushing to Only One Remote</h2>
<p>This is the workflow you&#39;ll use most. Always name the remote explicitly so there&#39;s no ambiguity about where a branch, tag, or deletion is headed.</p>
<pre><code class="language-bash">git push origin main       # GitHub only
git push bitbucket main    # Bitbucket only
</code></pre>
<h3>Setting the default upstream</h3>
<p><code>git push -u origin main</code> sets <code>origin</code> as the default target for a plain <code>git push</code> going forward. Run this once for GitHub and skip <code>-u</code> for the mirror, so an unqualified <code>git push</code> never accidentally lands on the wrong host:</p>
<pre><code class="language-bash">git push -u origin main
git push bitbucket main
</code></pre>
<p>If the upstream ever gets pointed at the wrong remote, restore it directly:</p>
<pre><code class="language-bash">git branch -u origin/main main
</code></pre>
<h3>Feature branches, tags, and deletions</h3>
<p>Each of these needs the remote spelled out too, since Bitbucket or GitLab won&#39;t see anything you don&#39;t explicitly push:</p>
<pre><code class="language-bash">git push -u origin feature/i18n     # PR branch on GitHub
git push bitbucket feature/i18n     # same branch mirrored to Bitbucket

git push origin v1.2.0              # a single tag
git push origin --delete old-branch # remove a branch on one remote only
</code></pre>
<h2>Fetching and Comparing Remotes</h2>
<pre><code class="language-bash">git fetch origin
git fetch bitbucket
git fetch --all
</code></pre>
<p>To see what&#39;s different between two remotes without merging anything:</p>
<pre><code class="language-bash">git fetch --all
git log --oneline origin/main..bitbucket/main
git log --oneline bitbucket/main..origin/main
</code></pre>
<h2>Pushing to Both Remotes at Once</h2>
<p>For a repo you mirror on every push, there are three ways to automate it.</p>
<table>
<thead>
<tr>
<th>Approach</th>
<th>How it works</th>
<th>Best for</th>
</tr>
</thead>
<tbody><tr>
<td>Dual push URL on one remote</td>
<td><code>git remote set-url --add --push origin &lt;second-url&gt;</code> makes <code>git push origin</code> hit two hosts at once</td>
<td>Repos where every push should always mirror, no exceptions</td>
</tr>
<tr>
<td>Shell alias</td>
<td>A one-line alias runs both <code>git push</code> commands in sequence</td>
<td>Day-to-day use where you want visibility into each push</td>
</tr>
<tr>
<td>Custom <code>pushInsteadOf</code> rewrite</td>
<td>Git rewrites the push URL at the config level</td>
<td>Rare; explicit named remotes are usually clearer</td>
</tr>
</tbody></table>
<p>The alias approach is what I use, since it keeps each push visible in the terminal output:</p>
<pre><code class="language-bash"># ~/.zshrc
alias gpush-all=&#39;git push origin HEAD &amp;&amp; git push bitbucket HEAD&#39;
alias gpush-gh=&#39;git push origin HEAD&#39;
alias gpush-bb=&#39;git push bitbucket HEAD&#39;
</code></pre>
<h2>The <code>main</code> vs <code>master</code> Trap</h2>
<p>Hosts don&#39;t agree on default branch names, and this catches people off guard the first time they mirror an existing repo.</p>
<table>
<thead>
<tr>
<th>Host</th>
<th>Common default</th>
</tr>
</thead>
<tbody><tr>
<td>GitHub</td>
<td><code>main</code></td>
</tr>
<tr>
<td>Bitbucket</td>
<td>historically <code>master</code></td>
</tr>
<tr>
<td>GitLab</td>
<td><code>main</code> (configurable)</td>
</tr>
</tbody></table>
<p>Your local branch might be <code>main</code> while an old, empty <code>master</code> branch is still sitting on Bitbucket from when the repo was first created. The Bitbucket UI path <code>/src/master/</code> will show that stale branch, not the one you&#39;re actually pushing to.</p>
<p>Check what a remote actually has:</p>
<pre><code class="language-bash">git ls-remote bitbucket
</code></pre>
<p>To make <code>main</code> the canonical branch on a host that still defaults to <code>master</code>, either force <code>master</code> to match <code>main</code>&#39;s content (only if the old <code>master</code> history is disposable):</p>
<pre><code class="language-bash">git push bitbucket main:master --force
</code></pre>
<p>Or push <code>main</code> on its own and change the host&#39;s default branch setting through its UI:</p>
<ul>
<li>GitHub: Settings → General → Default branch</li>
<li>Bitbucket: Repository settings → Repository details → Advanced → Main branch</li>
<li>GitLab: Settings → Repository → Default branch</li>
</ul>
<p>Git can&#39;t change a host&#39;s default branch over the git protocol itself. That setting lives in the host&#39;s own configuration.</p>
<h2>First Sync When the Remote Already Has Commits</h2>
<p>If the host auto-created a README, license, or <code>.gitignore</code>, your local history and the remote&#39;s history may have no common ancestor. Check before pushing:</p>
<pre><code class="language-bash">git fetch bitbucket
git log --oneline --graph --all --decorate | head -40
</code></pre>
<p>If the remote&#39;s content is disposable, force your history onto it:</p>
<pre><code class="language-bash">git push bitbucket main --force
</code></pre>
<p>If you need to keep what&#39;s already there, merge the histories explicitly:</p>
<pre><code class="language-bash">git pull bitbucket main --allow-unrelated-histories
git push bitbucket main
</code></pre>
<p>Avoid <code>--force</code> on any branch other people rely on unless the team has agreed on it first.</p>
<h2>My Daily Workflow for This Setup</h2>
<ol>
<li>Develop and commit locally, same as any single-remote repo.</li>
<li>Open pull requests and do code review on GitHub (<code>origin</code>), which stays canonical.</li>
<li>After a merge, or whenever the client needs the mirror current, sync Bitbucket:</li>
</ol>
<pre><code class="language-bash">git checkout main
git pull origin main
git push origin main
git push bitbucket main
</code></pre>
<ol start="4">
<li>Confirm the upstream is still GitHub:</li>
</ol>
<pre><code class="language-bash">git branch -u origin/main main
</code></pre>
<h2>A Note on CI</h2>
<p>Pushing the same commits to two hosts does not duplicate your CI pipelines. GitHub Actions and Bitbucket Pipelines are separate systems, and each one only runs if it&#39;s configured on that specific host. If Vercel or another deploy target is bound to GitHub, mirroring to Bitbucket won&#39;t trigger a second deployment unless you set that up independently.</p>
<h2>FAQ</h2>
<p><strong>Does pushing to <code>origin</code> automatically update <code>bitbucket</code>?</strong>
No. Remotes never sync with each other. Each <code>git push</code> only reaches the remote you name, so a mirror only stays current if you push to it as a separate step.</p>
<p><strong>What happens if I run <code>git push</code> with no remote name?</strong>
It goes to whichever remote your current branch tracks as its upstream, which you can check with <code>git branch -vv</code>. Set this explicitly with <code>git push -u &lt;remote&gt; &lt;branch&gt;</code> so there&#39;s no guessing.</p>
<p><strong>Can I set up automatic mirroring so I never have to push twice?</strong>
Yes, using a dual push URL on one remote name (<code>git remote set-url --add --push origin &lt;second-url&gt;</code>) or a CI job that mirrors on merge. A shell alias is the simplest version if you&#39;re doing this manually.</p>
<p><strong>Why does Bitbucket show <code>master</code> when I&#39;ve been working in <code>main</code> the whole time?</strong>
Bitbucket creates a default branch when the repository is first made, and that&#39;s often still <code>master</code> unless changed. Check <code>git ls-remote bitbucket</code> to see exactly which branches exist there, then update the host&#39;s default branch setting in its UI.</p>
<p><strong>Is it safe to force-push a mirror branch?</strong>
Force-pushing is fine on a mirror that no one else pulls from directly, since you&#39;re just overwriting a copy. Treat any branch other people build on as off-limits for force-pushing regardless of which remote it lives on.</p>
<h2>Wrapping Up</h2>
<p>A single local repository can push to as many hosts as a project needs, and the pattern stays the same regardless of how many remotes you add: name each one explicitly, decide which host is canonical for reviews, and push to the others as deliberate mirroring steps rather than assuming they&#39;ll stay in sync on their own. Once the remotes and SSH routing are set up, the daily commands are just <code>git push origin main</code> followed by <code>git push bitbucket main</code>.</p>
<p>Let me know in the comments if you have questions, and subscribe for more practical development guides.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/push-to-multiple-git-remotes-mirror-github-bitbucket</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/push-to-multiple-git-remotes-mirror-github-bitbucket</guid>
            <category><![CDATA[Tools]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Mon, 13 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;If a client requires their code on Bitbucket while your team collaborates on GitHub, you can run both from a single local repository. This guide walks through adding multiple remotes, pushing to each one selectively, and keeping one host as the canonical source for pull requests while the other stays a mirror.&lt;/p&gt;
&lt;p&gt;I set this up recently on a client project where the collaboration and review process happens on GitHub, but the client&amp;#39;s internal tooling requires a mirror on Bitbucket. Rather than maintaining two separate clones or relying on a manual copy-paste workflow, I configured one local repo with two remotes and a couple of scripted push commands. This guide documents that exact setup, including the SSH key routing, the branch-naming quirks between hosts, and the commands I actually run day to day.&lt;/p&gt;
&lt;h2&gt;Why One Repo Can Talk to Several Hosts&lt;/h2&gt;
&lt;p&gt;Git itself has no concept of a &amp;quot;home&amp;quot; host. Your local &lt;code&gt;.git&lt;/code&gt; directory holds the full history, and GitHub, Bitbucket, and GitLab are simply named bookmarks pointing at other copies of that same history. A remote is just a label attached to a URL.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Local repo (main branch)
      |
      ├── origin     → GitHub
      ├── bitbucket  → Bitbucket
      └── gitlab     → GitLab (optional)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fetching pulls commits from a remote into your machine. Pushing sends commits from your machine to a remote. The remotes never talk to each other directly, so if you push to GitHub and forget Bitbucket, Bitbucket simply falls behind until you push there too.&lt;/p&gt;
&lt;h2&gt;Checking What You Already Have&lt;/h2&gt;
&lt;p&gt;Before adding anything, see what&amp;#39;s configured:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git remote -v
git branch -vv
git status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A repo with two remotes looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;origin     https://github.com/org/repo.git (fetch)
origin     https://github.com/org/repo.git (push)
bitbucket  git@bitbucket.org:workspace/repo.git (fetch)
bitbucket  git@bitbucket.org:workspace/repo.git (push)

* main  abc1234 [origin/main] latest commit message
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;[origin/main]&lt;/code&gt; marker tells you which remote branch your local &lt;code&gt;main&lt;/code&gt; tracks. That&amp;#39;s the target for a plain &lt;code&gt;git pull&lt;/code&gt; or &lt;code&gt;git push&lt;/code&gt; with no remote name specified.&lt;/p&gt;
&lt;h2&gt;Adding a Second (or Third) Remote&lt;/h2&gt;
&lt;p&gt;Create the empty repository on each host first, then point your local project at it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# HTTPS
git remote add origin https://github.com/ORG/REPO.git
git remote add bitbucket https://bitbucket.org/WORKSPACE/REPO.git

# SSH (recommended for daily use)
git remote add origin git@github.com:ORG/REPO.git
git remote add bitbucket git@bitbucket.org:WORKSPACE/REPO.git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To change a URL later, or rename or remove a remote:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git remote set-url bitbucket git@bitbucket.org:WORKSPACE/REPO.git
git remote rename origin github
git remote remove gitlab
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The convention I use: GitHub stays &lt;code&gt;origin&lt;/code&gt; because it&amp;#39;s canonical for pull requests and daily pushes, and every other host gets named after itself (&lt;code&gt;bitbucket&lt;/code&gt;, &lt;code&gt;gitlab&lt;/code&gt;).&lt;/p&gt;
&lt;h2&gt;Routing SSH Keys per Host&lt;/h2&gt;
&lt;p&gt;If you use separate accounts across hosts, or you just want clean revocation, generate a dedicated key per host and route it through &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sshconfig&quot;&gt;Host github.com
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_ed25519_github
  IdentitiesOnly yes

Host bitbucket.org
  HostName bitbucket.org
  User git
  IdentityFile ~/.ssh/id_ed25519_bitbucket
  IdentitiesOnly yes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Generate a new key and add the public half to the host&amp;#39;s UI:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-keygen -t ed25519 -C &amp;quot;bitbucket-work&amp;quot; -f ~/.ssh/id_ed25519_bitbucket
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Confirm each connection works before you push anything:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh -T git@github.com
ssh -T git@bitbucket.org
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you see a host key warning, register the host once:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-keyscan -t ed25519,rsa bitbucket.org &amp;gt;&amp;gt; ~/.ssh/known_hosts
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Pushing to Only One Remote&lt;/h2&gt;
&lt;p&gt;This is the workflow you&amp;#39;ll use most. Always name the remote explicitly so there&amp;#39;s no ambiguity about where a branch, tag, or deletion is headed.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git push origin main       # GitHub only
git push bitbucket main    # Bitbucket only
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Setting the default upstream&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;git push -u origin main&lt;/code&gt; sets &lt;code&gt;origin&lt;/code&gt; as the default target for a plain &lt;code&gt;git push&lt;/code&gt; going forward. Run this once for GitHub and skip &lt;code&gt;-u&lt;/code&gt; for the mirror, so an unqualified &lt;code&gt;git push&lt;/code&gt; never accidentally lands on the wrong host:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git push -u origin main
git push bitbucket main
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the upstream ever gets pointed at the wrong remote, restore it directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git branch -u origin/main main
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Feature branches, tags, and deletions&lt;/h3&gt;
&lt;p&gt;Each of these needs the remote spelled out too, since Bitbucket or GitLab won&amp;#39;t see anything you don&amp;#39;t explicitly push:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git push -u origin feature/i18n     # PR branch on GitHub
git push bitbucket feature/i18n     # same branch mirrored to Bitbucket

git push origin v1.2.0              # a single tag
git push origin --delete old-branch # remove a branch on one remote only
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Fetching and Comparing Remotes&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git fetch origin
git fetch bitbucket
git fetch --all
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To see what&amp;#39;s different between two remotes without merging anything:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git fetch --all
git log --oneline origin/main..bitbucket/main
git log --oneline bitbucket/main..origin/main
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Pushing to Both Remotes at Once&lt;/h2&gt;
&lt;p&gt;For a repo you mirror on every push, there are three ways to automate it.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Dual push URL on one remote&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git remote set-url --add --push origin &amp;lt;second-url&amp;gt;&lt;/code&gt; makes &lt;code&gt;git push origin&lt;/code&gt; hit two hosts at once&lt;/td&gt;
&lt;td&gt;Repos where every push should always mirror, no exceptions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shell alias&lt;/td&gt;
&lt;td&gt;A one-line alias runs both &lt;code&gt;git push&lt;/code&gt; commands in sequence&lt;/td&gt;
&lt;td&gt;Day-to-day use where you want visibility into each push&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom &lt;code&gt;pushInsteadOf&lt;/code&gt; rewrite&lt;/td&gt;
&lt;td&gt;Git rewrites the push URL at the config level&lt;/td&gt;
&lt;td&gt;Rare; explicit named remotes are usually clearer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The alias approach is what I use, since it keeps each push visible in the terminal output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# ~/.zshrc
alias gpush-all=&amp;#39;git push origin HEAD &amp;amp;&amp;amp; git push bitbucket HEAD&amp;#39;
alias gpush-gh=&amp;#39;git push origin HEAD&amp;#39;
alias gpush-bb=&amp;#39;git push bitbucket HEAD&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The &lt;code&gt;main&lt;/code&gt; vs &lt;code&gt;master&lt;/code&gt; Trap&lt;/h2&gt;
&lt;p&gt;Hosts don&amp;#39;t agree on default branch names, and this catches people off guard the first time they mirror an existing repo.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Common default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;&lt;code&gt;main&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitbucket&lt;/td&gt;
&lt;td&gt;historically &lt;code&gt;master&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitLab&lt;/td&gt;
&lt;td&gt;&lt;code&gt;main&lt;/code&gt; (configurable)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Your local branch might be &lt;code&gt;main&lt;/code&gt; while an old, empty &lt;code&gt;master&lt;/code&gt; branch is still sitting on Bitbucket from when the repo was first created. The Bitbucket UI path &lt;code&gt;/src/master/&lt;/code&gt; will show that stale branch, not the one you&amp;#39;re actually pushing to.&lt;/p&gt;
&lt;p&gt;Check what a remote actually has:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git ls-remote bitbucket
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To make &lt;code&gt;main&lt;/code&gt; the canonical branch on a host that still defaults to &lt;code&gt;master&lt;/code&gt;, either force &lt;code&gt;master&lt;/code&gt; to match &lt;code&gt;main&lt;/code&gt;&amp;#39;s content (only if the old &lt;code&gt;master&lt;/code&gt; history is disposable):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git push bitbucket main:master --force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or push &lt;code&gt;main&lt;/code&gt; on its own and change the host&amp;#39;s default branch setting through its UI:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GitHub: Settings → General → Default branch&lt;/li&gt;
&lt;li&gt;Bitbucket: Repository settings → Repository details → Advanced → Main branch&lt;/li&gt;
&lt;li&gt;GitLab: Settings → Repository → Default branch&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Git can&amp;#39;t change a host&amp;#39;s default branch over the git protocol itself. That setting lives in the host&amp;#39;s own configuration.&lt;/p&gt;
&lt;h2&gt;First Sync When the Remote Already Has Commits&lt;/h2&gt;
&lt;p&gt;If the host auto-created a README, license, or &lt;code&gt;.gitignore&lt;/code&gt;, your local history and the remote&amp;#39;s history may have no common ancestor. Check before pushing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git fetch bitbucket
git log --oneline --graph --all --decorate | head -40
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the remote&amp;#39;s content is disposable, force your history onto it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git push bitbucket main --force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you need to keep what&amp;#39;s already there, merge the histories explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git pull bitbucket main --allow-unrelated-histories
git push bitbucket main
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Avoid &lt;code&gt;--force&lt;/code&gt; on any branch other people rely on unless the team has agreed on it first.&lt;/p&gt;
&lt;h2&gt;My Daily Workflow for This Setup&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Develop and commit locally, same as any single-remote repo.&lt;/li&gt;
&lt;li&gt;Open pull requests and do code review on GitHub (&lt;code&gt;origin&lt;/code&gt;), which stays canonical.&lt;/li&gt;
&lt;li&gt;After a merge, or whenever the client needs the mirror current, sync Bitbucket:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git checkout main
git pull origin main
git push origin main
git push bitbucket main
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;4&quot;&gt;
&lt;li&gt;Confirm the upstream is still GitHub:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git branch -u origin/main main
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;A Note on CI&lt;/h2&gt;
&lt;p&gt;Pushing the same commits to two hosts does not duplicate your CI pipelines. GitHub Actions and Bitbucket Pipelines are separate systems, and each one only runs if it&amp;#39;s configured on that specific host. If Vercel or another deploy target is bound to GitHub, mirroring to Bitbucket won&amp;#39;t trigger a second deployment unless you set that up independently.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Does pushing to &lt;code&gt;origin&lt;/code&gt; automatically update &lt;code&gt;bitbucket&lt;/code&gt;?&lt;/strong&gt;
No. Remotes never sync with each other. Each &lt;code&gt;git push&lt;/code&gt; only reaches the remote you name, so a mirror only stays current if you push to it as a separate step.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens if I run &lt;code&gt;git push&lt;/code&gt; with no remote name?&lt;/strong&gt;
It goes to whichever remote your current branch tracks as its upstream, which you can check with &lt;code&gt;git branch -vv&lt;/code&gt;. Set this explicitly with &lt;code&gt;git push -u &amp;lt;remote&amp;gt; &amp;lt;branch&amp;gt;&lt;/code&gt; so there&amp;#39;s no guessing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can I set up automatic mirroring so I never have to push twice?&lt;/strong&gt;
Yes, using a dual push URL on one remote name (&lt;code&gt;git remote set-url --add --push origin &amp;lt;second-url&amp;gt;&lt;/code&gt;) or a CI job that mirrors on merge. A shell alias is the simplest version if you&amp;#39;re doing this manually.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why does Bitbucket show &lt;code&gt;master&lt;/code&gt; when I&amp;#39;ve been working in &lt;code&gt;main&lt;/code&gt; the whole time?&lt;/strong&gt;
Bitbucket creates a default branch when the repository is first made, and that&amp;#39;s often still &lt;code&gt;master&lt;/code&gt; unless changed. Check &lt;code&gt;git ls-remote bitbucket&lt;/code&gt; to see exactly which branches exist there, then update the host&amp;#39;s default branch setting in its UI.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is it safe to force-push a mirror branch?&lt;/strong&gt;
Force-pushing is fine on a mirror that no one else pulls from directly, since you&amp;#39;re just overwriting a copy. Treat any branch other people build on as off-limits for force-pushing regardless of which remote it lives on.&lt;/p&gt;
&lt;h2&gt;Wrapping Up&lt;/h2&gt;
&lt;p&gt;A single local repository can push to as many hosts as a project needs, and the pattern stays the same regardless of how many remotes you add: name each one explicitly, decide which host is canonical for reviews, and push to the others as deliberate mirroring steps rather than assuming they&amp;#39;ll stay in sync on their own. Once the remotes and SSH routing are set up, the daily commands are just &lt;code&gt;git push origin main&lt;/code&gt; followed by &lt;code&gt;git push bitbucket main&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Let me know in the comments if you have questions, and subscribe for more practical development guides.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/push-to-multiple-git-remotes-mirror-github-bitbucket"/>
        </item>
        <item>
            <title><![CDATA[Payload DAM: What Exists Today and What's Coming 4.0]]></title>
            <description><![CDATA[<p>Payload now has a dedicated digital asset management use-case page.</p>
<p>At first glance, it looks like Payload has launched a complete DAM product alongside its CMS.</p>
<p>That is not quite the current reality.</p>
<p>Payload already provides many of the technical foundations needed to build a digital asset management system. You can upload files, generate image sizes, organize assets, add custom metadata, control access, connect cloud storage and customize the entire admin interface.</p>
<p>But Payload does not currently provide a finished, out-of-the-box DAM comparable to platforms such as Bynder, Brandfolder or Canto.</p>
<p>It does not even provide a public DAM starter template yet.</p>
<p>The more accurate interpretation is:</p>
<blockquote>
<p>Payload is positioning itself as a framework for building a custom DAM, while Payload 4.0 is expected to package and improve those capabilities into a more complete starting point.</p>
</blockquote>
<p>In this article, I will separate what Payload supports today from what is still planned, and explain what this means if you are considering Payload for digital asset management.</p>
<blockquote>
<p>[!NOTE]
This article reflects the public status as of July 21, 2026. Payload 4.0 is still under development, so individual features and timelines may change.</p>
</blockquote>
<h2>Why this distinction matters</h2>
<p>Payload’s official <a href="https://payloadcms.com/use-cases/digital-asset-management">digital asset management page</a> describes a system for organizing, sharing and governing digital assets alongside website content.</p>
<p>The page highlights capabilities such as:</p>
<ul>
<li>Asset organization</li>
<li>Team sharing</li>
<li>Asset lifecycle management</li>
<li>Version control</li>
<li>Audit logs</li>
<li>Field-level access control</li>
<li>Enterprise security</li>
<li>Custom workflows</li>
</ul>
<p>Those are all reasonable outcomes to build with Payload.</p>
<p>However, the page is also labelled <strong>Coming Soon</strong>.</p>
<p>That label matters because many of the features normally associated with a mature DAM are not currently available as one cohesive Payload product.</p>
<p>The page is describing the solution Payload wants to support, not necessarily a finished module that can be enabled in an existing project.</p>
<p>Payload’s own DAM RFC makes this clearer.</p>
<p>In May 2026, the Payload team published <a href="https://github.com/payloadcms/payload/discussions/16681">RFC #16681</a>, outlining planned improvements to its open-source digital asset management capabilities.</p>
<p>The RFC proposes:</p>
<ul>
<li>Video previews with controls</li>
<li>PDF previews</li>
<li>Video and PDF thumbnails</li>
<li>Localized files</li>
<li>File versioning</li>
<li>A dedicated DAM documentation section</li>
<li>A new DAM starter template</li>
<li>On-demand image resizing</li>
<li>Expiring asset-sharing links</li>
<li>Usage references showing where assets are used</li>
</ul>
<p>These are described as proposed changes.</p>
<p>In other words, the RFC confirms that Payload’s complete DAM experience is still being assembled.</p>
<h2>Is Payload currently a DAM?</h2>
<p>The practical answer is:</p>
<p><strong>Payload can be used to build a DAM, but Payload is not currently a finished DAM product.</strong></p>
<p>Payload is a full-stack application framework with a CMS and admin panel. Its code-first architecture makes it possible to model almost any content, workflow or internal application.</p>
<p>That includes digital asset management.</p>
<p>But there is a difference between having the primitives required to build a DAM and having a complete DAM available out of the box.</p>
<p>Payload gives developers the primitives.</p>
<p>The developer still needs to design and implement the final asset-management system.</p>
<h2>What Payload supports today</h2>
<p>Payload already includes several capabilities that are genuinely useful for digital asset management.</p>
<h3>Upload collections</h3>
<p>Any Payload collection can be configured as an upload-enabled collection.</p>
<p>This allows you to manage files as structured database records rather than treating them as anonymous objects in a storage bucket.</p>
<p>An uploaded asset can contain the standard file information alongside custom fields such as:</p>
<ul>
<li>Asset title</li>
<li>Description</li>
<li>Alternative text</li>
<li>Copyright owner</li>
<li>Licence information</li>
<li>Brand</li>
<li>Campaign</li>
<li>Product</li>
<li>Region</li>
<li>Language</li>
<li>Approval status</li>
<li>Expiry date</li>
<li>Usage restrictions</li>
<li>Internal notes</li>
</ul>
<p>This is one of Payload’s main strengths.</p>
<p>The media schema is not fixed by a vendor. You can model assets according to the actual requirements of your organization.</p>
<h3>Image processing</h3>
<p>Payload supports image resizing and generated image sizes.</p>
<p>A project can automatically generate different versions of an image for thumbnails, cards, product pages, mobile views or large desktop layouts.</p>
<p>Payload also supports focal-point selection and image cropping.</p>
<p>This makes it suitable for website and application media workflows where one source image needs to be used in several contexts.</p>
<p>However, more advanced transformation workflows may still require custom hooks, background jobs or an external image-processing service.</p>
<h3>Cloud storage</h3>
<p>Payload provides official storage adapters for services including:</p>
<ul>
<li>Amazon S3</li>
<li>Cloudflare R2</li>
<li>Google Cloud Storage</li>
<li>Azure Blob Storage</li>
<li>Vercel Blob</li>
<li>S3-compatible storage providers</li>
</ul>
<p>This means the asset files do not need to live on the application server.</p>
<p>For example, a Payload project can store asset metadata in PostgreSQL while storing the original files and generated sizes in an S3-compatible object-storage service.</p>
<p>This is a sensible architecture for a custom DAM because it separates the application layer from binary file storage.</p>
<h3>Access control</h3>
<p>Payload’s access-control system is one of its strongest DAM foundations.</p>
<p>Access rules can be defined at the collection, document and field level.</p>
<p>You can create rules such as:</p>
<ul>
<li>Users can only see assets belonging to their brand.</li>
<li>Marketing users can upload assets but cannot approve them.</li>
<li>Legal users can edit licence and copyright fields.</li>
<li>External partners can only access approved assets.</li>
<li>Regional teams can only see assets assigned to their markets.</li>
<li>Administrators can manage assets across all brands.</li>
<li>Public users can download only explicitly published files.</li>
</ul>
<p>The access rules apply to both the APIs and the Payload admin panel.</p>
<p>This allows the DAM permission model to reflect the organization’s real structure instead of being limited to basic folders and user groups.</p>
<h3>Versions and audit information</h3>
<p>Payload supports document versions, drafts and autosave.</p>
<p>When versions are enabled, teams can track changes to asset metadata over time, including who changed a record and when it was changed.</p>
<p>This is useful for fields such as:</p>
<ul>
<li>Approval status</li>
<li>Expiry date</li>
<li>Copyright information</li>
<li>Product relationships</li>
<li>Asset categorization</li>
<li>Availability by region</li>
<li>Internal ownership</li>
</ul>
<p>However, document versioning should not automatically be interpreted as complete binary file versioning.</p>
<p>A traditional DAM may preserve every previously uploaded binary file and allow users to restore an earlier asset revision.</p>
<p>Payload’s RFC lists file versioning as a proposed DAM improvement, indicating that this is an area the team still intends to strengthen.</p>
<h3>Folders</h3>
<p>Payload currently supports folders for organizing documents and uploaded assets.</p>
<p>Folders provide a more familiar browsing interface for editors who do not want to work exclusively with database filters and search.</p>
<p>However, folders should not become the entire asset taxonomy.</p>
<p>A folder can answer:</p>
<blockquote>
<p>Where does this asset currently live?</p>
</blockquote>
<p>Metadata can answer:</p>
<blockquote>
<p>What is this asset, who can use it, where can it be published, when does it expire and what is it related to?</p>
</blockquote>
<p>For a serious DAM implementation, folders should complement metadata rather than replace it.</p>
<h2>What Payload does not currently provide as one finished system</h2>
<p>Payload’s flexibility can sometimes make the line between native functionality and custom implementation difficult to see.</p>
<p>Many advanced DAM requirements are possible with Payload, but possible does not mean included.</p>
<p>The following capabilities should currently be treated as planned or custom implementation work.</p>
<h3>A dedicated DAM starter template</h3>
<p>Payload’s RFC explicitly proposes a new DAM starter template.</p>
<p>As of July 21, 2026, the official Payload repository contains templates for websites, ecommerce, plugins, blank projects and several infrastructure combinations.</p>
<p>It does not contain a public DAM template.</p>
<p>This means there is currently no official DAM boilerplate that developers can install and immediately adapt.</p>
<p>The DAM use-case page is therefore not advertising an existing template. It is presenting the direction of the product.</p>
<h3>Binary file versioning</h3>
<p>Payload supports versions for documents, but the RFC separately proposes file versioning.</p>
<p>This distinction is important.</p>
<p>Updating an asset record and replacing the actual underlying file are not necessarily the same operation.</p>
<p>A mature DAM may need to preserve:</p>
<ul>
<li>The original file</li>
<li>Every replacement file</li>
<li>Transformation history</li>
<li>Approval history</li>
<li>The active approved revision</li>
<li>Previous download URLs</li>
<li>The relationship between old and new versions</li>
</ul>
<p>This can be built in Payload today, but it needs to be modelled deliberately.</p>
<h3>PDF and video previews</h3>
<p>Payload’s current upload interface is primarily optimized around images and general file records.</p>
<p>The RFC proposes richer PDF and video previews, including thumbnails and playback controls.</p>
<p>Until those features are released, projects that require sophisticated media previews may need custom admin components or external processing services.</p>
<h3>Expiring sharing links</h3>
<p>Temporary public sharing is a standard DAM feature.</p>
<p>A user selects an asset or collection, generates a link, chooses an expiry date and shares it with someone who does not have an account.</p>
<p>Payload can support this architecture through custom collections, access rules and signed URLs.</p>
<p>However, expiring sharing links are listed as a proposed DAM feature rather than a finished native workflow.</p>
<h3>Usage references</h3>
<p>Before replacing or deleting an asset, editors often need to know where it is being used.</p>
<p>For example:</p>
<ul>
<li>Which pages reference this image?</li>
<li>Which product records use this PDF?</li>
<li>Is this logo included in an active campaign?</li>
<li>Which websites are using this file?</li>
<li>Will deleting this asset break published content?</li>
</ul>
<p>Payload relationships make it possible to build usage reporting.</p>
<p>But the RFC explicitly proposes usage references as a new feature, meaning there is not yet a complete native DAM interface for this workflow.</p>
<h3>Localized files</h3>
<p>Payload already supports localized fields.</p>
<p>However, localized files introduce a different requirement: one logical asset may contain a different binary file for each language.</p>
<p>For example:</p>
<ul>
<li>An English product brochure</li>
<li>A French product brochure</li>
<li>A German product brochure</li>
</ul>
<p>The DAM RFC proposes direct support for localized files.</p>
<p>Until that lands, teams need to model localized asset variants themselves, perhaps through relationships, arrays or separate asset records.</p>
<h3>Asset lifecycle automation</h3>
<p>A serious DAM often needs more than a status field.</p>
<p>It may need workflows such as:</p>
<ul>
<li>Notify an owner before an asset expires.</li>
<li>Prevent downloads after licence expiry.</li>
<li>Archive assets automatically.</li>
<li>Replace expired assets across connected websites.</li>
<li>Require legal approval before publication.</li>
<li>Require a new approval when the binary file changes.</li>
<li>Restrict an asset to certain countries.</li>
<li>Record why an asset was withdrawn.</li>
<li>Prevent the deletion of assets that are still in use.</li>
</ul>
<p>Payload provides hooks, jobs, access control and custom fields that can implement these rules.</p>
<p>But the lifecycle itself must be designed and built.</p>
<h3>External asset portals</h3>
<p>Many DAM systems provide branded portals where distributors, agencies, retailers or partners can download approved files.</p>
<p>Payload can be used to build this kind of portal because it includes authentication, APIs and access control.</p>
<p>Still, this is a custom application, not a standard Payload screen.</p>
<h2>What is coming in Payload 4.0</h2>
<p>The DAM work is part of the broader <a href="/blog/payload-4-0">Payload CMS 4.0 redesign</a>.</p>
<p>Payload 4.0 appears to be focused heavily on product maturity, especially the experience of editors and non-technical teams.</p>
<p>For DAM use cases, the main areas to watch are:</p>
<ul>
<li>A redesigned upload interface</li>
<li>Better media previews</li>
<li>PDF and video support</li>
<li>File versioning</li>
<li>Localized files</li>
<li>On-demand transformations</li>
<li>Expiring share links</li>
<li>Usage references</li>
<li>A dedicated DAM starter template</li>
<li>Better documentation</li>
<li>Improved folder and hierarchy interfaces</li>
<li>Changes to storage-adapter configuration</li>
</ul>
<p>At least one infrastructure change from the RFC has already landed on Payload’s main branch.</p>
<p>The change moves storage adapters to a dedicated top-level <code>storage</code> configuration property rather than treating them only as plugins.</p>
<p>This should give Payload more control over when storage adapters are initialized and make storage configuration a clearer first-class part of the application.</p>
<p>However, this is an architectural improvement.</p>
<p>It does not mean the complete DAM feature set has shipped.</p>
<h2>What the future DAM starter will probably be</h2>
<p>When the official starter arrives, it will likely resemble Payload’s other templates.</p>
<p>It will be a codebase designed to accelerate development, not a fixed SaaS product.</p>
<p>A DAM starter could reasonably include:</p>
<ul>
<li>An upload-enabled asset collection</li>
<li>Asset metadata fields</li>
<li>Folder configuration</li>
<li>Example access rules</li>
<li>Cloud-storage configuration</li>
<li>Asset list and detail views</li>
<li>File-preview components</li>
<li>Sharing-link collections</li>
<li>Usage-reference interfaces</li>
<li>Example lifecycle statuses</li>
<li>Seed data</li>
<li>A frontend or portal example</li>
</ul>
<p>That would be valuable.</p>
<p>It could reduce weeks of basic implementation and establish standard patterns for the Payload ecosystem.</p>
<p>But organizations would still need to adapt it to their requirements.</p>
<p>A pharmaceutical company, retailer, publisher and manufacturing business will not have the same asset model.</p>
<p>They may differ in:</p>
<ul>
<li>Approval requirements</li>
<li>Product relationships</li>
<li>Legal restrictions</li>
<li>Regions</li>
<li>Languages</li>
<li>Retention rules</li>
<li>User roles</li>
<li>Brand structure</li>
<li>Distribution channels</li>
<li>External partners</li>
<li>Reporting requirements</li>
</ul>
<p>Payload’s strength is that those rules can be expressed in code.</p>
<p>The trade-off is that someone still has to design them.</p>
<h2>Payload DAM compared with a traditional DAM product</h2>
<p>The most useful way to evaluate Payload is not simply to ask whether it has DAM features.</p>
<p>The better question is:</p>
<blockquote>
<p>Does the organization want to buy a standardized DAM product or build an asset platform around its own workflows?</p>
</blockquote>
<p>A traditional DAM platform usually provides:</p>
<ul>
<li>A finished user interface</li>
<li>Established asset workflows</li>
<li>Sharing portals</li>
<li>Reporting</li>
<li>Integrations</li>
<li>Vendor support</li>
<li>Less development work</li>
<li>Faster initial deployment</li>
</ul>
<p>The trade-offs may include:</p>
<ul>
<li>Higher licence costs</li>
<li>Vendor lock-in</li>
<li>Limited data-model flexibility</li>
<li>Integration complexity</li>
<li>Separate content and asset systems</li>
<li>Restrictions imposed by the vendor’s workflow model</li>
</ul>
<p>A Payload-based DAM provides:</p>
<ul>
<li>Full ownership of the application</li>
<li>Full control over the schema</li>
<li>Custom permissions</li>
<li>Custom workflows</li>
<li>Direct relationships between content and assets</li>
<li>Integration with existing Payload websites or applications</li>
<li>Flexible hosting</li>
<li>No mandatory DAM SaaS licence</li>
</ul>
<p>The trade-offs are:</p>
<ul>
<li>More architecture work</li>
<li>More custom development</li>
<li>Responsibility for maintenance</li>
<li>Responsibility for security and infrastructure</li>
<li>Fewer finished workflows today</li>
<li>Dependence on your implementation quality</li>
</ul>
<p>This is why Payload’s DAM page talks about the answer to “build vs buy.”</p>
<p>Payload is primarily the build option.</p>
<h2>When Payload makes sense for DAM</h2>
<p>Payload is a strong candidate when digital assets need to be tightly connected to other business content.</p>
<p>For example:</p>
<ul>
<li>Product images connected to product records</li>
<li>Brochures connected to markets and languages</li>
<li>Campaign assets connected to landing pages</li>
<li>Regulatory documents connected to products</li>
<li>Brand files shared across multiple websites</li>
<li>Assets governed by custom approval rules</li>
<li>Files distributed to selected partners</li>
<li>Content and assets managed in one application</li>
</ul>
<p>Payload becomes especially compelling when the organization already uses Payload for its websites, portals or content platform.</p>
<p>Instead of introducing a separate DAM with another authentication system, another permission model and another integration layer, the asset library can become part of the same application.</p>
<p>This can reduce duplication and synchronization work.</p>
<h2>When a dedicated DAM product may be better</h2>
<p>Payload may not be the right choice when an organization needs a mature DAM immediately and does not want to maintain custom software.</p>
<p>A dedicated DAM platform may be preferable when the requirements include:</p>
<ul>
<li>Advanced creative workflows</li>
<li>Adobe integration</li>
<li>Large agency ecosystems</li>
<li>Sophisticated rights management</li>
<li>Automated video transcoding</li>
<li>Visual similarity search</li>
<li>AI tagging available immediately</li>
<li>Large-scale distribution portals</li>
<li>Extensive analytics</li>
<li>Formal vendor support</li>
<li>A fixed implementation timeline with minimal development</li>
</ul>
<p>In those situations, the development cost of recreating mature DAM functionality may outweigh the benefits of owning the system.</p>
<h2>What teams should build today</h2>
<p>Teams do not necessarily need to wait for Payload 4.0.</p>
<p>A practical Payload 3 DAM can already be built with:</p>
<ul>
<li>Upload collections</li>
<li>S3-compatible storage</li>
<li>Custom metadata</li>
<li>Access control</li>
<li>Folders</li>
<li>Versions</li>
<li>Drafts</li>
<li>Hooks</li>
<li>Jobs</li>
<li>Custom admin components</li>
<li>A dedicated asset portal</li>
</ul>
<p>However, teams should separate requirements into three categories.</p>
<h3>Native today</h3>
<p>These are capabilities Payload already provides as reliable building blocks:</p>
<ul>
<li>File uploads</li>
<li>Image sizes</li>
<li>Metadata</li>
<li>Relationships</li>
<li>Authentication</li>
<li>Access control</li>
<li>Versions</li>
<li>APIs</li>
<li>Admin customization</li>
<li>Cloud-storage adapters</li>
</ul>
<h3>Custom implementation</h3>
<p>These can be built today, but they require project work:</p>
<ul>
<li>Approval workflows</li>
<li>Expiry enforcement</li>
<li>Partner portals</li>
<li>Usage reports</li>
<li>Sharing links</li>
<li>Download analytics</li>
<li>Duplicate detection</li>
<li>Binary revision history</li>
<li>Virus scanning</li>
<li>Asset replacement workflows</li>
<li>Rights management</li>
<li>Notifications</li>
</ul>
<h3>Planned for Payload 4.0</h3>
<p>These are areas the Payload team has publicly proposed improving:</p>
<ul>
<li>DAM starter template</li>
<li>File versioning</li>
<li>PDF previews</li>
<li>Video previews</li>
<li>Localized files</li>
<li>On-demand resizing</li>
<li>Expiring links</li>
<li>Usage references</li>
<li>Dedicated DAM documentation</li>
</ul>
<p>This separation is important when estimating scope.</p>
<p>A company should not budget a Payload DAM as though it were simply enabling a plugin.</p>
<p>There is real product and implementation work involved.</p>
<h2>My current assessment</h2>
<p>Payload has a credible foundation for digital asset management.</p>
<p>Its strongest advantages are not the media browser or preview interface.</p>
<p>Its strongest advantages are:</p>
<ul>
<li>A flexible data model</li>
<li>Fine-grained access control</li>
<li>Full-stack TypeScript</li>
<li>Extensible admin interfaces</li>
<li>Direct relationships between assets and content</li>
<li>Flexible infrastructure</li>
<li>Open-source ownership</li>
</ul>
<p>Those are the difficult architectural pieces.</p>
<p>The missing pieces are mostly the finished DAM product experience.</p>
<p>Payload 4.0 appears intended to close some of that gap by improving the upload UI, previews, file handling, sharing and usage visibility.</p>
<p>The upcoming DAM starter could also give teams a much better baseline.</p>
<p>But until those features are released, the honest position is:</p>
<blockquote>
<p>Payload is a strong framework for building a custom DAM, not an out-of-the-box DAM product.</p>
</blockquote>
<p>That is not necessarily a weakness.</p>
<p>For organizations with unusual content, brand, product or approval requirements, it may be exactly the reason to choose Payload.</p>
<p>For organizations that want a finished asset platform tomorrow, it may be a reason to choose something else.</p>
<h2>Final thoughts</h2>
<p>Payload’s digital asset management page is best understood as a statement of direction.</p>
<p>The company is clearly investing in DAM as a first-class use case.</p>
<p>The current Payload platform already contains many of the foundations required to build one, including uploads, metadata, access control, versions, folders and cloud storage.</p>
<p>Payload 4.0 is expected to make those foundations more coherent through improved media interfaces, richer previews, file versioning, sharing, usage references and an official starter template.</p>
<p>For now, the distinction remains simple:</p>
<ul>
<li>Payload can power a serious DAM today.</li>
<li>Developers still need to build the actual DAM experience.</li>
<li>Payload 4.0 should reduce that work.</li>
<li>The complete out-of-the-box product does not exist yet.</li>
</ul>
<p>For the wider Payload 4.0 roadmap, including the admin redesign, hierarchies, MCP improvements and experimental framework adapters, read <a href="/blog/payload-4-0">Payload CMS 4.0: Latest Status and What’s Coming</a>.</p>
<p>Thanks,
Matija</p>
]]></description>
            <link>https://www.buildwithmatija.com/blog/payload-dam-what-exists-today-coming-4-0</link>
            <guid isPermaLink="false">https://www.buildwithmatija.com/blog/payload-dam-what-exists-today-coming-4-0</guid>
            <category><![CDATA[Payload]]></category>
            <dc:creator><![CDATA[Matija Žiberna]]></dc:creator>
            <pubDate>Sun, 12 Jul 2026 06:00:00 GMT</pubDate>
            <content:encoded>&lt;p&gt;Payload now has a dedicated digital asset management use-case page.&lt;/p&gt;
&lt;p&gt;At first glance, it looks like Payload has launched a complete DAM product alongside its CMS.&lt;/p&gt;
&lt;p&gt;That is not quite the current reality.&lt;/p&gt;
&lt;p&gt;Payload already provides many of the technical foundations needed to build a digital asset management system. You can upload files, generate image sizes, organize assets, add custom metadata, control access, connect cloud storage and customize the entire admin interface.&lt;/p&gt;
&lt;p&gt;But Payload does not currently provide a finished, out-of-the-box DAM comparable to platforms such as Bynder, Brandfolder or Canto.&lt;/p&gt;
&lt;p&gt;It does not even provide a public DAM starter template yet.&lt;/p&gt;
&lt;p&gt;The more accurate interpretation is:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Payload is positioning itself as a framework for building a custom DAM, while Payload 4.0 is expected to package and improve those capabilities into a more complete starting point.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In this article, I will separate what Payload supports today from what is still planned, and explain what this means if you are considering Payload for digital asset management.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
This article reflects the public status as of July 21, 2026. Payload 4.0 is still under development, so individual features and timelines may change.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Why this distinction matters&lt;/h2&gt;
&lt;p&gt;Payload’s official &lt;a href=&quot;https://payloadcms.com/use-cases/digital-asset-management&quot;&gt;digital asset management page&lt;/a&gt; describes a system for organizing, sharing and governing digital assets alongside website content.&lt;/p&gt;
&lt;p&gt;The page highlights capabilities such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Asset organization&lt;/li&gt;
&lt;li&gt;Team sharing&lt;/li&gt;
&lt;li&gt;Asset lifecycle management&lt;/li&gt;
&lt;li&gt;Version control&lt;/li&gt;
&lt;li&gt;Audit logs&lt;/li&gt;
&lt;li&gt;Field-level access control&lt;/li&gt;
&lt;li&gt;Enterprise security&lt;/li&gt;
&lt;li&gt;Custom workflows&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those are all reasonable outcomes to build with Payload.&lt;/p&gt;
&lt;p&gt;However, the page is also labelled &lt;strong&gt;Coming Soon&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That label matters because many of the features normally associated with a mature DAM are not currently available as one cohesive Payload product.&lt;/p&gt;
&lt;p&gt;The page is describing the solution Payload wants to support, not necessarily a finished module that can be enabled in an existing project.&lt;/p&gt;
&lt;p&gt;Payload’s own DAM RFC makes this clearer.&lt;/p&gt;
&lt;p&gt;In May 2026, the Payload team published &lt;a href=&quot;https://github.com/payloadcms/payload/discussions/16681&quot;&gt;RFC #16681&lt;/a&gt;, outlining planned improvements to its open-source digital asset management capabilities.&lt;/p&gt;
&lt;p&gt;The RFC proposes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Video previews with controls&lt;/li&gt;
&lt;li&gt;PDF previews&lt;/li&gt;
&lt;li&gt;Video and PDF thumbnails&lt;/li&gt;
&lt;li&gt;Localized files&lt;/li&gt;
&lt;li&gt;File versioning&lt;/li&gt;
&lt;li&gt;A dedicated DAM documentation section&lt;/li&gt;
&lt;li&gt;A new DAM starter template&lt;/li&gt;
&lt;li&gt;On-demand image resizing&lt;/li&gt;
&lt;li&gt;Expiring asset-sharing links&lt;/li&gt;
&lt;li&gt;Usage references showing where assets are used&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are described as proposed changes.&lt;/p&gt;
&lt;p&gt;In other words, the RFC confirms that Payload’s complete DAM experience is still being assembled.&lt;/p&gt;
&lt;h2&gt;Is Payload currently a DAM?&lt;/h2&gt;
&lt;p&gt;The practical answer is:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Payload can be used to build a DAM, but Payload is not currently a finished DAM product.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Payload is a full-stack application framework with a CMS and admin panel. Its code-first architecture makes it possible to model almost any content, workflow or internal application.&lt;/p&gt;
&lt;p&gt;That includes digital asset management.&lt;/p&gt;
&lt;p&gt;But there is a difference between having the primitives required to build a DAM and having a complete DAM available out of the box.&lt;/p&gt;
&lt;p&gt;Payload gives developers the primitives.&lt;/p&gt;
&lt;p&gt;The developer still needs to design and implement the final asset-management system.&lt;/p&gt;
&lt;h2&gt;What Payload supports today&lt;/h2&gt;
&lt;p&gt;Payload already includes several capabilities that are genuinely useful for digital asset management.&lt;/p&gt;
&lt;h3&gt;Upload collections&lt;/h3&gt;
&lt;p&gt;Any Payload collection can be configured as an upload-enabled collection.&lt;/p&gt;
&lt;p&gt;This allows you to manage files as structured database records rather than treating them as anonymous objects in a storage bucket.&lt;/p&gt;
&lt;p&gt;An uploaded asset can contain the standard file information alongside custom fields such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Asset title&lt;/li&gt;
&lt;li&gt;Description&lt;/li&gt;
&lt;li&gt;Alternative text&lt;/li&gt;
&lt;li&gt;Copyright owner&lt;/li&gt;
&lt;li&gt;Licence information&lt;/li&gt;
&lt;li&gt;Brand&lt;/li&gt;
&lt;li&gt;Campaign&lt;/li&gt;
&lt;li&gt;Product&lt;/li&gt;
&lt;li&gt;Region&lt;/li&gt;
&lt;li&gt;Language&lt;/li&gt;
&lt;li&gt;Approval status&lt;/li&gt;
&lt;li&gt;Expiry date&lt;/li&gt;
&lt;li&gt;Usage restrictions&lt;/li&gt;
&lt;li&gt;Internal notes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is one of Payload’s main strengths.&lt;/p&gt;
&lt;p&gt;The media schema is not fixed by a vendor. You can model assets according to the actual requirements of your organization.&lt;/p&gt;
&lt;h3&gt;Image processing&lt;/h3&gt;
&lt;p&gt;Payload supports image resizing and generated image sizes.&lt;/p&gt;
&lt;p&gt;A project can automatically generate different versions of an image for thumbnails, cards, product pages, mobile views or large desktop layouts.&lt;/p&gt;
&lt;p&gt;Payload also supports focal-point selection and image cropping.&lt;/p&gt;
&lt;p&gt;This makes it suitable for website and application media workflows where one source image needs to be used in several contexts.&lt;/p&gt;
&lt;p&gt;However, more advanced transformation workflows may still require custom hooks, background jobs or an external image-processing service.&lt;/p&gt;
&lt;h3&gt;Cloud storage&lt;/h3&gt;
&lt;p&gt;Payload provides official storage adapters for services including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Amazon S3&lt;/li&gt;
&lt;li&gt;Cloudflare R2&lt;/li&gt;
&lt;li&gt;Google Cloud Storage&lt;/li&gt;
&lt;li&gt;Azure Blob Storage&lt;/li&gt;
&lt;li&gt;Vercel Blob&lt;/li&gt;
&lt;li&gt;S3-compatible storage providers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This means the asset files do not need to live on the application server.&lt;/p&gt;
&lt;p&gt;For example, a Payload project can store asset metadata in PostgreSQL while storing the original files and generated sizes in an S3-compatible object-storage service.&lt;/p&gt;
&lt;p&gt;This is a sensible architecture for a custom DAM because it separates the application layer from binary file storage.&lt;/p&gt;
&lt;h3&gt;Access control&lt;/h3&gt;
&lt;p&gt;Payload’s access-control system is one of its strongest DAM foundations.&lt;/p&gt;
&lt;p&gt;Access rules can be defined at the collection, document and field level.&lt;/p&gt;
&lt;p&gt;You can create rules such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Users can only see assets belonging to their brand.&lt;/li&gt;
&lt;li&gt;Marketing users can upload assets but cannot approve them.&lt;/li&gt;
&lt;li&gt;Legal users can edit licence and copyright fields.&lt;/li&gt;
&lt;li&gt;External partners can only access approved assets.&lt;/li&gt;
&lt;li&gt;Regional teams can only see assets assigned to their markets.&lt;/li&gt;
&lt;li&gt;Administrators can manage assets across all brands.&lt;/li&gt;
&lt;li&gt;Public users can download only explicitly published files.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The access rules apply to both the APIs and the Payload admin panel.&lt;/p&gt;
&lt;p&gt;This allows the DAM permission model to reflect the organization’s real structure instead of being limited to basic folders and user groups.&lt;/p&gt;
&lt;h3&gt;Versions and audit information&lt;/h3&gt;
&lt;p&gt;Payload supports document versions, drafts and autosave.&lt;/p&gt;
&lt;p&gt;When versions are enabled, teams can track changes to asset metadata over time, including who changed a record and when it was changed.&lt;/p&gt;
&lt;p&gt;This is useful for fields such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Approval status&lt;/li&gt;
&lt;li&gt;Expiry date&lt;/li&gt;
&lt;li&gt;Copyright information&lt;/li&gt;
&lt;li&gt;Product relationships&lt;/li&gt;
&lt;li&gt;Asset categorization&lt;/li&gt;
&lt;li&gt;Availability by region&lt;/li&gt;
&lt;li&gt;Internal ownership&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;However, document versioning should not automatically be interpreted as complete binary file versioning.&lt;/p&gt;
&lt;p&gt;A traditional DAM may preserve every previously uploaded binary file and allow users to restore an earlier asset revision.&lt;/p&gt;
&lt;p&gt;Payload’s RFC lists file versioning as a proposed DAM improvement, indicating that this is an area the team still intends to strengthen.&lt;/p&gt;
&lt;h3&gt;Folders&lt;/h3&gt;
&lt;p&gt;Payload currently supports folders for organizing documents and uploaded assets.&lt;/p&gt;
&lt;p&gt;Folders provide a more familiar browsing interface for editors who do not want to work exclusively with database filters and search.&lt;/p&gt;
&lt;p&gt;However, folders should not become the entire asset taxonomy.&lt;/p&gt;
&lt;p&gt;A folder can answer:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Where does this asset currently live?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Metadata can answer:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;What is this asset, who can use it, where can it be published, when does it expire and what is it related to?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For a serious DAM implementation, folders should complement metadata rather than replace it.&lt;/p&gt;
&lt;h2&gt;What Payload does not currently provide as one finished system&lt;/h2&gt;
&lt;p&gt;Payload’s flexibility can sometimes make the line between native functionality and custom implementation difficult to see.&lt;/p&gt;
&lt;p&gt;Many advanced DAM requirements are possible with Payload, but possible does not mean included.&lt;/p&gt;
&lt;p&gt;The following capabilities should currently be treated as planned or custom implementation work.&lt;/p&gt;
&lt;h3&gt;A dedicated DAM starter template&lt;/h3&gt;
&lt;p&gt;Payload’s RFC explicitly proposes a new DAM starter template.&lt;/p&gt;
&lt;p&gt;As of July 21, 2026, the official Payload repository contains templates for websites, ecommerce, plugins, blank projects and several infrastructure combinations.&lt;/p&gt;
&lt;p&gt;It does not contain a public DAM template.&lt;/p&gt;
&lt;p&gt;This means there is currently no official DAM boilerplate that developers can install and immediately adapt.&lt;/p&gt;
&lt;p&gt;The DAM use-case page is therefore not advertising an existing template. It is presenting the direction of the product.&lt;/p&gt;
&lt;h3&gt;Binary file versioning&lt;/h3&gt;
&lt;p&gt;Payload supports versions for documents, but the RFC separately proposes file versioning.&lt;/p&gt;
&lt;p&gt;This distinction is important.&lt;/p&gt;
&lt;p&gt;Updating an asset record and replacing the actual underlying file are not necessarily the same operation.&lt;/p&gt;
&lt;p&gt;A mature DAM may need to preserve:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The original file&lt;/li&gt;
&lt;li&gt;Every replacement file&lt;/li&gt;
&lt;li&gt;Transformation history&lt;/li&gt;
&lt;li&gt;Approval history&lt;/li&gt;
&lt;li&gt;The active approved revision&lt;/li&gt;
&lt;li&gt;Previous download URLs&lt;/li&gt;
&lt;li&gt;The relationship between old and new versions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This can be built in Payload today, but it needs to be modelled deliberately.&lt;/p&gt;
&lt;h3&gt;PDF and video previews&lt;/h3&gt;
&lt;p&gt;Payload’s current upload interface is primarily optimized around images and general file records.&lt;/p&gt;
&lt;p&gt;The RFC proposes richer PDF and video previews, including thumbnails and playback controls.&lt;/p&gt;
&lt;p&gt;Until those features are released, projects that require sophisticated media previews may need custom admin components or external processing services.&lt;/p&gt;
&lt;h3&gt;Expiring sharing links&lt;/h3&gt;
&lt;p&gt;Temporary public sharing is a standard DAM feature.&lt;/p&gt;
&lt;p&gt;A user selects an asset or collection, generates a link, chooses an expiry date and shares it with someone who does not have an account.&lt;/p&gt;
&lt;p&gt;Payload can support this architecture through custom collections, access rules and signed URLs.&lt;/p&gt;
&lt;p&gt;However, expiring sharing links are listed as a proposed DAM feature rather than a finished native workflow.&lt;/p&gt;
&lt;h3&gt;Usage references&lt;/h3&gt;
&lt;p&gt;Before replacing or deleting an asset, editors often need to know where it is being used.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Which pages reference this image?&lt;/li&gt;
&lt;li&gt;Which product records use this PDF?&lt;/li&gt;
&lt;li&gt;Is this logo included in an active campaign?&lt;/li&gt;
&lt;li&gt;Which websites are using this file?&lt;/li&gt;
&lt;li&gt;Will deleting this asset break published content?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Payload relationships make it possible to build usage reporting.&lt;/p&gt;
&lt;p&gt;But the RFC explicitly proposes usage references as a new feature, meaning there is not yet a complete native DAM interface for this workflow.&lt;/p&gt;
&lt;h3&gt;Localized files&lt;/h3&gt;
&lt;p&gt;Payload already supports localized fields.&lt;/p&gt;
&lt;p&gt;However, localized files introduce a different requirement: one logical asset may contain a different binary file for each language.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An English product brochure&lt;/li&gt;
&lt;li&gt;A French product brochure&lt;/li&gt;
&lt;li&gt;A German product brochure&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The DAM RFC proposes direct support for localized files.&lt;/p&gt;
&lt;p&gt;Until that lands, teams need to model localized asset variants themselves, perhaps through relationships, arrays or separate asset records.&lt;/p&gt;
&lt;h3&gt;Asset lifecycle automation&lt;/h3&gt;
&lt;p&gt;A serious DAM often needs more than a status field.&lt;/p&gt;
&lt;p&gt;It may need workflows such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Notify an owner before an asset expires.&lt;/li&gt;
&lt;li&gt;Prevent downloads after licence expiry.&lt;/li&gt;
&lt;li&gt;Archive assets automatically.&lt;/li&gt;
&lt;li&gt;Replace expired assets across connected websites.&lt;/li&gt;
&lt;li&gt;Require legal approval before publication.&lt;/li&gt;
&lt;li&gt;Require a new approval when the binary file changes.&lt;/li&gt;
&lt;li&gt;Restrict an asset to certain countries.&lt;/li&gt;
&lt;li&gt;Record why an asset was withdrawn.&lt;/li&gt;
&lt;li&gt;Prevent the deletion of assets that are still in use.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Payload provides hooks, jobs, access control and custom fields that can implement these rules.&lt;/p&gt;
&lt;p&gt;But the lifecycle itself must be designed and built.&lt;/p&gt;
&lt;h3&gt;External asset portals&lt;/h3&gt;
&lt;p&gt;Many DAM systems provide branded portals where distributors, agencies, retailers or partners can download approved files.&lt;/p&gt;
&lt;p&gt;Payload can be used to build this kind of portal because it includes authentication, APIs and access control.&lt;/p&gt;
&lt;p&gt;Still, this is a custom application, not a standard Payload screen.&lt;/p&gt;
&lt;h2&gt;What is coming in Payload 4.0&lt;/h2&gt;
&lt;p&gt;The DAM work is part of the broader &lt;a href=&quot;/blog/payload-4-0&quot;&gt;Payload CMS 4.0 redesign&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Payload 4.0 appears to be focused heavily on product maturity, especially the experience of editors and non-technical teams.&lt;/p&gt;
&lt;p&gt;For DAM use cases, the main areas to watch are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A redesigned upload interface&lt;/li&gt;
&lt;li&gt;Better media previews&lt;/li&gt;
&lt;li&gt;PDF and video support&lt;/li&gt;
&lt;li&gt;File versioning&lt;/li&gt;
&lt;li&gt;Localized files&lt;/li&gt;
&lt;li&gt;On-demand transformations&lt;/li&gt;
&lt;li&gt;Expiring share links&lt;/li&gt;
&lt;li&gt;Usage references&lt;/li&gt;
&lt;li&gt;A dedicated DAM starter template&lt;/li&gt;
&lt;li&gt;Better documentation&lt;/li&gt;
&lt;li&gt;Improved folder and hierarchy interfaces&lt;/li&gt;
&lt;li&gt;Changes to storage-adapter configuration&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At least one infrastructure change from the RFC has already landed on Payload’s main branch.&lt;/p&gt;
&lt;p&gt;The change moves storage adapters to a dedicated top-level &lt;code&gt;storage&lt;/code&gt; configuration property rather than treating them only as plugins.&lt;/p&gt;
&lt;p&gt;This should give Payload more control over when storage adapters are initialized and make storage configuration a clearer first-class part of the application.&lt;/p&gt;
&lt;p&gt;However, this is an architectural improvement.&lt;/p&gt;
&lt;p&gt;It does not mean the complete DAM feature set has shipped.&lt;/p&gt;
&lt;h2&gt;What the future DAM starter will probably be&lt;/h2&gt;
&lt;p&gt;When the official starter arrives, it will likely resemble Payload’s other templates.&lt;/p&gt;
&lt;p&gt;It will be a codebase designed to accelerate development, not a fixed SaaS product.&lt;/p&gt;
&lt;p&gt;A DAM starter could reasonably include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An upload-enabled asset collection&lt;/li&gt;
&lt;li&gt;Asset metadata fields&lt;/li&gt;
&lt;li&gt;Folder configuration&lt;/li&gt;
&lt;li&gt;Example access rules&lt;/li&gt;
&lt;li&gt;Cloud-storage configuration&lt;/li&gt;
&lt;li&gt;Asset list and detail views&lt;/li&gt;
&lt;li&gt;File-preview components&lt;/li&gt;
&lt;li&gt;Sharing-link collections&lt;/li&gt;
&lt;li&gt;Usage-reference interfaces&lt;/li&gt;
&lt;li&gt;Example lifecycle statuses&lt;/li&gt;
&lt;li&gt;Seed data&lt;/li&gt;
&lt;li&gt;A frontend or portal example&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That would be valuable.&lt;/p&gt;
&lt;p&gt;It could reduce weeks of basic implementation and establish standard patterns for the Payload ecosystem.&lt;/p&gt;
&lt;p&gt;But organizations would still need to adapt it to their requirements.&lt;/p&gt;
&lt;p&gt;A pharmaceutical company, retailer, publisher and manufacturing business will not have the same asset model.&lt;/p&gt;
&lt;p&gt;They may differ in:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Approval requirements&lt;/li&gt;
&lt;li&gt;Product relationships&lt;/li&gt;
&lt;li&gt;Legal restrictions&lt;/li&gt;
&lt;li&gt;Regions&lt;/li&gt;
&lt;li&gt;Languages&lt;/li&gt;
&lt;li&gt;Retention rules&lt;/li&gt;
&lt;li&gt;User roles&lt;/li&gt;
&lt;li&gt;Brand structure&lt;/li&gt;
&lt;li&gt;Distribution channels&lt;/li&gt;
&lt;li&gt;External partners&lt;/li&gt;
&lt;li&gt;Reporting requirements&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Payload’s strength is that those rules can be expressed in code.&lt;/p&gt;
&lt;p&gt;The trade-off is that someone still has to design them.&lt;/p&gt;
&lt;h2&gt;Payload DAM compared with a traditional DAM product&lt;/h2&gt;
&lt;p&gt;The most useful way to evaluate Payload is not simply to ask whether it has DAM features.&lt;/p&gt;
&lt;p&gt;The better question is:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Does the organization want to buy a standardized DAM product or build an asset platform around its own workflows?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A traditional DAM platform usually provides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A finished user interface&lt;/li&gt;
&lt;li&gt;Established asset workflows&lt;/li&gt;
&lt;li&gt;Sharing portals&lt;/li&gt;
&lt;li&gt;Reporting&lt;/li&gt;
&lt;li&gt;Integrations&lt;/li&gt;
&lt;li&gt;Vendor support&lt;/li&gt;
&lt;li&gt;Less development work&lt;/li&gt;
&lt;li&gt;Faster initial deployment&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The trade-offs may include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Higher licence costs&lt;/li&gt;
&lt;li&gt;Vendor lock-in&lt;/li&gt;
&lt;li&gt;Limited data-model flexibility&lt;/li&gt;
&lt;li&gt;Integration complexity&lt;/li&gt;
&lt;li&gt;Separate content and asset systems&lt;/li&gt;
&lt;li&gt;Restrictions imposed by the vendor’s workflow model&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A Payload-based DAM provides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Full ownership of the application&lt;/li&gt;
&lt;li&gt;Full control over the schema&lt;/li&gt;
&lt;li&gt;Custom permissions&lt;/li&gt;
&lt;li&gt;Custom workflows&lt;/li&gt;
&lt;li&gt;Direct relationships between content and assets&lt;/li&gt;
&lt;li&gt;Integration with existing Payload websites or applications&lt;/li&gt;
&lt;li&gt;Flexible hosting&lt;/li&gt;
&lt;li&gt;No mandatory DAM SaaS licence&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The trade-offs are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;More architecture work&lt;/li&gt;
&lt;li&gt;More custom development&lt;/li&gt;
&lt;li&gt;Responsibility for maintenance&lt;/li&gt;
&lt;li&gt;Responsibility for security and infrastructure&lt;/li&gt;
&lt;li&gt;Fewer finished workflows today&lt;/li&gt;
&lt;li&gt;Dependence on your implementation quality&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is why Payload’s DAM page talks about the answer to “build vs buy.”&lt;/p&gt;
&lt;p&gt;Payload is primarily the build option.&lt;/p&gt;
&lt;h2&gt;When Payload makes sense for DAM&lt;/h2&gt;
&lt;p&gt;Payload is a strong candidate when digital assets need to be tightly connected to other business content.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Product images connected to product records&lt;/li&gt;
&lt;li&gt;Brochures connected to markets and languages&lt;/li&gt;
&lt;li&gt;Campaign assets connected to landing pages&lt;/li&gt;
&lt;li&gt;Regulatory documents connected to products&lt;/li&gt;
&lt;li&gt;Brand files shared across multiple websites&lt;/li&gt;
&lt;li&gt;Assets governed by custom approval rules&lt;/li&gt;
&lt;li&gt;Files distributed to selected partners&lt;/li&gt;
&lt;li&gt;Content and assets managed in one application&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Payload becomes especially compelling when the organization already uses Payload for its websites, portals or content platform.&lt;/p&gt;
&lt;p&gt;Instead of introducing a separate DAM with another authentication system, another permission model and another integration layer, the asset library can become part of the same application.&lt;/p&gt;
&lt;p&gt;This can reduce duplication and synchronization work.&lt;/p&gt;
&lt;h2&gt;When a dedicated DAM product may be better&lt;/h2&gt;
&lt;p&gt;Payload may not be the right choice when an organization needs a mature DAM immediately and does not want to maintain custom software.&lt;/p&gt;
&lt;p&gt;A dedicated DAM platform may be preferable when the requirements include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Advanced creative workflows&lt;/li&gt;
&lt;li&gt;Adobe integration&lt;/li&gt;
&lt;li&gt;Large agency ecosystems&lt;/li&gt;
&lt;li&gt;Sophisticated rights management&lt;/li&gt;
&lt;li&gt;Automated video transcoding&lt;/li&gt;
&lt;li&gt;Visual similarity search&lt;/li&gt;
&lt;li&gt;AI tagging available immediately&lt;/li&gt;
&lt;li&gt;Large-scale distribution portals&lt;/li&gt;
&lt;li&gt;Extensive analytics&lt;/li&gt;
&lt;li&gt;Formal vendor support&lt;/li&gt;
&lt;li&gt;A fixed implementation timeline with minimal development&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In those situations, the development cost of recreating mature DAM functionality may outweigh the benefits of owning the system.&lt;/p&gt;
&lt;h2&gt;What teams should build today&lt;/h2&gt;
&lt;p&gt;Teams do not necessarily need to wait for Payload 4.0.&lt;/p&gt;
&lt;p&gt;A practical Payload 3 DAM can already be built with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Upload collections&lt;/li&gt;
&lt;li&gt;S3-compatible storage&lt;/li&gt;
&lt;li&gt;Custom metadata&lt;/li&gt;
&lt;li&gt;Access control&lt;/li&gt;
&lt;li&gt;Folders&lt;/li&gt;
&lt;li&gt;Versions&lt;/li&gt;
&lt;li&gt;Drafts&lt;/li&gt;
&lt;li&gt;Hooks&lt;/li&gt;
&lt;li&gt;Jobs&lt;/li&gt;
&lt;li&gt;Custom admin components&lt;/li&gt;
&lt;li&gt;A dedicated asset portal&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;However, teams should separate requirements into three categories.&lt;/p&gt;
&lt;h3&gt;Native today&lt;/h3&gt;
&lt;p&gt;These are capabilities Payload already provides as reliable building blocks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;File uploads&lt;/li&gt;
&lt;li&gt;Image sizes&lt;/li&gt;
&lt;li&gt;Metadata&lt;/li&gt;
&lt;li&gt;Relationships&lt;/li&gt;
&lt;li&gt;Authentication&lt;/li&gt;
&lt;li&gt;Access control&lt;/li&gt;
&lt;li&gt;Versions&lt;/li&gt;
&lt;li&gt;APIs&lt;/li&gt;
&lt;li&gt;Admin customization&lt;/li&gt;
&lt;li&gt;Cloud-storage adapters&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Custom implementation&lt;/h3&gt;
&lt;p&gt;These can be built today, but they require project work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Approval workflows&lt;/li&gt;
&lt;li&gt;Expiry enforcement&lt;/li&gt;
&lt;li&gt;Partner portals&lt;/li&gt;
&lt;li&gt;Usage reports&lt;/li&gt;
&lt;li&gt;Sharing links&lt;/li&gt;
&lt;li&gt;Download analytics&lt;/li&gt;
&lt;li&gt;Duplicate detection&lt;/li&gt;
&lt;li&gt;Binary revision history&lt;/li&gt;
&lt;li&gt;Virus scanning&lt;/li&gt;
&lt;li&gt;Asset replacement workflows&lt;/li&gt;
&lt;li&gt;Rights management&lt;/li&gt;
&lt;li&gt;Notifications&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Planned for Payload 4.0&lt;/h3&gt;
&lt;p&gt;These are areas the Payload team has publicly proposed improving:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;DAM starter template&lt;/li&gt;
&lt;li&gt;File versioning&lt;/li&gt;
&lt;li&gt;PDF previews&lt;/li&gt;
&lt;li&gt;Video previews&lt;/li&gt;
&lt;li&gt;Localized files&lt;/li&gt;
&lt;li&gt;On-demand resizing&lt;/li&gt;
&lt;li&gt;Expiring links&lt;/li&gt;
&lt;li&gt;Usage references&lt;/li&gt;
&lt;li&gt;Dedicated DAM documentation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This separation is important when estimating scope.&lt;/p&gt;
&lt;p&gt;A company should not budget a Payload DAM as though it were simply enabling a plugin.&lt;/p&gt;
&lt;p&gt;There is real product and implementation work involved.&lt;/p&gt;
&lt;h2&gt;My current assessment&lt;/h2&gt;
&lt;p&gt;Payload has a credible foundation for digital asset management.&lt;/p&gt;
&lt;p&gt;Its strongest advantages are not the media browser or preview interface.&lt;/p&gt;
&lt;p&gt;Its strongest advantages are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A flexible data model&lt;/li&gt;
&lt;li&gt;Fine-grained access control&lt;/li&gt;
&lt;li&gt;Full-stack TypeScript&lt;/li&gt;
&lt;li&gt;Extensible admin interfaces&lt;/li&gt;
&lt;li&gt;Direct relationships between assets and content&lt;/li&gt;
&lt;li&gt;Flexible infrastructure&lt;/li&gt;
&lt;li&gt;Open-source ownership&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those are the difficult architectural pieces.&lt;/p&gt;
&lt;p&gt;The missing pieces are mostly the finished DAM product experience.&lt;/p&gt;
&lt;p&gt;Payload 4.0 appears intended to close some of that gap by improving the upload UI, previews, file handling, sharing and usage visibility.&lt;/p&gt;
&lt;p&gt;The upcoming DAM starter could also give teams a much better baseline.&lt;/p&gt;
&lt;p&gt;But until those features are released, the honest position is:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Payload is a strong framework for building a custom DAM, not an out-of-the-box DAM product.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is not necessarily a weakness.&lt;/p&gt;
&lt;p&gt;For organizations with unusual content, brand, product or approval requirements, it may be exactly the reason to choose Payload.&lt;/p&gt;
&lt;p&gt;For organizations that want a finished asset platform tomorrow, it may be a reason to choose something else.&lt;/p&gt;
&lt;h2&gt;Final thoughts&lt;/h2&gt;
&lt;p&gt;Payload’s digital asset management page is best understood as a statement of direction.&lt;/p&gt;
&lt;p&gt;The company is clearly investing in DAM as a first-class use case.&lt;/p&gt;
&lt;p&gt;The current Payload platform already contains many of the foundations required to build one, including uploads, metadata, access control, versions, folders and cloud storage.&lt;/p&gt;
&lt;p&gt;Payload 4.0 is expected to make those foundations more coherent through improved media interfaces, richer previews, file versioning, sharing, usage references and an official starter template.&lt;/p&gt;
&lt;p&gt;For now, the distinction remains simple:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Payload can power a serious DAM today.&lt;/li&gt;
&lt;li&gt;Developers still need to build the actual DAM experience.&lt;/li&gt;
&lt;li&gt;Payload 4.0 should reduce that work.&lt;/li&gt;
&lt;li&gt;The complete out-of-the-box product does not exist yet.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For the wider Payload 4.0 roadmap, including the admin redesign, hierarchies, MCP improvements and experimental framework adapters, read &lt;a href=&quot;/blog/payload-4-0&quot;&gt;Payload CMS 4.0: Latest Status and What’s Coming&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Thanks,
Matija&lt;/p&gt;
</content:encoded>
            <link rel="canonical" href="https://www.buildwithmatija.com/blog/payload-dam-what-exists-today-coming-4-0"/>
        </item>
    </channel>
</rss>