<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Nicolas Lecocq</title>
    <description>The latest articles on Forem by Nicolas Lecocq (@nicolasai).</description>
    <link>https://forem.com/nicolasai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3916505%2F2e7a8ea4-ac05-4bb1-8758-e63b74054bdf.webp</url>
      <title>Forem: Nicolas Lecocq</title>
      <link>https://forem.com/nicolasai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/nicolasai"/>
    <language>en</language>
    <item>
      <title>Voice-Matching LinkedIn Posts With Claude + 5 Sample Posts</title>
      <dc:creator>Nicolas Lecocq</dc:creator>
      <pubDate>Tue, 12 May 2026 15:00:01 +0000</pubDate>
      <link>https://forem.com/nicolasai/voice-matching-linkedin-posts-with-claude-5-sample-posts-39ma</link>
      <guid>https://forem.com/nicolasai/voice-matching-linkedin-posts-with-claude-5-sample-posts-39ma</guid>
      <description>&lt;p&gt;When users sign up for my LinkedIn tool, the first request I get is "make it sound like me." Generic AI output sounds the same across every account, and people can tell. The model writes the way training data writes. To match a specific person's voice, you have to either fine-tune or feed examples in-context. Fine-tuning is expensive, requires hundreds of samples, and locks you into one model. Examples in-context cost nothing extra and work across any frontier model.&lt;/p&gt;

&lt;p&gt;Five sample posts is the threshold I have found where Claude (Sonnet 4.6 in my testing) reliably matches sentence rhythm, word choice, opener style, and ending pattern. Three samples is too noisy. Ten samples does not measurably improve over five, and it eats context.&lt;/p&gt;

&lt;p&gt;Here is the prompt structure I landed on after a lot of iteration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The system prompt
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are writing a LinkedIn post in the user's exact voice.

The user's writing style is defined by these five sample posts.
Study them for: sentence length distribution, opener patterns,
word choice, transition style, ending patterns, and any
recurring phrases.

DO NOT copy phrases or sentences verbatim from the samples.
DO match the rhythm, tone, and structural choices.

Sample 1:
[paste full post 1]

Sample 2:
[paste full post 2]

Sample 3:
[paste full post 3]

Sample 4:
[paste full post 4]

Sample 5:
[paste full post 5]

User context:
- Business: [user.businessDescription]
- Audience: [user.targetAudience]
- Tone preference: [user.writingTone]
- Topics to avoid: [user.neverMention]

Write a new LinkedIn post on the topic the user provides.
Match the voice from the samples. The post should feel like
something this user would write today, not a generic AI output.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact wording matters less than the structure. Five labeled samples, then context, then the task. Putting the task before the samples weakens voice transfer measurably.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed when I used Anthropic's prompt caching
&lt;/h2&gt;

&lt;p&gt;Five sample posts at 200-400 words each is roughly 4,000-8,000 input tokens. Plus the system prompt scaffolding. Plus user context. You are at 10,000 input tokens per request before the user's actual prompt arrives. At standard Claude pricing, that is about $0.03 per generation just for the voice setup.&lt;/p&gt;

&lt;p&gt;Anthropic's prompt caching makes the same setup cheap to reuse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-sonnet-4-6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;voicePrompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the structured prompt above&lt;/span&gt;
      &lt;span class="na"&gt;cache_control&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ephemeral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userTopic&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mark the system prompt with &lt;code&gt;cache_control: { type: "ephemeral" }&lt;/code&gt;. The first request computes the cache. Every subsequent request from the same user reuses it at 10% of the input cost. Cached entries live 5 minutes by default.&lt;/p&gt;

&lt;p&gt;For a user generating multiple drafts in a session, the first generation costs full price ($0.03). The next 5-10 inside that 5-minute window cost about $0.003 each. The cost math gets a lot better when you multiply across users.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "voice match" actually means
&lt;/h2&gt;

&lt;p&gt;When I say Claude "matches voice" with five samples, here is what it actually does well and what it does not:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does well:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sentence length distribution. If samples are mostly 8-15 words, output trends 8-15. If samples mix short and long, output mixes.&lt;/li&gt;
&lt;li&gt;Opener style. Question openers, story openers, contrarian openers. Picks up the dominant pattern from samples.&lt;/li&gt;
&lt;li&gt;Word choice. If you avoid corporate words in samples, output avoids them. If you use technical terms, output uses them.&lt;/li&gt;
&lt;li&gt;Closing pattern. Question closer, takeaway closer, no closer. The model picks one consistent with samples.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Does not do well:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Specific anecdotes. The model will not invent your stories. If you want a story-shaped post, you have to give it the story in the prompt.&lt;/li&gt;
&lt;li&gt;Inside-baseball references. If your audience knows specific tools, names, or running jokes, the model misses them unless you list them in user context.&lt;/li&gt;
&lt;li&gt;Long-form structural patterns. For posts under 300 words, voice transfer is strong. For longer-form (1000+ words), the model's training-data rhythm starts leaking through.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sample selection matters
&lt;/h2&gt;

