vercel

Activity

Stars and mentions · Last 30 days, Oct 7 – Nov 5

GitHub stars2,145
Mentions119
057114171228Oct 7Oct 21Nov 5
UTC days · repositories vercel owns27 repositories

Top repositories

vercel’s repositories ranked by pull request authors, as on trending.

Loading

Contributes to

Repositories outside vercel’s account where vercel opened or reviewed pull requests in the past 365 days.

Who noticed vercel

Everyone who starred or forked vercel’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

114posts
Nov 61 post
  1. Inflearnのコンテンツページに動的OG画像を導入する こんにちは、Inflabのフロントエンド開発者、Yoongdyです。 最近、Inflearnのユーザーの皆さんが心を込めて書いてくださった受講レビューや質問&回答のページをSNSでシェアしたときに真っ先に目に入るリンクプレビュー画像が、以前と変わったことにお気づきの方はいらっしゃるでしょうか? まだ気づいていない方は、この記事を読む前に、ご自身がInflearnに投稿した自慢の受講レビューや質問&回答をSNSでシェアしてみてください。この記事をより楽しく読んでいただけるはずです。 それでは、OG画像を動的に生成する仕組みを開発する中で得た経験を共有していきます。 始める前に、OG画像とは? オープングラフ(OG) 画像とは、ソーシャルメディア(例: FacebookやSlack)にリンクをシェアしたときに、Webページの情報をプレビューできるよう表示される代表画像です。リンクのプレビュー画像は、見る人の関心を視覚的に引きつけ、クリックを促すうえで大きな効果があります。 Inflearnにはなぜ動的に生成されるOG画像が必要なのか? Inflearnには、講座の受講生や、講座を提供する講師の方々が自ら書いた良質なコンテンツがたくさんあります。こうしたコンテンツを外部に共有するときは、リンクをコピーしてSNSでシェアすることが多いですよね。 SNSでシェアされたInflearnのスタディ募集投稿の例 (スタディ募集の投稿なのに、OG画像にはInflearnのスローガンしか表示されず、募集の投稿であることが一目では分からない) しかし実際には、上のように外部にシェアされたコンテンツは、リンクからInflearnのサイトにアクセスして中身を確認しない限り、どんな内容なのか一目では分からないことがよくあります。 受講レビュー詳細ページのreferrer指標 ちなみに、最近の受講レビュー詳細ページの流入元(referrer)指標を見ると、SNSでのシェア経由でInflearnのWebサイトにアクセスするユーザーが大きな割合を占めています。データでこれが裏付けられたことで、リンクシェア時に表示されるプレビュー画像を早急に改善したいという声が高まりました。 GeekNewsのOG画像の例 改善のために他のWebサイトのOG画像を参考にしていたところ、毎週Slackで共有されるGeekNewsのリンクのOG画像こそ、この問題を解決してくれそうだと感じました。 というのも、普段GeekNewsで読む記事を選ぶとき、OG画像にあるタイトルと概要を見て、興味のあるテーマなら自然とリンクをクリックしていたからです。 そこで、Inflearnのユーザーコンテンツページにも、OG画像を動的に生成して適用することに決めました。 コンテンツごとに動的にOG画像を生成するには? OG画像を動的に作る方法をいろいろ調べる中で、Vercelが @vercel/og というオープンソースライブラリを提供していることを知りました。HTML/CSSをSVGに変換し、ソーシャル画像を高速かつダイナミックに作成できるライブラリです。 @vercel/og ライブラリを使う Vercelが提供するライブラリ @vercel/og の使い方はとても簡単です。 公式ドキュメントに丁寧に書かれているとおり、まずNext.jsプロジェクトにライブラリをインストールします。あとは、プロジェクトのルーター方式に合わせてAPIルートを作成し、ライブラリのインターフェースに沿ってコードを書けば完了です。 import { ImageResponse } from '@vercel/og'; export const config = { runtime: 'edge', }; export default async function handler() { return new ImageResponse( ( <div style={{ fontSize: 40, color: 'black', background: 'white', width: '100%', height: '100%', padding: '50px 200px', textAlign: 'center', justifyContent: 'center', alignItems: 'center', }} > 👋 Hello </div> ), { width: 1200, height: 630, fonts: [ { style: "normal", name: "Pretendard", data: boldFontBuffer, weight: 700, } ... ] }, ); } @vercel/og ライブラリを使う際の注意点は次のとおりです。 Node.js 16以上のみをサポートしています。 @vercel/og はNode.jsランタイムをサポートしていません。Edgeランタイムでのみ動作します。 内部のコアエンジンであるSatoriが許容する一部のHTML/CSS機能のみをサポートしています。 👀 ライブラリの使い方の詳細は、@vercel/og の公式ドキュメントをご確認ください。 Webページに動的OG画像を適用する 動的に生成されるOG画像をWebページに適用するのも、とても簡単です。 <head> <title>Inflearn - 受講レビュー詳細ページ</title> <meta property="og:image" content="inflearn.com" /> </head> 動的OG画像を適用するページのHTMLで、<meta> タグの og:image 属性の値をOG画像生成APIのURLに変更するだけで完了です。 さらに、Twitterでのリンクの表示形式を決める twitter:card メタタグを summary_large_image に設定すると、ツイート内のリンクプレビュー画像がより目立つようになります。 twitter:card summary 設定時 twitter:card summary_large_image 設定時 とても簡単ですが、これで終わりにすると、リンクをSNSやSlackなどでシェアするたびに、新しいOG画像を生成するための不要なリクエストとレスポンスが発生してしまいます。 コンテンツの内容が変わっていないのに、毎回新しい画像を生成する必要はあるでしょうか?(ないですよね) そこで、生成済みの画像をもう一度生成する無駄をなくし、画像をより速くユーザーに表示するために、Amazon CloudFrontというCDNサービスを使って改善しました。 動的に生成された画像のレスポンスをキャッシュする Inflearnのサービスのアーキテクチャでは、途中にCloudFront(= CDN)を挟んでいます。 リクエストを毎回オリジンサーバーまで届けるのではなく、同じリクエストが来たときはCDNにキャッシュしたレスポンスを返すことで、必要なリソースを素早くユーザーに提供するためです。 CDNキャッシュの図解 それなら、動的に生成されるOG画像のリクエストもキャッシュできるのではないでしょうか? これが可能なら、一度生成したことのある画像はリクエストがオリジンサーバーまで届かず、CDN側ですぐに処理できます。 動的OG画像生成器のリクエストとレスポンスの図解 受講レビューのIDを基準にCDNキャッシュを有効にした場合、レビューの内容が変更されたときに、キャッシュされた画像をどうやって更新すればよいでしょうか? DBには、updatedAt のような最終更新日時を保存しています。 変更があるたびに、このカラムの値を更新しています。 この値をCDNのキャッシュキーとして使い、og:image に設定するURLに含めればよいのです。 🧞‍♂️ ちょっとしたヒント og:image の値に設定するURLがエンコードされていないと、Slackのリンクプレビュー画像が表示されないことがあるので注意してください。 レビューの内容が変更された場合、updatedAt フィールドが更新され、og:image の値に設定したURLの updateKey も同じ値に変わります。 その後ユーザーがSNSにリンクをシェアすると、最新の updateKey を持つ og:image のURLにリクエストが送られます。 CDNにキャッシュされた古い updateKey の画像は、もうリクエストされなくなります。 この仕組みによって、画像レスポンスの状態や履歴を別途DBに保存しなくても、常に最新のコンテンツ内容を反映したOG画像をユーザーに提供できるようになりました。 Amazon CloudFrontのキャッシュ設定は次のとおりです。 Amazon CloudFrontの設定例 (CloudFront Cache Keyの設定例) TTL(Time To Live) Min: 0 Max: 1年 Default: 0 defaultとminを0にしているのは、cache-controlヘッダーを返さないリクエストをキャッシュしないためです。 Cache Key Settings Query Strings - ALL どんなクエリ文字列でもキャッシュキーとして機能します。 同じURIの画像であっても、クエリ文字列が異なれば別のキャッシュ対象とみなします。 最後に、Webページの <meta> タグの og:image 属性の値を変更します。 <meta property="og:image" content="cdn.inflearn.com" /> キャッシュ適用の結果 CDNキャッシュミス時: 1.271082084s GET /community/api/v1/og/review/41887 HTTP/1.1 HTTP/1.1 200 OK cache-control: no-cache, no-store X-Cache: Miss from cloudfront Elapsed time: 1.271082084s CDNキャッシュヒット時: 0.102158167s GET /community/api/v1/og/review/41887?updatedAt=1 HTTP/1.1 HTTP/1.1 200 OK Cache-Control: public, max-age=31536000, immutable X-Cache: Hit from cloudfront Elapsed time: 0.102158167s キャッシュにヒットしたかどうかで、画像生成リクエストのレスポンス速度がこれだけ違うのが分かるでしょうか? DBのようなリソースを別途構築しなくても、CDNを挟むだけで不要なリクエストとレスポンスが減り、およそ13倍速くなりました。ただし、惜しい点が一つあります。CDNにキャッシュされる前、つまり初回の画像生成のレスポンスが遅すぎることです。 これを改善するため、ターミナルから手軽にサーバーの負荷テストと性能測定ができるツールwrkを使い、毎秒n件の画像を生成したときにサーバーがどの程度の負荷に耐えられるかを測定しました。あわせて、Node.js V8 Inspectorのデバッグツールで画像生成処理のボトルネックを見つけ、初回の画像生成速度を改善するといった作業を行いました。 その取り組みを一つずつ紹介していきます。 画像生成の最適化 Inflabでは、Vercelプラットフォームを使わず、Next.jsをAWSクラウド上に自前で構築して運用しています。動的画像生成器も、standaloneコンテナとしてAmazon ECSクラスターにデプロイされます。 そのため、Edgeランタイムでのみ動作する @vercel/og の性能面のメリットを得られず、むしろ性能が落ちるうえ、次のような制約もありました。 EdgeランタイムはすべてのNode.js APIをサポートしているわけではないため、一部の npm パッケージが動作しない可能性があります。 例: lodash-es、fs Vercelにデプロイした場合、Edgeランタイムはサーバーレス関数として実行されます。一方、AWSのようなクラウドサービスでセルフホスティングする場合は、Edgeランタイムへの切り替え時にオーバーヘッドなどによる性能低下が起こる可能性があります。 社内で使っているDatadog APMとの連携やモニタリングが難しくなります。 そこで、Node.jsランタイムでも動作するライブラリに置き換え、性能を改善しつつモニタリングの効率も高めることにしました。 standaloneについて気になる方は、Rallitにstandaloneを適用した話を参考にしてください。 Satoriを直接使う Node.jsランタイムでHTML + CSSをSVGに変換するには、@vercel/og が内部で使っているSatoriライブラリを直接使う必要があります。 Satoriの使い方は、@vercel/og と大きくは変わりません。 @vercel/og はSVGからPNGへの変換などの細かい処理を抽象化しているので、追加で必要なのは、Satoriが返したSVGをPNG画像に変換する処理だけです。 import satori from "satori"; export default async function handler( request: NextApiRequest, response: NextApiResponse ) { const svg = await satori( ( <div style={{ fontSize: 40, color: 'black', background: 'white', width: '100%', height: '100%', padding: '50px 200px', textAlign: 'center', justifyContent: 'center', alignItems: 'center', }} > 👋 Hello </div> ), { width: 1200, height: 630, fonts: [ { style: "normal", name: "Pretendard", data: boldFontBuffer, weight: 700, } ... ] }, ); } Resvgの代わりにsharpを使う @vercel/og はSVGをPNGに変換する処理にResvgライブラリを使っているため、私たちも同じくResvgを使いました。 import { Resvg } from '@resvg/resvg-js' ... const resvg = new Resvg(svg, {}); const pngData = resvg.render(); const pngBuffer = pngData.asPng(); return response.setHeader("content-type", "image/png").send(pngBuffer); SVGをPNG画像に変換するところまでは成功です。性能も改善されているだろうと期待しながら、負荷テストを行いました。 しかし結果は… @vercel/og を使ったときと同様で、サーバーは1RPS(Requests per Second)すら耐えられませんでした。 ほかのPNG変換ライブラリを探していたところ、sharpでPNGに変換しているサンプルコードを見つけたので、試してみることにしました。 import sharp from "sharp"; ... const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer(); return response.setHeader("content-type", "image/png").send(pngBuffer); 結果は…? 0.4RPSから13RPSまで改善されました。 簡単に調べたところ、resvg-jsとsharpは似た処理を行いますが、用途が異なることが分かりました。 resvg-js SVGのグラフィック要素を高解像度でレンダリングして高品質な画像を生成するなど、ラスター画像への変換が主な用途 sharp Node.jsでの画像のリサイズ、回転、切り抜きなどの画像処理が主な用途 InflearnのOG画像に必要なのは、高解像度の画像生成ではなく単なるSVGからPNGへの変換なので、sharpのほうが適していました。性能もresvg-jsを使ったときより10倍以上良かったので、sharpを選ばない理由はありませんでした。 resvg-jsのGitHub issueにも、両者の性能差についての投稿があります。気になる方はご覧ください。sharp is faster for me when mass converting SVGs to HQ PNGs コード内のボトルネックを見つける Node.js inspectorを使うと、Chrome DevToolsからWebSocketで接続したNode.js(上で動くNext.js)サーバーのCPUを追跡できます。 Performanceタブで記録ボタンを押し、wrkでサーバーにリクエストを送りながらプロファイリングすると、次のような結果が得られます。 Node.jsのCPUプロファイリング結果 プロファイリングの結果、addFonts() という関数がリクエストのたびに呼び出されていることが分かりました。 この問題をデバッグした流れは次のとおりです。 Satoriライブラリの内部で、一度メモリにロードしたフォントをキャッシュしていないのではないかと疑う Satoriの内部コードで、options.fonts オブジェクトのメモリアドレスを key としてWeakMapをグローバル変数に宣言し、一度ロードしたフォントをキャッシュするロジックを確認 しかしプロファイリング結果では addFonts() が毎回呼び出されており、キャッシュしたフォントを再利用していないのではないかと疑う ライブラリをデバッグし、fontCache.set() だけが実行されて fontCache にエントリが溜まる一方で、実際にはキャッシュが使われていないことを確認 Satoriのこの問題についてはissueを立てましたが、すぐに解決されるとは期待できません。そこで、自分たちのコード側で解決する方法を探しました。 まず、私たちが突き止めた問題の原因は次のとおりです。 options.fonts オブジェクトのメモリアドレスが fontCache の key になっていることが根本的な原因 satori() の呼び出し時に渡す options.fonts オブジェクトのメモリアドレスがリクエストのたびに変わっている 呼び出すたびにメモリアドレスが変わるので、fontCache に設定した key も変わり、キャッシュが見つからない 毎回 addFonts() が無駄に呼び出され、ボトルネックになっている そこで「options.fonts オブジェクトのメモリアドレスを固定すればよいのでは?」という結論に至り、オプションの値をあらかじめ変数として宣言し、それを渡すようにコードを修正しました。 Satori optionsの修正前 ... const svg = await satori( ( <div style={{ fontSize: 40, color: 'black', background: 'white', width: '100%', height: '100%', padding: '50px 200px', textAlign: 'center', justifyContent: 'center', alignItems: 'center', }} > 👋 Hello </div> ), { width: 1200, height: 630, fonts: [ { style: "normal", name: "Pretendard", data: boldFontBuffer, weight: 700, }, ... ], } ); ... Satori optionsの修正後 const satoriOptions: SatoriOptions = { width: 1200, height: 630, fonts: [ { style: "normal", name: "Pretendard", data: boldFontBuffer, weight: 700, }, ... ], }; ... const svg = await satori( ( <div style={{ fontSize: 40, color: 'black', background: 'white', width: '100%', height: '100%', padding: '50px 200px', textAlign: 'center', justifyContent: 'center', alignItems: 'center', }} > 👋 Hello </div> ), satoriOptions ); ... 性能改善の結果 Node.jsのプロファイリングの結果、addFonts() は1回だけ呼び出され、それ以降は呼び出されないことを確認しました。 Node.jsのCPUプロファイリング結果(最終) wrkで負荷テストを行ったところ、Satoriのカスタムフォント(options.fonts)の設定を変数に切り出したことで、RPSが約1.8〜2倍以上向上しました。 おわりに 記事で紹介しきれなかった細かなコード最適化も合わせて、OG画像生成時のサーバー性能を1RPSから35RPSまで向上させることができました。 動的OG画像の生成という、一見簡単そうな作業に取り組む中で、次のようなことを実践しました。 サーバー側の作業では負荷テストなどをこまめに行い、性能を確認する Node.jsのCPUプロファイリングなどで、コードのボトルネックを見つけて改善する こうした取り組みを通じて、ユーザーにとっての使い心地を改善できたのは、良い経験になりました。 最後に、OG画像のPoCと性能最適化に一緒に取り組んでくれた、InflabのDevOpsチームのJakeとJaceに感謝を伝えて、本記事を締めくくります。 最後まで読んでいただき、ありがとうございました。🙏 ※この記事は2024年2月29日に執筆されたものです。

  2. Next.js is experimenting with Go support in the App Router! 👀 You can now write route handlers in Go using route.go, alongside the existing JS/TS route.ts handlers. Still experimental... PR: next.js#100130 t.co

    7 likes · 232 views
  3. vercel's best salesperson is a repo they give away. the example: next.js is free and open source - vercel/next.js, mit license, 143k stars as of today. every developer who builds on it is one deploy away from vercel's paid platform. the play: > find the layer of your product that isn't the moat: the sdk, the component library, the cli > open source it properly: docs, examples, a repo someone can actually run > the repo earns trust with people who will never read your landing page > the paid product is the next step from the free one, not a separate pitch free code, paid platform. the repo does the warming up.

    6 likes · 178 views
  4. next.js merged experimental App Router route.go handlers write GET/POST in Go, turbopack compiles it, runtime: "go". still behind experimental.goRoutes but wild for hot paths

  5. I may have missed something obvious, but I can't find the references to ethereum in the source code. Can you point me to the js file? I have found this though: next.js#44899 It may be unrelated to the app which is using Vercel next.js and it seems to catch the error when in dev mode (yes .. I don't know much about it). Brave uses a wallet (Brave Wallet) and it looks like it is adding this every time you visit a site. Newer versions of Brave shouldn't display this error (or when browsing in private mode). I am confused, because I am discovering this right now. Sorry. localtunnel#514 Anyways, if app author is reading, there may be a simple solution by adding this code: window.addEventListener('error', e => { if (e.message?.includes('ethereum')) e.stopImmediatePropagation(); }); ps: when I visited the site, I intuitively said no to myself "no way I am activating my camera in order to try air guitar, I cannot trust what I am viewing". The vibe feel, the lack of vids, the overall "quickly setup site" made me go.

  6. 👀 Quite exciting Next.js PR "The intended use is to prepare on pointerdown and commit on click, which starts the dynamic request earlier." This could significantly improve the perceived performance. You could even prepare more aggressively on hover. next.js#101050 t.co

    29 likes · 1 repost · 3,917 views
  7. AI SDK 7.0.160 Started Enforcing dataPartSchemas. Your Loading States Might Now Throw If you pass dataPartSchemas to useChat and bump ai to 7.0.160 or later, a chat that worked yesterday can end up in status: 'error' with a TypeValidationError. You didn't change anything. Your schemas just started running. What changed useChat and Chat accept dataPartSchemas, keyed by the bare part name (weather for a data-weather part). The stream processor looked the schema up by the full chunk type (data-weather), so the lookup always missed and streamed data parts were never validated. That's issue #24241, fixed in PR #24298 and shipped in ai 7.0.160: "Invalid data parts now raise a TypeValidationError." So a patch release turned on a check that was configured but never ran. What I ran I didn't want to guess at the edge cases, so I drove the Chat class from @ai-sdk/react with a fake transport that replays fixed UI message chunks. No model, no network. useChat runs on that same class (its chat option even takes an existing Chat instance), so the stream handling is the same code path. I ran the same script against ai 7.0.159 with @ai-sdk/react 4.0.163, ai 7.0.162 with @ai-sdk/react 4.0.166, and ai 6.0.322 with @ai-sdk/react 3.0.325, all with zod 4.6.7 on Node 20.19.2. | Stream | 7.0.159 | 7.0.162 | | --- | --- | --- | | Valid data-weather part | ready | ready | | city: 42, then some text | ready, bad data kept in parts, text arrives | error, no assistant message at all | | Loading update without weather, then the final value | ready | error, earlier text kept, final value lost | | Transient part that fails the schema | ready, onData called | error, onData not called | | data-notice part with no schema | ready | ready | On 6.0.322 every row matched the 7.0.159 column: bad data went through without an error. The one that'll bite: progressive updates The streaming data docs use a progressive pattern: write { city, status: 'loading' } first, then write to the same id again with the real value. The docs type weather as optional (weather?: string), which fits that pattern. But if your zod schema was written for the finished shape, the loading chunk fails now. Here's the excerpt from my test script (start and end are the start/finish chunks, text() writes one text part, and run() sends a message through Chat with these chunks): const strictSchemas = { weather: z.object({ city: z.string(), weather: z.string(), status: z.enum(['loading', 'success']), }), }; // Case 3: a loading update, then the final value under the same id await run('3 loading update without weather, then final', [...start, ...text('t0', 'Checking...'), { type: 'data-weather', id: 'w1', data: { city: 'Berlin', status: 'loading' } }, { type: 'data-weather', id: 'w1', data: { city: 'Berlin', weather: 'sunny', status: 'success' } }, ...end]); On 7.0.162 this ended with Type validation failed for message.parts[2].data (data-weather, id: "w1"). The "Checking..." text that streamed before it stayed in the message, the final value never arrived, and chat.status was error. Every chunk is checked, not just the last one per id. Transient parts too, even though they never land in message.parts. The fix Make the schema describe every state the server actually sends. A discriminated union keeps the final shape strict: const unionSchemas = { weather: z.discriminatedUnion('status', [ z.object({ status: z.literal('loading'), city: z.string() }), z.object({ status: z.literal('success'), city: z.string(), weather: z.string() }), ]), }; With this schema the same stream finished with status: ready on both versions, and the part ended up as { city: 'Berlin', weather: 'sunny', status: 'success' }. Before you upgrade, I'd do three things: 1. Search the client for dataPartSchemas. 2. For each data-* type in there, check every writer.write() on the server against its schema, including loading, progress and transient writes. 3. Make sure the UI shows error from useChat. In my runs a failed part stopped the whole response, so a silent failure here looks like a chat that just stops. Data parts with no schema aren't checked at all, so a schema-less type won't start failing. I'd rather have the error than let city: 42 slip into message.parts without a word, which is what 7.0.159 did. Have you hit this one after updating, or were your schemas already matching what the server sends? References - ai 7.0.160 release notes - Issue #24241: useChat/Chat never validates streamed data parts against dataPartSchemas - PR #24298: validate streamed data parts against dataPartSchemas - AI SDK docs: Streaming custom data - AI SDK docs: useChat reference

    1 reaction · 2 comments
  8. Next.js 16 が publicRuntimeConfig を廃止予告 — 3 アプリへの影響確認と移行パターン Cosoado Lab Blog 同時掲載予定: cosoado-lab.com 以前「Next.js で env var 1 つで 3 アプリの配色・機能を切り替えるジャンル設計」を書いた。Next.js 16 canary のリリースノートを眺めていたら Remove deprecated publicRuntimeConfig and serverRuntimeConfig というタイトルのコミットが目に入って、一瞬血の気が引いた。「あの記事の前提、詰んだ?」と思ったので、3 アプリ分のコードを確認しながら移行パターンを整理した。 TL;DR Next.js 16.0.0 では削除は revert されており、deprecation 警告の追加に留まっている canary ブランチのアップグレードガイドには「removed. Use environment variables instead」と明記されており、削除は時間の問題 移行パターン 2 種: ① ビルド時定数 → NEXT_PUBLIC_* ② 実行時読み取り → process.env + connection() getConfig() を呼んでいるコードがあれば今すぐ grep して棚卸しした方がいい publicRuntimeConfig が何のために存在したか 存在意義は「ビルド後に値を変えられる」こと。NEXT_PUBLIC_* はビルド時に bundle に静的インライン展開されるので、値を変えるには再ビルドが必要だ。getConfig() を使う runtime config は、サーバー起動時の process.env を変えるだけで差し替えられた。App Router 以降は Server Components が増えてこのニーズが別の方法で満たせるようになり、廃止に向かっている。 v16.0.0 の実際の状況 canary では PR #83944 で削除が一度 merge されたが、リリース直前の PR #84167 で revert された。v16.0.0 には削除ではなく PR #84168 で追加された deprecation 警告だけが入っている。 ただし canary ブランチのアップグレードガイドには: serverRuntimeConfig and publicRuntimeConfig have been removed. Use environment variables instead. と書いてある。v16.0.0 に間に合わなかっただけで削除の意志は変わっていない。次のマイナー更新か 16.x のどこかで壊れる。 私の 3 アプリは影響ゼロだった SparMate・NetaPair・BoardLink は全部 NEXT_PUBLIC_GENRE=sparmate のような NEXT_PUBLIC_* パターンを使っていた。getConfig() はどこにも呼んでいなかった。 確認するまでの 10 分は冷や汗をかいたが、前回の記事を書いたときに「runtime config は使わない」と決めていたのが功を奏した。ちゃんと理由を持って設計しておくとこういうとき助かる。 移行パターン 2 種 パターン 1: ビルド時定数 → NEXT_PUBLIC_* 値はビルド時に bundle に埋め込まれる。 // Vercel の Environment Variables に NEXT_PUBLIC_API_BASE_URL を追加して置き換えるだけ const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL 値を変えると再ビルドが必要。「デプロイ後に差し替えたい」ニーズがあればパターン 2 を使う。 パターン 2: 実行時読み取り → process.env + connection() Server Components 専用。connection() を先に呼ぶことで「プリレンダリング時ではなく、リクエスト時に読む」ことが保証される。 import { connection } from 'next/server' export default async function Page() { await connection() // これ以降はリクエスト時に実行される const apiUrl = process.env.API_BASE_URL // ... } connection() なしだと、Next.js が静的プリレンダリング時に process.env を評価してしまう場合がある。serverRuntimeConfig の「起動後に差し替えたい」ユースケースはこれで代替できる。 やらかした話: connection() を Client Component で呼ぼうとした 移行パターンを確認しながら「connection() を useEffect の中で呼べばいいのでは?」と試した。コンパイルは通ったが、ブラウザのコンソールに「connection is not defined in client context」みたいなエラーが出た。 connection() は Server Components 専用だ。'use client' の中では呼べない。実行時読み取りが必要な値は Server Component で取得して props で渡すか、Server Action 経由にする必要がある。「async/await が書けるなら Server Component でも Client Component でも同じか」という油断がある。これは一番やった間違いで、自信満々に修正して PR を出したあとにレビューで気づいた。 まとめ getConfig() を使っているコードは今すぐ grep -r "next/config" して棚卸しを v16.0.0 は deprecation 警告だけだが、canary docs には「removed」とある ビルド時定数でよければ → NEXT_PUBLIC_* デプロイ後に変えたい実行時値は → Server Component + connection() + process.env getConfig() を使った例は古いドキュメントやチュートリアルにまだ多い。次のアップグレードで詰まる前に確認しておく価値がある。 参照: vercel/next.js #83944 — Remove deprecated publicRuntimeConfig and serverRuntimeConfig vercel/next.js #84167 — Revert removal for v16.0.0 vercel/next.js #84168 — Add deprecation warning to Runtime config Next.js v16 アップグレードガイド (canary) SparMate — 格闘技の練習相手マッチング NetaPair — お笑いの相方探し BoardLink — ボドゲ・TRPG 仲間募集 Cosoado Lab

  9. Prerendering a page per device, and the cache key that has to agree --- title: "Prerendering a page per device, and the cache key that has to agree" published: true description: "A prerendered page in the Next.js App Router is one file per URL, and device specific markup needs two. Generating the extra routes keeps the public URL intact. Making your device split agree with the cache in front of it is the part nobody warns you about." tags: nextjs, webdev, caching, performance canonical_url: yhryshchanka.dev ai_disclosure_level: some_ai --- A marketing page with no per visitor content should be built once and handed out as a file, which is what prerendering means here: the build writes the HTML, and the server answers with it instead of rendering again. The page I had to do this to was not quite static. Its footer is an accordion on a phone and a grid on a laptop, and a few sections below it differ the same way. Two rendered versions of one document have to exist at the same time. The browser can make that choice by itself, which is the first thing to rule out. A CSS media query renders both versions and hides one, so the same text sits in the document twice and every visitor downloads the one they will never see. JavaScript picks after the page has loaded, so the first paint is a guess and the reader watches it change. Either way the page ships markup the device in front of it will not use. Choosing at build time means one version per file, and the HTML a crawler reads is the HTML a phone gets. Everything below happens inside the application, in the Next.js App Router, on version 15. The same split can live a layer above it instead. Inside the application, that means two files and one URL. A shared cache in front of it keys on that URL and nothing else, so on its own it cannot tell the two files apart. What it can do instead is split its own storage by device, and making my split agree with its split decided the shape of everything else. The whole thing, before the explanation Two prerendered files per page, one public URL, and a rewrite that picks between them before the request reaches a route. Everything below this section is the reasoning. This is the code, and it runs. A page opts in with one export, and refuses to render at its own path, where no device arrives: app/terms-of-use/page.tsx import Layout from '@/app/_layout/Layout' import type { DeviceSegment } from '@/lib/device' import { notFound } from 'next/navigation' export const isDeviceBasedSSG = true export const metadata = { title: 'Terms of use' } export default function TermsPage({ device }: { device?: DeviceSegment }) { if (!device) notFound() return <Layout device={device}>Terms of use</Layout> } A script turns that marker into one route per device, plus the list of paths that got one: scripts/generate-device-routes.mjs import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, relative } from 'node:path' const DEVICES = ['desktop', 'mobile'] const SOURCE_DIR = 'app' const OUT_DIR = join('app', '(generated)') const PATHS_FILE = 'lib/cacheable-paths.generated.ts' const MARKER = /export\s+const\s+isDeviceBasedSSG\s*=\s*true/ // A folder named `(group)` adds no segment to the URL. A folder named `_private` is out of // routing altogether, so a page under one is not a route and must not get a wrapper. const isGroup = (name) => name.startsWith('(') const isPrivate = (name) => name.startsWith('_') function findMarkedPages(dir, routePath = '') { const entries = readdirSync(dir, { withFileTypes: true }) const here = entries.some((entry) => entry.isFile() && entry.name === 'page.tsx') && MARKER.test(readFileSync(join(dir, 'page.tsx'), 'utf8')) ? [{ routePath: routePath || '/', dir }] : [] return [ ...here, ...entries .filter( (entry) => entry.isDirectory() && !isPrivate(entry.name) && join(dir, entry.name) !== OUT_DIR, ) .flatMap((entry) => findMarkedPages( join(dir, entry.name), isGroup(entry.name) ? routePath : `${routePath}/${entry.name}`, ), ), ] } // The import path goes into a string literal and never into a comment: a directory // name may carry a line terminator, which ends a // comment and drops the rest into // code the build compiles. const wrapper = (importPath, device, metadataExport) => `// Generated. Do not edit. import Page${metadataExport ? `, { ${metadataExport} }` : ''} from ${JSON.stringify(importPath)} export const dynamic = 'error' ${metadataExport ? `\nexport { ${metadataExport} }\n` : ''} export default function GeneratedDeviceRoute() { return <Page device="${device}" /> } ` const marked = findMarkedPages(SOURCE_DIR) for (const { routePath, dir } of marked) { // A wrapper is a route at a fixed path, so it cannot carry a parameter, and it sits // directly under the app root, so it cannot inherit a layout from the source's folder. if (routePath.includes('[')) { throw new Error(`${dir}: a device route cannot hold a dynamic segment (${routePath})`) } const ancestors = routePath.split('/').filter(Boolean).slice(0, -1) for (let i = 0; i < ancestors.length; i += 1) { const layout = join(SOURCE_DIR, ...ancestors.slice(0, i + 1), 'layout.tsx') try { readFileSync(layout) throw new Error(`${dir}: ${layout} would not apply to the generated route`) } catch (error) { if (error.code !== 'ENOENT') throw error } } } rmSync(OUT_DIR, { recursive: true, force: true }) for (const { routePath, dir } of marked) { const source = readFileSync(join(dir, 'page.tsx'), 'utf8') const metadataExport = /export\s+(async\s+)?function\s+generateMetadata/.test(source) ? 'generateMetadata' : /export\s+const\s+metadata\b/.test(source) ? 'metadata' : '' for (const device of DEVICES) { const outFile = join(OUT_DIR, device, routePath, 'page.tsx') const importPath = relative(dirname(outFile), join(dir, 'page')).split('\\').join('/') mkdirSync(dirname(outFile), { recursive: true }) writeFileSync(outFile, wrapper(importPath, device, metadataExport)) } } writeFileSync( PATHS_FILE, `// Generated. Do not edit.\nconst CACHEABLE_PATHS = ${JSON.stringify( marked.map((page) => page.routePath), )} as const\n\nexport default CACHEABLE_PATHS\n`, ) console.log(`generated ${marked.length * DEVICES.length} routes for ${marked.length} pages`) Run it before anything that reads the routes, and keep what it writes out of the repository: package.json { "scripts": { "generate:routes": "node scripts/generate-device-routes.mjs", "predev": "npm run generate:routes", "prebuild": "npm run generate:routes", "pretypecheck": "npm run generate:routes", "pretest": "npm run generate:routes" } } .gitignore # Written by scripts/generate-device-routes.mjs. app/(generated)/ lib/cacheable-paths.generated.ts Middleware rewrites the public path into the generated route, so the visitor's URL and the canonical tag never carry a device: middleware.ts import CACHEABLE_PATHS from '@/lib/cacheable-paths.generated' import { deviceFromHeaders, isInternalDevicePath } from '@/lib/device' import { NextResponse, type NextRequest } from 'next/server' export function middleware(request: NextRequest) { const { pathname } = request.nextUrl // Nothing links to the internal form, so a request carrying one was typed or crawled. if (isInternalDevicePath(pathname)) { return NextResponse.rewrite(new URL('/_not-found', request.url), { status: 404 }) } if (!(CACHEABLE_PATHS as readonly string[]).includes(pathname)) return NextResponse.next() return NextResponse.rewrite( new URL(`/${deviceFromHeaders(request.headers)}${pathname}`, request.url), ) } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], } One device read that every page shares, and the guard the middleware just used: lib/device.ts import { userAgent } from 'next/server' export const DEVICE_SEGMENTS = ['desktop', 'mobile'] as const export type DeviceSegment = (typeof DEVICE_SEGMENTS)[number] const LAYOUT_BY_CDN_TYPE: Record<string, DeviceSegment> = { mobile: 'mobile', tablet: 'mobile', desktop: 'desktop', } export function deviceFromHeaders(headers: Headers): DeviceSegment { const cdnType = headers.get('cf-device-type') ?? '' // Own keys only: outside a cache rule this header is whatever the client sent, and // `constructor` or `__proto__` would otherwise resolve to a member of the prototype. if (Object.hasOwn(LAYOUT_BY_CDN_TYPE, cdnType)) return LAYOUT_BY_CDN_TYPE[cdnType] return userAgent({ headers }).device.type === 'mobile' ? 'mobile' : 'desktop' } export function isInternalDevicePath(pathname: string): boolean { const [, first] = pathname.split('/') return (DEVICE_SEGMENTS as readonly string[]).includes(first) } And the response headers, because a prerendered route otherwise advertises a year of shared caching to whatever sits in front of it: next.config.mjs import CACHEABLE_PATHS from './lib/cacheable-paths.generated.ts' /** @type {import('next').NextConfig} */ export default { async headers() { return CACHEABLE_PATHS.flatMap((path) => [ { source: path, has: [{ type: 'header', key: 'rsc' }], headers: [{ key: 'Cache-Control', value: 'private, no-store' }], }, { source: path, missing: [ { type: 'header', key: 'rsc' }, { type: 'query', key: '_rsc' }, ], headers: [{ key: 'Cache-Control', value: 'public, max-age=0, s-maxage=3600' }], }, ]) }, } That is the implementation, on Next 15 with the App Router. Next 16 renames the file to proxy.ts and the function to proxy, which I have not run. The rest of this article is why each of those decisions is the one that survived, and the two failures none of the code shows on its face: a build that stays green while nothing is prerendered, and a cache that hands one visitor's layout to everybody else. The cache key is the path, and nothing from the request Next keeps the HTML it prerendered in what it calls the full route cache. The key for that cache is the resolved route path. In 15.5 it is one line of next/dist/server/base-server.js: let resolvedUrlPathname = getRequestMeta(req, 'rewroteURL') || urlPathname and getFilePath in the file system cache turns that same string into a path on disk. Nothing else from the request reaches it. Next does send a Vary header for whatever caches the response downstream, but its own route cache does not consult one. The query string does not help either. I measured that rather than reasoning about it: with the page not reading searchParams, ?device=m, ?device=d and no query at all came back byte for byte identical, all three hashing to 4005cda574c9, one cache entry answering every one of them. A page that reads searchParams is not prerendered at all, and whether that is a build failure or a silent fallback to rendering on demand is the subject of a later section. Rewrites declared in next.config do not create routes either, they point at routes that already exist, so they add no dimension to the key. I pointed one at a prerendered route from beforeFiles and another from afterFiles, and both source URLs came back identical to the route itself, all three hashing to d0fa5dfdb35c. One entry, three ways in. Two variants therefore mean two paths, and the only varying input a prerendered render can see is a route parameter. Where the shape came from I did not invent this. The shape comes from a comment by a Next collaborator in a discussion about serving a light and a dark theme out of one cache: > Prerender per known theme > > generateStaticParams returns the theme list. Pages live under [theme]/. A > proxy rewrites /foo → /<cookie>/foo server-side, and redirects direct > visits to /light or /dark back to the unprefixed path so themed URLs > aren't shareable. > > Each route ends up with one cache slot per theme. > > HTML arrives themed. No FOUC, no JS required. > > Server can branch on theme, that is, RTL, different components, distinct meta. Read it with devices in place of themes and it says four things. Make the variant a route parameter. Move the pages that want variants under that parameter's folder. Let generateStaticParams prerender one copy per value. Have middleware, the function that runs before a request reaches a route, rewrite the public URL into the variant so the visitor never sees it. He names two costs: > Switching theme is a server round trip, an actual navigation. > > Proxy on every request, between you and your CDN. Neither reaches this case. Nobody switches devices, so there is no switch to pay for. And middleware was already running on every request here, for locale routing and for putting visitors into experiments, so its cost was on the bill before this feature existed. Two parts of that I did not want. A dynamic segment in the first position costs more than the URLs it agrees to match. Once the first segment is a parameter, every first level path is a valid route, and the framework's own not found stops firing for a wrong one, because from its side nothing is wrong. Something inside the tree has to decide which values are real and call notFound() for the rest, which makes that check the only place the legal values are written down. A reader who wants to know what the site accepts can no longer look at the folders, they have to open the code. A whitelist does not rescue it either, because Next has no fall through: once a route matches, notFound() renders the not found boundary rather than handing the request to the next candidate route. The shared parent folder fails more quietly. Every page that wants a variant has to move under it, and pages that cannot have one stay where they are. Say articles/ holds two pages, one that can be cached and one that reads a cookie and therefore cannot. The first moves under [theme]/ and the second does not, so articles/ now exists in two places at once. Add a page, and remember the second tree. Rename a folder, and remember it again. The build is green either way, and the mistake shows up as a page that quietly stopped being prerendered. So the parameter went away, and two literal folders replaced it, desktop/ and mobile/, each holding a real page file. A folder matches its own name and nothing else, so an unknown value now matches no route at all. That is what generateStaticParams and dynamicParams were there to arrange, and what the guard was there to catch when they failed, so all of it goes. What literal folders do not remove is the duplicated tree. They move it: desktop/ and mobile/ each hold a copy of every marked page. What changes is who keeps the copies in step. Two trees a person edits is the objection above. Two trees a script writes is a build step, because nobody edits them and nobody reviews them. That is the whole argument for generating them. What the generator writes The whole opt-in is one export, at the page's normal path: app/terms-of-use/page.tsx export const isDeviceBasedSSG = true The script writes two things from it: a route per device, and the list of paths that got one. The list is a module rather than a directory scan, because the same code has to work wherever the rewrite runs, and because deriving it from the marker is what stops it drifting away from the folders it names. Each generated route carries no logic of its own: app/(generated)/desktop/terms-of-use/page.tsx // Generated. Do not edit. import Page, { metadata } from '../../../terms-of-use/page' export const dynamic = 'error' export { metadata } export default function GeneratedDeviceRoute() { return <Page device="desktop" /> } The wrapper takes no props of its own on purpose. A page file is type checked against the props Next passes a route, and a wrapper that declares anything else fails that check. Metadata is re-exported, and a wrapper that forgets it leaves the page with no title and no description, which is why the script reads the source for a metadata export and emits the re-export only when it finds one. The line that matters more than either of those is dynamic = 'error'. The one line that turns a silent failure into a build failure Nothing in a prerendered route's tree may read cookies(), headers() or searchParams. That includes the layout and the not found boundary, which sit in every route's tree. One such read anywhere puts the whole route back on per request rendering, and the build stays green while it happens. The not found page is the one that catches people out, and it is also the one page whose device genuinely cannot be known, so it takes a fixed value rather than reading for one. dynamic = 'error' is what turns that class of mistake into a build failure, and the failure names the API: `Route /desktop/terms-of-use with dynamic = "error" couldn't be rendered statically because it used headers`. The variant you serve has to be the variant the cache stored Everything above gets you two prerendered files and a rewrite that picks one. Put a shared cache in front and a new failure appears, because the cache stores one body per bucket of its own choosing, and it chose that bucket with its own classifier. Cloudflare, for one, can split a cache rule by device type and then sends the origin a CF-Device-Type header carrying the bucket it used. If middleware picks the branch with a User Agent parser while the edge keys on the CDN's answer, then for every user agent where the two disagree, whoever arrives first decides what the entire bucket serves to everyone behind them. They disagree on real traffic, and not on anything exotic. A native app's networking stack and a reading device asked to behave like a desktop both landed in one bucket at the edge and another in my parser. The particular agents will age, so take the category instead of the list: anything whose user agent does not describe the screen it draws on. Whatever you parse with has to collapse to the buckets the cache actually holds. Mine had a third answer, unknown, for anything it did not recognise, and under two buckets a third answer is not a third variant, it is a coin toss about which of the two documents a visitor gets. The read above has no third branch for that reason. Every disagreement I found became a test case. The fix is one server side read, used by every page, taking the CDN's answer first and the user agent second: lib/device.ts export function deviceFromHeaders(headers: Headers): DeviceSegment { const cdnType = headers.get('cf-device-type') ?? '' // Own keys only: outside a cache rule this header is whatever the client sent, and // `constructor` or `__proto__` would otherwise resolve to a member of the prototype. if (Object.hasOwn(LAYOUT_BY_CDN_TYPE, cdnType)) return LAYOUT_BY_CDN_TYPE[cdnType] return parseUserAgent(headers.get('user-agent') ?? '').isMobile ? 'mobile' : 'desktop' } Tablets map to the mobile layout, which means three buckets hold two documents and the tablet copy duplicates the mobile one. That is the price of letting the cache own the classification. The internal paths have to be unreachable Nothing links to /desktop/terms-of-use, so a request carrying one was typed or crawled. It is also a real route, and I checked what happens without the guard: it answers 200 and serves the document. The same page at a second URL, with whatever search weight the real one has split between them. lib/device.ts export function isInternalDevicePath(pathname: string): boolean { const [, first] = pathname.split('/') return (DEVICE_SEGMENTS as readonly string[]).includes(first) } The rewrite goes to /_not-found with an explicit status: 404. Without the status it is a soft 404: the not found page at 200, which is the duplicate the guard exists to prevent, wearing a different hat. What I would keep Keep the variant as a path segment, because nothing else reaches the key. Keep literal folders rather than an enumerated dynamic segment, because a folder cannot match a value it is not named after. Keep one device classifier for the whole application, and if a shared cache sits in front, make it the cache's classifier. Keep dynamic = 'error' on every variant route. Keep a check that reads the prerender manifest rather than the route table, and a second one that requests the URLs nobody should request and expects a 404. Keep a check on the bytes themselves, which asks a running build for the same page twice and compares what comes back: same bytes inside one device class, different bytes between classes, and no change when the request carries cookies. Take the cookie names from the application's own constants rather than retyping them, or the check sends a cookie nothing reads and quietly passes on two requests that both carried nothing. Keep the generator, and know what you are buying with it. Two hand written wrappers of four lines each do the same job today, for two cacheable pages. The day somebody marks a third page, the wrappers and the path list have to follow, and a person who forgets gets no error, just a page quietly rendering per request again. A marker that generates both of them cannot be forgotten. That is the only thing the script buys. The price is a prebuild step in front of every command a developer runs, stack traces that point at files which are not in the repository, an error that names a wrapper when the mistake is in the source page, a tree that can be stale, and a script that writes paths into code the build then compiles. For two pages it is a bad trade. For a site where anybody can mark a page, I would make it again. The generated tree is in .gitignore, and that is the call in all of this I am least settled about. It keeps the repository honest about what a person wrote. It also means nothing in the repository shows a reader what actually gets built, and a stack trace can point at a file they cannot open. The code here is a rewrite of what shipped, generic enough to drop into any project. To be sure the rewrite is more than a sketch I built a clean Next 15.5 app out of exactly these files and drove it, and the build output, the response headers and the hashes above come from that app. What a local app cannot show is how two classifiers disagree on real traffic, and that part comes from the application the design shipped in.

    1 reaction · 2 comments
  10. create-next-app でPages Routerにする選択肢を無くすらしい、ほかフレームワークを使えってことなのかな next.js#100850

    7 likes · 534 views

Achievements

Awards earned by the repositories vercel owns. Select an award to embed its badge.

Gold
121st place finishes
Silver
132nd place finishes
Bronze
63rd place finishes
Awarded repositories
7Owned by vercel now

Repositories

Ordered by gold, then silver, then bronze.

  1. pkgBest: 1st by stars, 1,075 stars310
  2. swrBest: 1st by stars, 3,179 stars301
  3. turborepoBest: 1st by stars, 2,057 stars253
  4. next.jsBest: 1st by stars, 4,506 stars231
  5. satoriBest: 1st by stars, 1,818 stars220
  6. commerceBest: 2nd by stars, 456 stars020
  7. chatbotBest: 3rd by stars, 986 stars001

Every award

Top-three finishes of these repositories in completed trending days and weeks, newest first.

  1. chatbot
  2. turborepo
  3. turborepo
  4. turborepo
  5. turborepo
  6. turborepo
  7. satori
  8. satori

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.