Все записи
6 мин

Next.js пихал /index.txt в адресную строку: как я убил баг static export через нативный <a>

вайбкодингclaude codeинфраструктура

Если у вас на статической сборке Next.js при клике по внутренней ссылке в адресной строке вдруг вылезает что-то вроде /портрет/index.txt вместо нормальной страницы, то это не вы криворукие, это известный баг сочетания output: 'export' и trailingSlash: true вместе с префетчем у next/link. Лечится он тем, что свою ссылку-обёртку вы переписываете на нативный тег в обход клиентского роутера, и симптом исчезает. Я именно так и сделал в Картаре на Next.js 15.5.15: заменил импорты в 16 файлах, собрал 152 статические страницы, exit 0, и эта дрянь перестала вылезать. А теперь по порядку, потому что путь к фиксу был не сразу.

Что вообще случилось

Картара — это Telegram Mini App с AI-персонажем, и у неё есть веб-версия на Next.js. И вот сижу я, обычная сессия, ничего особенного не трогаю, просто нахожусь в профиле, делаю хард-рефреш, жму на ссылку «портрет», и мне в URL вместо страницы лезет /портрет/index.txt. Я сначала даже не поверил, потому что раньше такого не было вообще, а тут на ровном месте появилось. Цитирую сам себя из той сессии дословно, чтобы вы прочувствовали реакцию: «Я блять просто нахожусь в профиле. обновляю хард рефреш. жму портрет. и мне лезет уртл портрег/index.txt заебло блять. даже не было такого а ащс есть».

То есть человек кликает по нормальной кнопке, ждёт нормальную страницу, а получает в адресной строке текстовый файл. Для пользователя это выглядит так, будто сайт сломался напрочь, и в общем-то он прав, сломался. Самое противное в таких багах то, что они появляются как будто из ниоткуда: вы ничего руками в роутинге не трогали, а оно всплыло. И вот тут начинается интересное, потому что причина оказалась не в моём коде, а в самом фреймворке.

Почему Next.js это делает

Я отдал задачу Claude Code, оркестрировал разбор, и мы докопались до сути, а не просто залепили заплатку. Оказалось, что когда у вас в Next.js включён режим static export, то есть output: 'export', и при этом trailingSlash: true, то фреймворк на каждую страницу генерит не один файл, а два: обычный /index.html и рядом /index.txt, в котором лежит RSC-пейлоад (это внутренняя порция данных для React Server Components) с типом text/plain. Сам по себе этот .txt не зло, он часть механики. Зло начинается тогда, когда next/link на статике делает prefetch, то есть заранее подтягивает следующую страницу, и в этой связке префетч на хард-рефреше может увести именно на .txt-версию вместо html. Вот вам и весь фокус: клик по ссылке, а в строке текстовый пейлоад.

И тут я хочу проговорить вещь, на которой можно споткнуться: это не «так задумано», это именно баг. Чтобы не выдумывать и не гадать, мы пошли в открытые issue Next.js и нашли подтверждение, там это тянется через версии с 13-й по 16-ю, и люди жалуются на ровно то же самое. Для тех, кто захочет сам проверить и не верить мне на слово: vercel/next.js #74445, #44393, #47334. Когда видишь свою проблему один в один описанной чужими людьми в трекере фреймворка, сразу легче дышать, потому что понимаешь, что чинить надо не себя, а обходить кривое поведение. И да, на этом моменте я выдохнул, ведь одно дело когда ты сам где-то накосячил, и совсем другое когда косяк в инструменте и нужно просто грамотно его объехать.

Первая попытка фикса, которая не дотянула

Логика простая: раз виноват префетч у next/link, давайте его выключим. Я сделал обёртку, свой компонент AppLink поверх обычного Link, и проставил ему prefetch=false. Звучит как очевидное решение, и в теории оно должно было убрать ту самую предзагрузку, из-за которой нас уводило на .txt. На бумаге красиво, а на деле сработало только наполовину, потому что мы всё ещё оставались внутри клиентского роутера Next.js, а вся эта возня с RSC-пейлоадами и генерацией двух файлов живёт именно там, в его навигации. То есть мы убрали один из триггеров, но не вышли из той зоны, где этот триггер вообще существует.