&lt;p&gt;Bad sample selection breaks voice match. Three rules I learned from testing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use posts the user actually wrote alone.&lt;/strong&gt; Posts written by ghostwriters or heavily edited by an editor pull voice in the wrong direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick posts that did well, not random ones.&lt;/strong&gt; Successful posts represent the user at their best. The model anchors on whatever you feed it, including bad posts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vary the post types.&lt;/strong&gt; One opinion post, one story, one contrarian, one tactical, one personal. Five copies of the same format teaches the model to write that format, not to match the voice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A user who pastes their five top-performing varied-format posts gets dramatically better output than a user who pastes the first five posts off their profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this replaced
&lt;/h2&gt;

&lt;p&gt;Before voice matching, my generated posts felt like AI output. Generic. Same cadence as every other tool's output. Users would generate, look at it, and rewrite from scratch. Conversion to a paying plan was bad.&lt;/p&gt;

&lt;p&gt;After voice matching, generated posts feel like the user's posts. They still need light editing, but the editing is "fix one phrase" not "rewrite from scratch." The conversion math changed because users actually use the output instead of treating it as a starting brainstorm.&lt;/p&gt;

&lt;p&gt;The whole feature is twenty lines of code plus the prompt template. It is one of those moments where the AI-product cliché ("the prompt is the product") turns out to be literally true.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>prompts</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Generating LinkedIn Carousels as Multi-Page PDFs With Puppeteer</title>
      <dc:creator>Nicolas Lecocq</dc:creator>
      <pubDate>Mon, 11 May 2026 15:00:03 +0000</pubDate>
      <link>https://forem.com/nicolasai/generating-linkedin-carousels-as-multi-page-pdfs-with-puppeteer-307l</link>
      <guid>https://forem.com/nicolasai/generating-linkedin-carousels-as-multi-page-pdfs-with-puppeteer-307l</guid>
      <description>&lt;p&gt;LinkedIn carousels are not images. They are PDFs, one page per slide, uploaded as a document attachment to the share. LinkedIn's renderer turns each page into a swipeable slide on the feed. This is one of those things that took me three failed uploads to figure out, because the documentation says "carousel" and your mind says "image sequence."&lt;/p&gt;

&lt;p&gt;Once you know it is a PDF, the pipeline is straightforward. Render HTML templates to PDF with Puppeteer, upload the PDF to LinkedIn as a document, attach to a share. Here is the working setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The HTML template approach
&lt;/h2&gt;

&lt;p&gt;Each slide is a 1080x1350 HTML page. I render them with React on the server, write the HTML to disk, then point Puppeteer at it.&lt;/p&gt;

&lt;p&gt;The slide template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Slide&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;SlideProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;head&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;style&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`
          @page { size: 1080px 1350px; margin: 0; }
          html, body { margin: 0; padding: 0; width: 1080px; height: 1350px; }
          .slide {
            width: 1080px; height: 1350px;
            display: flex; flex-direction: column;
            padding: 80px;
            font-family: Inter, system-ui, sans-serif;
          }
        `&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;style&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;head&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"slide"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;56&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginTop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;marginTop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; / &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@page&lt;/code&gt; rule is the part that keeps biting people. PDF page size is set in CSS, not in Puppeteer. Set it once per page, omit margins, and your output matches the slide dimensions exactly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Puppeteer call
&lt;/h2&gt;

&lt;p&gt;Render each slide's HTML to a separate PDF, then concatenate. This is more reliable than rendering one giant multi-page document because Puppeteer handles single-page renders cleanly and concatenation is fast.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;puppeteer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdf-lib&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderCarousel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slides&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SlideHtml&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;headless&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;pdfBuffers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slideHtml&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;slides&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slideHtml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;networkidle0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1080px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1350px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;printBackground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;preferCSSPageSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;pdfBuffers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Concatenate into a single PDF&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pdfBuffers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;single&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;copied&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copyPages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;single&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;copied&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;preferCSSPageSize: true&lt;/code&gt; is what makes the &lt;code&gt;@page&lt;/code&gt; size in your HTML actually apply. Without it, Puppeteer falls back to its default A4 and you get a tiny 1080px image floating in a giant page.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;printBackground: true&lt;/code&gt; is the difference between "your background colors render" and "your slides come out white because Puppeteer skips background paints by default."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;waitUntil: "networkidle0"&lt;/code&gt; matters if your slides load fonts or images. Skip it and you race the loader.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hosting Puppeteer on Vercel
&lt;/h2&gt;

