목차 1. '<'가 오면 JSON이 아니다 2. 상태와 Content-Type부터 본다 3. 프록시와 슬래시가 HTML을 주는 경우 4. 라우트 핸들러는 JSON 헤더를 명시한다 5. 실전에서 고르는 순서 6. 참고 자료 7. 자주 묻는 질문 JSON.parse가 Unexpected token '<'를 내면, 받은 본문이 JSON이 아니라 HTML입니다. 파서가 틀린 게 아니라 응답이 문서입니다.
로컬 API는 객체를 주는데 배포만 로그인 페이지나 404 HTML이 오는 일, 넥스트 라우트와 버셀 프리뷰에서 한 번은 만납니다. 콘솔만 보면 파서 버그로 보이죠.
클라이언트 파서를 키우기 전에 그 요청이 어떤 문서를 받았는지 한 번만 열어 보세요. 상태 코드와 본문 타입부터 보면 원인이 갈립니다. 근거는 MDN JSON.parse 와 Response.json 에 있습니다.
본문 첫 글자가 <이면 HTML입니다. JSON은 객체면 {, 배열이면 [로 시작합니다. 파서가 그 글자를 토큰으로 못 읽어서 그 위치에서 멈춘 겁니다.
MDN도 JSON.parse는 올바른 JSON 문자열만 받는다고 적습니다. HTML, 빈 문자열, 서버가 붙인 경고 문구는 모두 같은 줄이 납니다. res.json()도 안에서 parse를 호출하므로 증상은 같습니다.
자주 오는 HTML은 404 페이지, 로그인 리다이렉트, 프레임워크 에러 화면입니다. 상태 코드는 200인데 본문이 HTML인 경우도 있어서, 코드만 보면 놓칩니다. CORS로 막힌 줄은 프리플라이트 글 입니다.
본문 시작 실제 응답 먼저 볼 것
<!DOCTYPE, <html HTML 문서 URL, 상태, Content-Type
<pre> 에러 페이지 프레임워크 예외 화면 서버 로그
{ 또는 [ JSON 키 이름, 스키마
빈 본문 204 또는 프록시 절단 상태 코드
먼저 찍을 것 | await res.text()로 앞 200자를 로그하세요. parse 전에 본문을 보면 HTML인지 바로 갈립니다. 추측으로 클라이언트를 고치지 마세요.
Unexpected token 줄은 파서가 아니라 응답 본문이 HTML일 때 난다 네트워크 탭에서 그 요청을 고르고 상태, 요청 URL, Content-Type을 나란히 보세요. application/json이 아니면 parse할 대상이 아닙니다. 401, 302, 404가 HTML을 실어 보내는 서버가 많습니다.
요청 URL이 의도한 API인지가 절반입니다. 상대 경로 /api/posts가 배포에서 프론트 도메인으로 붙으면, 넥스트 페이지가 HTML을 줍니다. 로컬은 리라이트가 받아 주고, 프리뷰는 받아 주지 않을 때 이 줄이 납니다.
슬래시 왕복으로 HTML 로그인 페이지를 받는 경우는 리다이렉트 루프 와 겹칩니다. 소켓이 중간에 끊긴 줄은 ECONNRESET 입니다. parse 줄과 소켓 줄을 섞지 마세요.
parse 전에 본문과 타입을 확인
const res = await fetch('/api/posts', { headers: { Accept: 'application/json' } })
const type = res.headers.get('content-type') || ''
const raw = await res.text()
if (!res.ok || !type.includes('application/json')) {
console.error('status', res.status, 'type', type, 'body', raw.slice(0, 200))
throw new Error('JSON이 아닌 응답')
}
const data = JSON.parse(raw)버셀 프리뷰, 카카오 로그인 콜백, trailingSlash 설정이 맞물리면 API 경로가 페이지로 떨어집니다. 그때 본문은 넥스트 HTML이고, 클라이언트는 그걸 JSON으로 읽습니다.
리라이트 대상이 꺼져 있거나, basePath가 붙은 채 상대 경로를 치면 같은 증상이 납니다. 서버 라우트는 204를 줬는데 앞단 캐시가 옛 HTML을 주는 경우도 있습니다. 헤더에 x-vercel-cache나 CDN 히트가 있으면 캐시부터 의심하세요.
한국에서 프론트와 API를 같은 넥스트에 두고 카카오 콜백만 다른 경로로 빼 둔 구성이면, 콜백이 HTML 동의 화면을 돌려줄 때도 이 줄이 납니다. 토큰 교환 URL과 페이지 URL을 섞지 마세요.
상황 받는 것 고칠 칸
상대 경로가 페이지로 붙음 넥스트 HTML 절대 API URL, rewrites
401 후 로그인 페이지 HTML 폼 인증 헤더, 쿠키
404 커스텀 페이지 HTML 라우트 파일 위치
CDN이 옛 문서 캐시 HTML 캐시 키, 재배포
배포에서만 나면 요청 URL이 페이지로 붙었는지부터 본다 넥스트 라우트 핸들러는 Response.json()을 쓰면 타입이 붙습니다. 문자열을 그대로 돌려보내면 브라우저가 HTML로 추측하는 경우가 있습니다. App Router 라우트 문서를 기준으로 객체를 JSON으로 직렬화하세요.
에러 분기에서 notFound()나 redirect()를 타면 본문이 HTML이 됩니다. API로 쓰는 경로에서 페이지용 헬퍼를 부르면 클라이언트의 parse가 깨집니다. 에러도 { ok: false, message }처럼 JSON으로 통일하세요.
서버가 정말 JSON을 줬는데도 이 줄이면, 중간에 gzip 잔재나 BOM이 붙었는지 앞 몇 바이트를 확인합니다. 타임아웃으로 본문이 잘린 줄은 ETIMEDOUT 입니다.
app/api/posts/route.js
export async function GET() {
try {
const rows = [{ id: 1, title: 'ok' }]
return Response.json({ rows })
} catch (err) {
return Response.json(
{ ok: false, message: '조회 실패' },
{ status: 500 }
)
}
}
[ ] res.text()로 본문 앞부분을 찍었다
[ ] Content-Type이 application/json인지 봤다
[ ] 요청 URL이 페이지가 아니라 API인지 확인했다
[ ] 401, 404, 302가 HTML을 실어 오는지 봤다
[ ] API 에러도 JSON으로 돌려주게 바꿨다
콘솔의 parse 줄보다 네트워크 탭을 먼저 엽니다. 상태와 URL, 타입, 본문 앞 200자를 보면 원인은 대개 한 번에 갈립니다. 그다음에 리라이트, 인증, 캐시를 손봅니다.
클라이언트의 try/catch만 키워 봐야 HTML은 JSON이 되지 않습니다. 서버가 JSON을 주고, 그 URL로 실제 요청이 가게 맞추는 일이 먼저입니다.
한국 1인 넥스트에서 로컬만 되고 프리뷰만 이 줄이면, 환경 변수로 API 원점이 비어 상대 경로가 페이지를 친 경우가 많습니다. 빌드에 키가 빠지는 줄은 NEXT_PUBLIC 글 을 같이 보세요.
parse 전에 타입과 본문을 확인하고, API는 JSON만 돌려준다
내부 연계: CORS 프리플라이트 , 리다이렉트 루프 , ECONNRESET , NEXT_PUBLIC
인용한 동작은 2026년 9월 공개 문서 기준입니다.
Unexpected token '<'는 파서가 고장난 건가요?
아닙니다. 받은 문자열이 JSON이 아니라 HTML입니다. 첫 글자 <를 토큰으로 못 읽어서 그 위치에서 멈춘 겁니다. 본문 앞부분을 먼저 찍으세요.
상태 코드는 200인데도 납니다.
200이어도 Content-Type과 본문이 HTML일 수 있습니다. 잘못된 경로가 페이지를 200으로 주는 경우입니다. 코드만 믿지 말고 본문을 보세요.
로컬은 되고 프리뷰만 실패합니다.
상대 경로가 프리뷰에서 프론트 도메인으로 붙어 페이지 HTML을 받는 경우가 많습니다. 리라이트, basePath, API 원점 환경 변수를 로컬과 같은지 대조하세요.
res.json() 대신 JSON.parse를 쓰면 달라지나요?
둘 다 같은 파서입니다. 본문이 HTML이면 같은 줄이 납니다. text()로 확인한 뒤에만 parse하세요.
로그인 뒤에만 납니다.
401을 HTML 로그인 페이지로 돌려보내는 서버입니다. API는 JSON 에러를 주고, 화면 이동은 클라이언트가 상태 코드를 보고 처리하세요.
CORS 에러와 같이 보입니다.
브라우저가 본문을 못 읽게 막으면 빈 값이나 다른 줄이 납니다. CORS는 서버 허용 헤더 글에서 보고, 여기 줄은 본문이 HTML로 오는 경우만 다룹니다.
Unexpected token '<'는 응답이 JSON이 아니라 HTML 이라는 뜻입니다. parse 전에 본문과 Content-Type을 보고, 요청 URL이 API인지 맞추세요. 관련 글: CORS , 리다이렉트 루프 , NEXT_PUBLIC .