И вот это, кстати, типичная ловушка в работе с любым фреймворком, не только с Next.js: вы находите параметр, который как будто отвечает за проблему, дёргаете его, и вам кажется, что всё, победа. А на самом деле вы просто прикрыли одну дырку, пока сама конструкция продолжает течь в других местах. Поэтому я не стал делать вид, что починилось, и пошёл дальше, к решению, которое выдёргивает корень целиком, а не подстригает листья.

Второй заход: нативный <a> в обход роутера

Решение, которое реально закрыло вопрос, это переписать AppLink не на обёртку поверх next/link, а на честный нативный тег . То есть полностью выйти из клиентского роутера Next.js и сделать обычный браузерный переход по ссылке, как в интернете было всегда до всех этих SPA. При таком подходе никакого префетча нет в принципе, потому что некому его делать, и никакого выбора между .html и .txt у роутера не возникает, браузер просто идёт по адресу и открывает страницу. prefetch при этом честно проигнорирован, потому что у нативного такого механизма нет, и слава богу.

Заменить пришлось не в одном месте, а в 16 файлах, везде, где раньше дёргался старый Link, теперь стоит AppLink на нативном . Звучит как занудство, но именно поэтому я и держу такие вещи через одну общую обёртку, а не разбрасываю прямые импорты по всему проекту: меняешь поведение в одной точке, прокидываешь по импортам, и весь сайт переезжает разом. После замены собрал, 152 статические страницы, exit 0, никаких .txt в адресной строке. Кликаешь портрет и открывается портрет, как и должно быть.

Реакция на работающий билд была короткой и по делу, опять же цитирую себя дословно: «Сработало. Ахуеть. запиши ка это знание. пиздец блять. новые страницы не будут голову ебать когда будем добавлять?». И вот этот последний вопрос, про новые страницы, для меня важнее самого фикса.

Почему обёртка важнее, чем сам баг

Когда вы делаете проект, в котором страницы будут добавляться и дальше, а в Картаре они будут, то любой фикс «в лоб», размазанный по 16 местам, это бомба замедленного действия. Добавите завтра новую страницу, забудете в одном файле поставить правильную ссылку, и баг с .txt вернётся ровно в том углу, где вы его не ждёте. Поэтому я и держу AppLink единой точкой: новая страница просто использует ту же обёртку, и проблема физически не может всплыть заново, потому что весь роутинг идёт через нативный . Это и есть ответ на тот мой вопрос «не будут голову ебать?», не будут, потому что лечится не симптом на конкретной странице, а сам способ переходов во всём проекте — ровно так же общая обёртка лечила дрейф интерфейса между двумя фронтами, а не симптом на отдельном экране.

Я тут не хочу строить из себя гуру вайбкодинга, который всё знал заранее. Я код руками не пишу, я оркестрирую Claude Code и разбираюсь в том, что он делает, до уровня «понимаю причину, а не верю на слово». И вот эта история ровно про то, зачем вообще копать вглубь, а не хвататься за первый зелёный билд. Первый фикс с prefetch=false выглядел рабочим, мог бы и сойти, но он бы рано или поздно меня укусил. А правильное решение нашлось только тогда, когда мы дошли до того, что виновата сама навигация фреймворка на статике, а не один параметр.

Если коротко для тех, кто загуглит ровно эту боль: баг с /index.txt в URL на Next.js — это static export плюс trailingSlash плюс префетч у next/link, лечится выходом на нативный в обход роутера, подтверждено открытыми issue и проверено лично на 15.5.15 со 152 страницами. У меня про близкие грабли с фреймворком и Claude Code есть отдельная история, когда «готово» оборачивалось пустым экраном, и там та же мораль: верьте не словам сборки, а тому, что реально открылось у пользователя. Вот и делайте выводы.