&lt;p&gt;Puppeteer ships with a bundled Chromium. The bundle is roughly 170MB, which exceeds Vercel's serverless function size limit. You have three options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;@sparticuz/chromium&lt;/code&gt;&lt;/strong&gt;, which is a slimmed Chromium specifically for serverless. Combined with &lt;code&gt;puppeteer-core&lt;/code&gt;, the function fits under the 50MB limit on Vercel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the rendering on a separate machine&lt;/strong&gt; (a small Fly.io or Render container) and call it from Vercel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a managed PDF API&lt;/strong&gt; like Browserless or PDFShift.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I went with option 1 for cost, option 2 for reliability when traffic gets uneven. Both work. Option 3 is fastest to set up and the most expensive at scale.&lt;/p&gt;

&lt;p&gt;Setup with &lt;code&gt;@sparticuz/chromium&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;puppeteer-core&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@sparticuz/chromium&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;executablePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;executablePath&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;headless&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headless&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Uploading to LinkedIn
&lt;/h2&gt;

&lt;p&gt;Once you have the PDF, the upload flow is similar to images. POST to &lt;code&gt;/rest/documents?action=initializeUpload&lt;/code&gt;, PUT the PDF bytes to the returned upload URL, attach the document URN to your share.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.linkedin.com/rest/documents?action=initializeUpload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LinkedIn-Version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;202401&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;initializeUploadRequest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;authorUrn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uploadUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pdfBytes&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Attach value.document URN to your UGC share's media field&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LinkedIn caps document size at 100MB and 300 pages. For carousels, you will usually be in the 1-5MB and 5-15 page range, so the cap rarely matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the user sees
&lt;/h2&gt;

&lt;p&gt;The PDF you upload becomes a swipeable carousel in the feed. Each page is one slide. LinkedIn's renderer adds the page count overlay automatically, but I include my own &lt;code&gt;1/N&lt;/code&gt; indicator at the bottom of each slide because the LinkedIn one is positioned poorly on mobile.&lt;/p&gt;

&lt;p&gt;Once the upload pipeline is in place, generating 10-slide carousels takes about 4-6 seconds end to end. Most of that is Puppeteer cold start. Subsequent runs in the same warm function drop to 2-3 seconds.&lt;/p&gt;

&lt;p&gt;The whole thing is one of those "nobody told me it was a PDF" gotchas. Once you know, the implementation is shorter than the figuring-out.&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>pdf</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>BYOK Architecture: How I Made My LinkedIn AI Tool 96% Cheaper Than Competitors</title>
      <dc:creator>Nicolas Lecocq</dc:creator>
      <pubDate>Sun, 10 May 2026 15:00:02 +0000</pubDate>
      <link>https://forem.com/nicolasai/byok-architecture-how-i-made-my-linkedin-ai-tool-96-cheaper-than-competitors-18np</link>
      <guid>https://forem.com/nicolasai/byok-architecture-how-i-made-my-linkedin-ai-tool-96-cheaper-than-competitors-18np</guid>
      <description>&lt;p&gt;The standard SaaS pricing for AI tools in 2026 looks like this: pay $49 a month, get 100 generations, anything beyond costs extra credits. The reason is simple. The vendor pays the model API per token, marks it up to cover infrastructure plus margin, and caps your usage so they do not lose money on heavy users.&lt;/p&gt;

&lt;p&gt;I went the other way. Users on my LinkedIn tool bring their own OpenAI, Anthropic, Google, or Grok key. They pay the AI provider directly, at the provider's pricing. I charge a flat $19 a month for the platform itself, with no token caps.&lt;/p&gt;

&lt;p&gt;The math the user sees: $19 to me + $2-4 a month to OpenAI for typical usage = $21-23 total. The competitor charging $49 with caps costs them more, gives them less, and runs out faster.&lt;/p&gt;

&lt;p&gt;This article is about why this works, what the architecture looks like, and what tradeoffs you accept when you go BYOK.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost math
&lt;/h2&gt;

&lt;p&gt;Take a typical creator generating 30 LinkedIn posts a month with GPT-4o or Claude Sonnet. Each generation is roughly 2,000 input tokens (your context, voice samples, brand info) plus 800 output tokens. At current OpenAI prices, that is about $0.03 per generation. Thirty a month: $0.90.&lt;/p&gt;

&lt;p&gt;Add some image generation: 4 LinkedIn carousels, 8 hook variations tested. Maybe $1.50 in image cost.&lt;/p&gt;

&lt;p&gt;Total monthly AI cost for an active user: $2-3. Heavy users hit $5-8. None of them hit $20.&lt;/p&gt;

&lt;p&gt;Meanwhile, the standard SaaS markup is 5-10x. The vendor sees the same $2-3 in API cost and charges $49. Caps usage so heavy users do not eat the margin. Adds "credits" to upsell on overage.&lt;/p&gt;

&lt;p&gt;BYOK eliminates the markup entirely. Users pay the API at API prices.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The user stores their own API key in their account. My app reads it at request time and calls the provider directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generatePost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;encryptedOpenaiKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preferredModel&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;buildMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;voiceProfile&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The platform never proxies API requests. It just decrypts the key on the request hot path and uses it. No vendor middlemen, no rate-limit aggregation, no shared infrastructure billing.&lt;/p&gt;

&lt;p&gt;Storage: the API key is encrypted at rest with a per-user secret derived from your platform's master key plus the user ID. I use AES-256-GCM. Never log the key. Never return it in responses (return a masked version like &lt;code&gt;sk-...AbCd&lt;/code&gt; for the UI).&lt;/p&gt;

&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;p&gt;There are real tradeoffs. Pretending there are not is dishonest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot offer a "no setup, click and go" experience.&lt;/strong&gt; Users have to create an account on OpenAI or Anthropic, generate a key, and paste it into your settings. That is friction. It eliminates the casual try-it crowd. Some of them never come back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot promise a fixed monthly cost.&lt;/strong&gt; A new user does not know what their AI usage will look like. Telling them "between $2 and $8 depending on usage" is true but feels uncertain. Users like fixed numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot enforce per-user quotas centrally.&lt;/strong&gt; If a user gives their key to four people, you cannot stop that. The provider stops them when they hit their own provider limits. From your side, that is fine. From a "use case enforcement" perspective, it is a gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Support gets weirder.&lt;/strong&gt; When generations fail, the question is now "is it your code or my API key." Users blame you for provider errors. You build error mapping that surfaces "this is an OpenAI 429, not us." It works but it is more code than a single-tenant setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  When BYOK is wrong
&lt;/h2&gt;

&lt;p&gt;BYOK does not fit every product. Three cases where it fails:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your model is fine-tuned or proprietary.&lt;/strong&gt; If you trained on your own data and serve a custom endpoint, you cannot let users bring their own key. There is no key to bring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your users are non-technical and the API key step kills them.&lt;/strong&gt; B2C tools targeting "anyone can do it" audiences lose 30-50% of signups at the key step. If your audience is "marketers who do not know what an API is," do not BYOK.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your value is in the routing, caching, or aggregation.&lt;/strong&gt; Tools that route a single request across multiple models, cache embeddings across users, or aggregate inference costs across a tenant cannot easily go BYOK.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For my use case (LinkedIn content generation, technical-leaning audience, no caching benefit because each user's voice is unique), BYOK is a clean win. It would be wrong for a different audience or different value proposition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pricing pitch
&lt;/h2&gt;

&lt;p&gt;When a user looks at a competitor at $49 a month with a 100-post cap, then looks at $19 a month plus $2-4 in API costs and unlimited generation, the math is a hard yes. The friction of pasting an API key is real but it is a one-time cost. The price difference is a recurring cost.&lt;/p&gt;

&lt;p&gt;Most BYOK conversions happen on the second visit. They see the architecture, leave, do the math, come back. The first visit is too noisy for the tradeoffs to land.&lt;/p&gt;

&lt;p&gt;If you are pricing an AI SaaS and your only differentiator is the model output, BYOK is worth modeling. The margin you give up is offset by the conversion you gain on price-sensitive users.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>architecture</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Scheduling Posts Exactly On Time With QStash + Next.js</title>
      <dc:creator>Nicolas Lecocq</dc:creator>
      <pubDate>Sat, 09 May 2026 15:00:01 +0000</pubDate>
      <link>https://forem.com/nicolasai/scheduling-posts-exactly-on-time-with-qstash-nextjs-9k9</link>
      <guid>https://forem.com/nicolasai/scheduling-posts-exactly-on-time-with-qstash-nextjs-9k9</guid>
      <description>&lt;p&gt;I needed to schedule LinkedIn posts to publish at exact times chosen by users. Vercel cron supports cron expressions, so I figured I would run a job every minute and check if any post should publish in the next minute. That works. It is also wasteful, fires a function 1,440 times a day, and on free Vercel plans you only get a 1-minute resolution which is not exact.&lt;/p&gt;

&lt;p&gt;I switched to Upstash QStash. Same family as Redis, separate product, designed for delayed and scheduled HTTP requests. The setup ended up cleaner than the cron approach and runs at the exact second.&lt;/p&gt;

&lt;h2&gt;
  
  
  What QStash actually does
&lt;/h2&gt;

&lt;p&gt;You publish a message to QStash with a target URL and a delay or cron expression. QStash sits on the message until the time is right, then makes an HTTP POST to your URL with whatever body you set. It signs the request so you can verify it came from QStash.&lt;/p&gt;

&lt;p&gt;For one-off scheduling, you publish once with a &lt;code&gt;delay&lt;/code&gt; or &lt;code&gt;notBefore&lt;/code&gt; parameter. For recurring jobs, you create a &lt;code&gt;schedule&lt;/code&gt; with a cron expression. Both go through the same delivery path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The publish call
&lt;/h2&gt;

&lt;p&gt;For a post the user wants to schedule at a specific timestamp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@upstash/qstash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qstash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QSTASH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/linkedin/publish-scheduled`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;qstash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publishJSON&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;targetUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abc123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_456&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;notBefore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scheduledAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// unix seconds&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// store result.messageId so you can cancel later&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;notBefore&lt;/code&gt; field is a unix timestamp in seconds. QStash will not deliver before that time. Delivery typically happens within a second of the target time, in my testing.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;messageId&lt;/code&gt; from the response is what you use to cancel the schedule later. Store it on the post row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receiving and verifying
&lt;/h2&gt;

&lt;p&gt;The route that QStash calls has to verify the signature. Without verification, anyone could POST to your endpoint and trigger publishing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Receiver&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@upstash/qstash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;receiver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Receiver&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;currentSigningKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QSTASH_CURRENT_SIGNING_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;nextSigningKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QSTASH_NEXT_SIGNING_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;upstash-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;receiver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/linkedin/publish-scheduled`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...do the actual publishing&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two signing keys: current and next. QStash rotates them periodically and your verifier accepts both during the rotation window. Set them up once in your env vars and forget about them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancellation
&lt;/h2&gt;

&lt;p&gt;When the user unschedules a post, you delete the QStash message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;qstash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qstashMessageId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the message has already been delivered, this returns 404 and you ignore it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use cron schedules vs one-off
&lt;/h2&gt;

&lt;p&gt;QStash supports both. I use &lt;code&gt;publishJSON&lt;/code&gt; for user-scheduled posts because each one needs its own target time. For things like "run this every day at 14:00 UTC" (cleanup jobs, daily syncs), I use &lt;code&gt;schedules.create&lt;/code&gt; once at deploy time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://qstash-us-east-1.upstash.io/v2/schedules/&lt;/span&gt;&lt;span class="nv"&gt;$DESTINATION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$QSTASH_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Upstash-Cron: 0 14 * * *"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Upstash-Retries: 2"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two retries on failure, daily at 14:00 UTC. The schedule lives until you delete it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What QStash does not do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It does not run code. It calls your URL. You still need a Next.js route handler doing the work.&lt;/li&gt;
&lt;li&gt;It does not give you transaction guarantees. If your handler fails after partially completing, you handle that.&lt;/li&gt;
&lt;li&gt;It is not free past the lowest tier. The free tier is generous (500 messages a day at the time of writing), but a busy app outgrows it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The before/after
&lt;/h2&gt;

&lt;p&gt;With Vercel cron, my "publish scheduled posts" job ran 1,440 times a day. Most of those runs found nothing to do. Function invocations on Vercel cost money past the free quota.&lt;/p&gt;

&lt;p&gt;With QStash, I run zero idle invocations. Each scheduled post triggers exactly one function call at the exact time the user wanted. My cost dropped, my logs got cleaner, and the publish-time accuracy went from "within the next minute" to "within 1-2 seconds of the requested time."&lt;/p&gt;

&lt;p&gt;If you are building anything that needs to run code at a specific time the user chose, QStash is worth the 30 minutes of setup.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>vercel</category>
      <category>qstash</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Posting to LinkedIn From Node.js: 7 API Quirks That Burned Me</title>
      <dc:creator>Nicolas Lecocq</dc:creator>
      <pubDate>Fri, 08 May 2026 15:00:01 +0000</pubDate>
      <link>https://forem.com/nicolasai/posting-to-linkedin-from-nodejs-7-api-quirks-that-burned-me-5885</link>
      <guid>https://forem.com/nicolasai/posting-to-linkedin-from-nodejs-7-api-quirks-that-burned-me-5885</guid>
      <description>&lt;p&gt;I built a tool that posts to LinkedIn programmatically. Here is the list of things I wish I had known before I started, with code where it helps. None of these are documented prominently. All of them cost me an evening or two.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Author URN, not user ID
&lt;/h2&gt;

&lt;p&gt;When you create a UGC post, the API does not accept a user ID. It accepts a URN like &lt;code&gt;urn:li:person:abc123&lt;/code&gt;. If you store the LinkedIn user ID as a string and try to send it directly, you get a &lt;code&gt;INVALID_AUTHOR&lt;/code&gt; error with no further detail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authorUrn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`urn:li:person:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;linkedinProfileId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For company pages, it is &lt;code&gt;urn:li:organization:12345&lt;/code&gt;. You must store the URN type along with the ID, or compute it at request time based on whether you are posting as a person or a company.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The image upload is three calls, not one
&lt;/h2&gt;

&lt;p&gt;You cannot just send a base64 image in the post body. The flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;POST &lt;code&gt;/rest/images?action=initializeUpload&lt;/code&gt; with the author URN. Response includes an &lt;code&gt;uploadUrl&lt;/code&gt; and an &lt;code&gt;image&lt;/code&gt; URN.&lt;/li&gt;
&lt;li&gt;PUT the binary image bytes to that &lt;code&gt;uploadUrl&lt;/code&gt;. No auth header. LinkedIn's signed URL handles auth.&lt;/li&gt;
&lt;li&gt;POST your share with the image URN in the &lt;code&gt;media&lt;/code&gt; field.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Step 1&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.linkedin.com/rest/images?action=initializeUpload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LinkedIn-Version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;202401&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Restli-Protocol-Version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;initializeUploadRequest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;authorUrn&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uploadUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uploadUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;imageUrn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Step 2 — note: no auth header on the PUT&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uploadUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageBuffer&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Step 3 — use imageUrn in your share&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you skip the &lt;code&gt;LinkedIn-Version&lt;/code&gt; header, you get a 400 with no explanation. The version is a date string, not a semver.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Empty body returns 422, not 400
&lt;/h2&gt;

&lt;p&gt;If you POST a share with &lt;code&gt;commentary&lt;/code&gt; set to an empty string, LinkedIn returns 422 Unprocessable Entity. If you omit &lt;code&gt;commentary&lt;/code&gt; entirely, you also get 422. The error message says nothing useful. The fix is to always send at least one space if the user wanted an image-only post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;commentary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Rate limits are silent
&lt;/h2&gt;

&lt;p&gt;There is no &lt;code&gt;X-RateLimit-Remaining&lt;/code&gt; header. There is no documented per-user limit. Your app has a daily quota you can see in the developer portal, but per-user behavior is enforced at LinkedIn's discretion. If you post too fast on the same user account, you start getting 429 errors with a generic message. Back off exponentially. Plan for 5 to 10 posts per user per day as a soft ceiling, even if your own users would never hit that.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Token expiry is real, refresh is opt-in
&lt;/h2&gt;

&lt;p&gt;Access tokens are valid for 60 days. After that, the API returns 401 with a &lt;code&gt;REVOKED_ACCESS_TOKEN&lt;/code&gt; error. You can opt into refresh tokens by adding &lt;code&gt;offline_access&lt;/code&gt; to your scopes, but only if your app has been approved for it. Most apps are not. Plan to re-prompt users for consent every 60 days, or apply for the refresh flow if your use case justifies it.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Scope creep does not apply retroactively
&lt;/h2&gt;

&lt;p&gt;If you grant a user &lt;code&gt;openid profile email&lt;/code&gt; today and add &lt;code&gt;w_member_social&lt;/code&gt; next week, the user's existing access token does NOT include the new scope. You need to re-run them through OAuth with the new scopes. The token they get back includes the union of new and previously granted scopes.&lt;/p&gt;

&lt;p&gt;The annoying part: there is no clean way to detect "this user needs to re-auth" from the API. You only find out when you try to call an endpoint that requires the new scope and it returns 403. Track the scopes you requested per user in your own database and compare against your current code.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The consent screen caches aggressively
&lt;/h2&gt;

&lt;p&gt;If you change scopes during development and try to test, LinkedIn shows the user the cached consent from last time. The token comes back with the OLD scopes. You will spend time wondering why your code is not picking up the new scope you added.&lt;/p&gt;

&lt;p&gt;Two fixes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Option A: force consent every time during dev&lt;/span&gt;
&lt;span class="nx"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;consent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Option B: have the user revoke the connection in their LinkedIn settings&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use option A while developing. Drop it for production because forcing consent every login annoys users.&lt;/p&gt;

&lt;p&gt;That is the list. None of these are exotic. All of them have bitten me more than once. Bookmark this if you are building anything that touches LinkedIn programmatically.&lt;/p&gt;

</description>
      <category>api</category>
      <category>node</category>
      <category>linkedin</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Reverse-Engineering LinkedIn's 360Brew From Their Engineering Blog</title>
      <dc:creator>Nicolas Lecocq</dc:creator>
      <pubDate>Thu, 07 May 2026 15:00:01 +0000</pubDate>
      <link>https://forem.com/nicolasai/reverse-engineering-linkedins-360brew-from-their-engineering-blog-4jm6</link>
      <guid>https://forem.com/nicolasai/reverse-engineering-linkedins-360brew-from-their-engineering-blog-4jm6</guid>
      <description>&lt;p&gt;LinkedIn quietly replaced its feed ranking system in 2026. Not with a tweaked version of the old one. With a single 150-billion-parameter language model called 360Brew, built on top of LLaMA 3 and fine-tuned on internal data. They published the technical details on their engineering blog. Most people did not read it.&lt;/p&gt;

&lt;p&gt;Here is what the paper actually says, with the parts that matter for anyone building on or analyzing the platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it replaced
&lt;/h2&gt;

&lt;p&gt;The old LinkedIn ranking pipeline was a chain of around thirty specialized models. Each one scored a numerical feature: dwell time, sender-receiver affinity, click-through rate, comment likelihood, and so on. The features were stitched together by a final ranking layer that picked which posts to show.&lt;/p&gt;

&lt;p&gt;This is the same pattern most social platforms have used since the 2010s. It is fast, cheap, well-understood, and easy to A/B test. The downside is that it can only see what the engineers thought to measure. Anything outside those features is invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 360Brew does instead
&lt;/h2&gt;

&lt;p&gt;360Brew is one decoder-only model. It takes a post, the author profile, the candidate reader profile, and recent interaction history, and asks itself a single question: would this specific reader find this specific post worth engaging with.&lt;/p&gt;

&lt;p&gt;The reason this matters: the model understands language. The old pipeline could count words but could not tell that a post about "Gong's revenue intelligence platform" and "Salesforce CRM workflows" are talking about the same thing. 360Brew can. That changes which posts get cross-cluster distribution.&lt;/p&gt;

&lt;p&gt;It also changes which posts get punished. If the model decides a post sounds generic or rehearsed, it lowers the score regardless of how engagement-bait the structure is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concrete behaviors that changed
&lt;/h2&gt;

&lt;p&gt;A few patterns I noticed by looking at my own posts and a friend's analytics, then matching against what the paper says is happening:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hooks that worked in 2024 stop working.&lt;/strong&gt; The "Stop scrolling. This will change your life." family of openers ranks lower because the model recognizes them as a pattern. Even small variations get caught.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Saves and long thoughtful comments matter more than likes.&lt;/strong&gt; The old pipeline scored all engagement somewhat similarly. 360Brew weights interactions by how much intent they show, and saving is the biggest signal because it costs the user something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Topic clusters lock in faster.&lt;/strong&gt; Your profile gets classified into a topic cluster based on your last 60 to 90 days of posting. Once you are in a cluster, posting outside it gets capped. The old system was more forgiving about category drift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pods and engagement rings died.&lt;/strong&gt; The old engagement-counting models could not tell the difference between "200 people who care liked this" and "200 people in a pod liked this." 360Brew sort of can, by reading the comments. If the comments are off-topic or generic, the model assumes the engagement is fake and downweights distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for builders
&lt;/h2&gt;

&lt;p&gt;If you are writing tools that touch LinkedIn data, you cannot rely on the old engagement-rate metrics anymore. Two posts with identical likes and comments can have wildly different reach because 360Brew weighs the quality of the engagement, not just the count.&lt;/p&gt;

&lt;p&gt;A few practical implications for any tool that schedules, generates, or analyzes LinkedIn content:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scoring "good content" by like-prediction is now wrong. The model rewards substance, not engagement-bait. Your scoring model should reward specificity, not virality patterns.&lt;/li&gt;
&lt;li&gt;Voice-matching matters more than format-matching. The old algo loved certain formats (line-broken hooks, three-bullet bodies). 360Brew does not care about format. It cares whether the post says something a real person would say.&lt;/li&gt;
&lt;li&gt;Comment quality is a real ranking signal now. If you build comment automation, the model can tell. Generic comments hurt your distribution score.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part the paper does not say
&lt;/h2&gt;

&lt;p&gt;The paper is careful to never give actual weights or thresholds. No "saves count X times more than likes." No "the topic cluster window is exactly 60 days." Those numbers are inferred from creator behavior, not declared.&lt;/p&gt;

&lt;p&gt;That means anyone telling you the exact recipe for going viral under 360Brew is making it up. The honest answer is the model rewards posts that say something specific, posted into a topic cluster the author has been building, and engaged with by people in that cluster within the first hour.&lt;/p&gt;

&lt;p&gt;The full paper is on the &lt;a href="https://www.linkedin.com/blog/engineering/feed/engineering-the-next-generation-of-linkedins-feed" rel="noopener noreferrer"&gt;LinkedIn Engineering Blog&lt;/a&gt;. Worth reading even if you do not build on LinkedIn. It is one of the cleaner public write-ups of replacing a feature-engineering pipeline with a foundation model.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>linkedin</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Sign In With LinkedIn Using OpenID Connect in Next.js 16</title>
      <dc:creator>Nicolas Lecocq</dc:creator>
      <pubDate>Wed, 06 May 2026 19:24:12 +0000</pubDate>
      <link>https://forem.com/nicolasai/sign-in-with-linkedin-using-openid-connect-in-nextjs-16-1p48</link>
      <guid>https://forem.com/nicolasai/sign-in-with-linkedin-using-openid-connect-in-nextjs-16-1p48</guid>
      <description>&lt;p&gt;LinkedIn finally moved Sign In to OpenID Connect a while back. Most of the tutorials still floating around the internet show the legacy v1 OAuth dance with &lt;code&gt;r_liteprofile&lt;/code&gt; and &lt;code&gt;r_emailaddress&lt;/code&gt; scopes. Those are deprecated. If you copy them, your callback will work for a while and then mysteriously stop, which is the worst kind of bug.&lt;/p&gt;

&lt;p&gt;Here is the flow that actually works in 2026 with Next.js 16 and NextAuth v5.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scopes you want
&lt;/h2&gt;

&lt;p&gt;Forget &lt;code&gt;r_liteprofile&lt;/code&gt; and &lt;code&gt;r_emailaddress&lt;/code&gt;. The current Sign-In product asks for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openid profile email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three scopes, lowercase, space separated. That is the entire request. LinkedIn returns a standard OIDC ID token plus a regular access token. No more profile-specific endpoints, no more email-specific endpoints. The user identity ships in the token claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  The provider config in NextAuth v5
&lt;/h2&gt;

&lt;p&gt;NextAuth v5 has a built-in LinkedIn provider, but it ships with the legacy scopes. You need to override it. Here is the working config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/auth.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;LinkedIn&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-auth/providers/linkedin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signOut&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NextAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;LinkedIn&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LINKEDIN_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;clientSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LINKEDIN_CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.linkedin.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openid profile email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.linkedin.com/oauth/v2/accessToken&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;userinfo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.linkedin.com/v2/userinfo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;picture&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two parts that trip people up: the &lt;code&gt;userinfo&lt;/code&gt; endpoint is &lt;code&gt;/v2/userinfo&lt;/code&gt;, not the legacy &lt;code&gt;/v2/me&lt;/code&gt;, and the &lt;code&gt;profile.sub&lt;/code&gt; field is what you want as the stable LinkedIn user ID. Do not use &lt;code&gt;profile.id&lt;/code&gt;. It does not exist on the new endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Callback URL
&lt;/h2&gt;

&lt;p&gt;In the LinkedIn Developer Portal, register exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://your-domain.com/api/auth/callback/linkedin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you forget the trailing path, LinkedIn returns a generic "Bummer, something went wrong" page with no useful debug info, and you will spend forty minutes wondering if you broke your environment variables. Ask me how I know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storing the access token
&lt;/h2&gt;

&lt;p&gt;The OIDC ID token tells you who the user is. The access token lets you call other LinkedIn APIs on their behalf, like posting to their feed. NextAuth v5 hands you both in the &lt;code&gt;jwt&lt;/code&gt; callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;linkedin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;linkedinAccessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;linkedinExpiresAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist &lt;code&gt;linkedinAccessToken&lt;/code&gt; to your database row for the user, plus the expiry timestamp. LinkedIn's access tokens are valid for 60 days. They do support refresh tokens now, but only if you specifically request the &lt;code&gt;offline_access&lt;/code&gt; scope, which most apps do not need. For a 60-day window, store it, check the expiry on each use, and re-prompt if expired.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you do not get from OIDC
&lt;/h2&gt;

&lt;p&gt;Sign-In gives you identity. It does not give you posting rights. To post on someone's behalf, you need the separate &lt;code&gt;w_member_social&lt;/code&gt; scope through the "Share on LinkedIn" product. Same OAuth, different consent screen, different scope. Apply for it through the developer portal.&lt;/p&gt;

&lt;p&gt;Same story for company page management, feed reading, and the Community Management API. Each is a separate "product" you have to apply for in your LinkedIn app, and each has its own approval queue. Sign-In gets approved instantly. Posting takes a few days. Community APIs take longer and ask for use cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug to watch for
&lt;/h2&gt;

&lt;p&gt;LinkedIn caches the consent screen aggressively. If you change scopes in your code and try to test, the user gets sent back to LinkedIn with the OLD consent already granted. The token they return does not include your new scopes. The fix: revoke the existing connection in their LinkedIn account settings, or pass &lt;code&gt;prompt=consent&lt;/code&gt; in the authorization params:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openid profile email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;consent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This forces a fresh consent dialog every time. Use it during development, drop it for production.&lt;/p&gt;

&lt;p&gt;That is the whole flow. Three scopes, one userinfo endpoint, one callback URL, one access token that lives 60 days. Most of the painful parts of the old LinkedIn OAuth went away when they moved to OIDC. The rest of the painful parts are documented above so you do not have to discover them at 2am.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>oauth</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
