[{"data":1,"prerenderedAt":890},["ShallowReactive",2],{"blog-/es/blog/embed-ai-dashboard-nextjs":3},{"id":4,"title":5,"author":6,"authorImage":7,"authorTitle":8,"body":9,"category":881,"date":882,"description":883,"extension":884,"image":7,"meta":885,"navigation":159,"path":886,"readTime":224,"seo":887,"stem":888,"__hash__":889},"content/blog/embed-ai-dashboard-nextjs.md","Embed an AI-Built Dashboard in Next.js","Vibedasher Team",null,"Developer Relations",{"type":10,"value":11,"toc":872},"minimark",[12,16,20,23,28,31,113,116,124,299,310,314,326,336,343,497,512,517,532,641,747,762,766,780,813,823,827,841,844,848,851,868],[13,14,5],"h1",{"id":15},"embed-an-ai-built-dashboard-in-nextjs",[17,18,19],"p",{},"You describe a dashboard, our AI builds it against a real ETL + query engine, and then you ship it. This post is the developer-first version of \"then you ship it\" — specifically inside a Next.js App Router app.",[17,21,22],{},"There are two doors. One works today. One is a developer preview. We'll be honest about which is which.",[24,25,27],"h2",{"id":26},"door-1-the-iframe-works-today","Door 1 — the iframe (works today)",[17,29,30],{},"The zero-code path. Publish a dashboard in Vibedasher, and you get a public URL. Drop it into any page — a Next.js component, a plain HTML file, a CMS block:",[32,33,38],"pre",{"className":34,"code":35,"language":36,"meta":37,"style":37},"language-html shiki shiki-themes github-light github-dark","\u003Ciframe\n  src=\"https://cloud.vibedasher.com/v/YOUR-DASHBOARD\"\n  width=\"100%\"\n  height=\"600\"\n  frameborder=\"0\"\n>\u003C/iframe>\n","html","",[39,40,41,54,68,79,90,101],"code",{"__ignoreMap":37},[42,43,46,50],"span",{"class":44,"line":45},"line",1,[42,47,49],{"class":48},"sVt8B","\u003C",[42,51,53],{"class":52},"s9eBZ","iframe\n",[42,55,57,61,64],{"class":44,"line":56},2,[42,58,60],{"class":59},"sScJk","  src",[42,62,63],{"class":48},"=",[42,65,67],{"class":66},"sZZnC","\"https://cloud.vibedasher.com/v/YOUR-DASHBOARD\"\n",[42,69,71,74,76],{"class":44,"line":70},3,[42,72,73],{"class":59},"  width",[42,75,63],{"class":48},[42,77,78],{"class":66},"\"100%\"\n",[42,80,82,85,87],{"class":44,"line":81},4,[42,83,84],{"class":59},"  height",[42,86,63],{"class":48},[42,88,89],{"class":66},"\"600\"\n",[42,91,93,96,98],{"class":44,"line":92},5,[42,94,95],{"class":59},"  frameborder",[42,97,63],{"class":48},[42,99,100],{"class":66},"\"0\"\n",[42,102,104,107,110],{"class":44,"line":103},6,[42,105,106],{"class":48},">\u003C/",[42,108,109],{"class":52},"iframe",[42,111,112],{"class":48},">\n",[17,114,115],{},"That's it. We host it, render it, and keep it fresh. No build step, no SDK, no keys. This exact snippet is running in production right now.",[17,117,118,119,123],{},"For ",[120,121,122],"strong",{},"private"," data, you don't expose the raw dashboard — you mint a short-lived signed token on your backend and pass it in the URL. The embed secret never touches the browser:",[32,125,129],{"className":126,"code":127,"language":128,"meta":37,"style":37},"language-ts shiki shiki-themes github-light github-dark","// server-side only — the embed secret never reaches the browser\nimport { signEmbedToken } from \"@vibedasher/client/embed\";\n\nconst token = signEmbedToken({\n  embedSecret: process.env.VIBEDASHER_EMBED_SECRET, // per-API-key secret\n  keyId: process.env.VIBEDASHER_API_KEY_ID,         // -> kid header\n  accountSlug: \"acme\",                              // -> iss\n  vizId: 1275,                                       // the dashboard to render\n  filters: { tenant_id: \"acme\" },                    // ANDed into every query\n  viewerId: \"user_8842\",                             // per-seat metering\n  ttlSeconds: 600,                                   // 10 min TTL, max 86400\n});\n// -> HS256 JWT, drop it into the iframe URL:\n// https://cloud.vibedasher.com/v/YOUR-DASHBOARD?embed_token=${token}\n","ts",[39,130,131,137,155,161,179,193,207,222,237,251,266,281,287,293],{"__ignoreMap":37},[42,132,133],{"class":44,"line":45},[42,134,136],{"class":135},"sJ8bj","// server-side only — the embed secret never reaches the browser\n",[42,138,139,143,146,149,152],{"class":44,"line":56},[42,140,142],{"class":141},"szBVR","import",[42,144,145],{"class":48}," { signEmbedToken } ",[42,147,148],{"class":141},"from",[42,150,151],{"class":66}," \"@vibedasher/client/embed\"",[42,153,154],{"class":48},";\n",[42,156,157],{"class":44,"line":70},[42,158,160],{"emptyLinePlaceholder":159},true,"\n",[42,162,163,166,170,173,176],{"class":44,"line":81},[42,164,165],{"class":141},"const",[42,167,169],{"class":168},"sj4cs"," token",[42,171,172],{"class":141}," =",[42,174,175],{"class":59}," signEmbedToken",[42,177,178],{"class":48},"({\n",[42,180,181,184,187,190],{"class":44,"line":92},[42,182,183],{"class":48},"  embedSecret: process.env.",[42,185,186],{"class":168},"VIBEDASHER_EMBED_SECRET",[42,188,189],{"class":48},", ",[42,191,192],{"class":135},"// per-API-key secret\n",[42,194,195,198,201,204],{"class":44,"line":103},[42,196,197],{"class":48},"  keyId: process.env.",[42,199,200],{"class":168},"VIBEDASHER_API_KEY_ID",[42,202,203],{"class":48},",         ",[42,205,206],{"class":135},"// -> kid header\n",[42,208,210,213,216,219],{"class":44,"line":209},7,[42,211,212],{"class":48},"  accountSlug: ",[42,214,215],{"class":66},"\"acme\"",[42,217,218],{"class":48},",                              ",[42,220,221],{"class":135},"// -> iss\n",[42,223,225,228,231,234],{"class":44,"line":224},8,[42,226,227],{"class":48},"  vizId: ",[42,229,230],{"class":168},"1275",[42,232,233],{"class":48},",                                       ",[42,235,236],{"class":135},"// the dashboard to render\n",[42,238,240,243,245,248],{"class":44,"line":239},9,[42,241,242],{"class":48},"  filters: { tenant_id: ",[42,244,215],{"class":66},[42,246,247],{"class":48}," },                    ",[42,249,250],{"class":135},"// ANDed into every query\n",[42,252,254,257,260,263],{"class":44,"line":253},10,[42,255,256],{"class":48},"  viewerId: ",[42,258,259],{"class":66},"\"user_8842\"",[42,261,262],{"class":48},",                             ",[42,264,265],{"class":135},"// per-seat metering\n",[42,267,269,272,275,278],{"class":44,"line":268},11,[42,270,271],{"class":48},"  ttlSeconds: ",[42,273,274],{"class":168},"600",[42,276,277],{"class":48},",                                   ",[42,279,280],{"class":135},"// 10 min TTL, max 86400\n",[42,282,284],{"class":44,"line":283},12,[42,285,286],{"class":48},"});\n",[42,288,290],{"class":44,"line":289},13,[42,291,292],{"class":135},"// -> HS256 JWT, drop it into the iframe URL:\n",[42,294,296],{"class":44,"line":295},14,[42,297,298],{"class":135},"// https://cloud.vibedasher.com/v/YOUR-DASHBOARD?embed_token=${token}\n",[17,300,301,302,305,306,309],{},"The ",[39,303,304],{},"filters"," claim is ANDed into every query the embed runs, and the browser can't widen it. That's your multi-tenant boundary. Run ",[39,307,308],{},"signEmbedToken"," in a Next.js route handler or Server Action — never in a client component.",[24,311,313],{"id":312},"door-2-eject-the-dashboard-into-your-own-code-developer-preview","Door 2 — eject the dashboard into your own code (developer preview)",[17,315,316,317,321,322,325],{},"The iframe is great until you want the dashboard to ",[318,319,320],"em",{},"be your app"," — your components, your design system, your interactions, your routing. That's what ",[120,323,324],{},"eject"," is for.",[327,328,329],"blockquote",{},[17,330,331,332,335],{},"Eject is a ",[120,333,334],{},"developer preview — not generally available yet."," The shape below is real and is what the sample app runs; treat it as the model, not a stable public API. A hosted quickstart is coming.",[17,337,338,339,342],{},"Here's the idea. Your AI (Claude Code, Cursor) talks to the Vibedasher MCP, pulls the dashboard's source, and rebuilds it natively in your app. The charts, layout, and filter logic land as ordinary React files that eject verbatim. The ",[120,340,341],{},"only"," thing wired to us is the data call:",[32,344,346],{"className":126,"code":345,"language":128,"meta":37,"style":37},"// Ejected into your app by your AI via the Vibedasher MCP.\n// You own this file; it renders from our engine through the SDK.\nimport { Vibedasher } from \"@vibedasher/client\";\nimport { DATASET_IDS } from \"./config\";   // plural — joins supported\n\nconst client = new Vibedasher({ apiKey: process.env.VIBEDASHER_API_KEY });\n\n// The one call shape every ejected chart uses:\nconst { columns, rows } = await client.query({\n  datasetIds: DATASET_IDS,   // optional — omitted, the engine infers them from the SQL's aliases\n  sql,                        // alias-only SQL, composed from your controls\n  params,                     // { column: value } filters, bound server-side\n});\n// Bind by column type: number -> measure, date -> time axis, string -> category.\n",[39,347,348,353,358,372,390,394,418,422,427,458,472,480,488,492],{"__ignoreMap":37},[42,349,350],{"class":44,"line":45},[42,351,352],{"class":135},"// Ejected into your app by your AI via the Vibedasher MCP.\n",[42,354,355],{"class":44,"line":56},[42,356,357],{"class":135},"// You own this file; it renders from our engine through the SDK.\n",[42,359,360,362,365,367,370],{"class":44,"line":70},[42,361,142],{"class":141},[42,363,364],{"class":48}," { Vibedasher } ",[42,366,148],{"class":141},[42,368,369],{"class":66}," \"@vibedasher/client\"",[42,371,154],{"class":48},[42,373,374,376,379,381,384,387],{"class":44,"line":81},[42,375,142],{"class":141},[42,377,378],{"class":48}," { DATASET_IDS } ",[42,380,148],{"class":141},[42,382,383],{"class":66}," \"./config\"",[42,385,386],{"class":48},";   ",[42,388,389],{"class":135},"// plural — joins supported\n",[42,391,392],{"class":44,"line":92},[42,393,160],{"emptyLinePlaceholder":159},[42,395,396,398,401,403,406,409,412,415],{"class":44,"line":103},[42,397,165],{"class":141},[42,399,400],{"class":168}," client",[42,402,172],{"class":141},[42,404,405],{"class":141}," new",[42,407,408],{"class":59}," Vibedasher",[42,410,411],{"class":48},"({ apiKey: process.env.",[42,413,414],{"class":168},"VIBEDASHER_API_KEY",[42,416,417],{"class":48}," });\n",[42,419,420],{"class":44,"line":209},[42,421,160],{"emptyLinePlaceholder":159},[42,423,424],{"class":44,"line":224},[42,425,426],{"class":135},"// The one call shape every ejected chart uses:\n",[42,428,429,431,434,437,439,442,445,447,450,453,456],{"class":44,"line":239},[42,430,165],{"class":141},[42,432,433],{"class":48}," { ",[42,435,436],{"class":168},"columns",[42,438,189],{"class":48},[42,440,441],{"class":168},"rows",[42,443,444],{"class":48}," } ",[42,446,63],{"class":141},[42,448,449],{"class":141}," await",[42,451,452],{"class":48}," client.",[42,454,455],{"class":59},"query",[42,457,178],{"class":48},[42,459,460,463,466,469],{"class":44,"line":253},[42,461,462],{"class":48},"  datasetIds: ",[42,464,465],{"class":168},"DATASET_IDS",[42,467,468],{"class":48},",   ",[42,470,471],{"class":135},"// optional — omitted, the engine infers them from the SQL's aliases\n",[42,473,474,477],{"class":44,"line":268},[42,475,476],{"class":48},"  sql,                        ",[42,478,479],{"class":135},"// alias-only SQL, composed from your controls\n",[42,481,482,485],{"class":44,"line":283},[42,483,484],{"class":48},"  params,                     ",[42,486,487],{"class":135},"// { column: value } filters, bound server-side\n",[42,489,490],{"class":44,"line":289},[42,491,286],{"class":48},[42,493,494],{"class":44,"line":295},[42,495,496],{"class":135},"// Bind by column type: number -> measure, date -> time axis, string -> category.\n",[17,498,499,500,503,504,507,508,511],{},"One method carries the whole contract: ",[39,501,502],{},"client.query({ sql, params })"," in (",[39,505,506],{},"datasetIds"," is optional — the engine infers the set from the aliases the SQL references), typed ",[39,509,510],{},"{ columns, rows }"," out. Everything underneath — transport, result fetching, decoding, caching — is hidden.",[513,514,516],"h3",{"id":515},"keep-the-api-key-on-the-server","Keep the API key on the server",[17,518,519,520,523,524,527,528,531],{},"In Next.js, run the query in a Server Component or route handler and pass the serializable result down as props. The API key is read from ",[39,521,522],{},"process.env.VIBEDASHER_API_KEY"," (note: ",[120,525,526],{},"no"," ",[39,529,530],{},"NEXT_PUBLIC_"," prefix) so it never enters the client bundle:",[32,533,535],{"className":126,"code":534,"language":128,"meta":37,"style":37},"// lib/vibedasher.ts  —  import 'server-only'\nimport { Vibedasher, type QueryResult } from \"@vibedasher/client\";\n\nconst client = new Vibedasher({ apiKey: process.env.VIBEDASHER_API_KEY! });\n\nexport async function loadDashboard(): Promise\u003CQueryResult> {\n  return client.query({ sql, params });   // datasetIds inferred from the SQL\n}\n",[39,536,537,542,561,565,586,590,621,636],{"__ignoreMap":37},[42,538,539],{"class":44,"line":45},[42,540,541],{"class":135},"// lib/vibedasher.ts  —  import 'server-only'\n",[42,543,544,546,549,552,555,557,559],{"class":44,"line":56},[42,545,142],{"class":141},[42,547,548],{"class":48}," { Vibedasher, ",[42,550,551],{"class":141},"type",[42,553,554],{"class":48}," QueryResult } ",[42,556,148],{"class":141},[42,558,369],{"class":66},[42,560,154],{"class":48},[42,562,563],{"class":44,"line":70},[42,564,160],{"emptyLinePlaceholder":159},[42,566,567,569,571,573,575,577,579,581,584],{"class":44,"line":81},[42,568,165],{"class":141},[42,570,400],{"class":168},[42,572,172],{"class":141},[42,574,405],{"class":141},[42,576,408],{"class":59},[42,578,411],{"class":48},[42,580,414],{"class":168},[42,582,583],{"class":141},"!",[42,585,417],{"class":48},[42,587,588],{"class":44,"line":92},[42,589,160],{"emptyLinePlaceholder":159},[42,591,592,595,598,601,604,607,610,613,615,618],{"class":44,"line":103},[42,593,594],{"class":141},"export",[42,596,597],{"class":141}," async",[42,599,600],{"class":141}," function",[42,602,603],{"class":59}," loadDashboard",[42,605,606],{"class":48},"()",[42,608,609],{"class":141},":",[42,611,612],{"class":59}," Promise",[42,614,49],{"class":48},[42,616,617],{"class":59},"QueryResult",[42,619,620],{"class":48},"> {\n",[42,622,623,626,628,630,633],{"class":44,"line":209},[42,624,625],{"class":141},"  return",[42,627,452],{"class":48},[42,629,455],{"class":59},[42,631,632],{"class":48},"({ sql, params });   ",[42,634,635],{"class":135},"// datasetIds inferred from the SQL\n",[42,637,638],{"class":44,"line":224},[42,639,640],{"class":48},"}\n",[32,642,646],{"className":643,"code":644,"language":645,"meta":37,"style":37},"language-tsx shiki shiki-themes github-light github-dark","// app/page.tsx  —  a Server Component\nexport const dynamic = \"force-dynamic\";\n\nexport default async function Page() {\n  const { columns, rows } = await loadDashboard();\n  return \u003CDashboard columns={columns} rows={rows} />; // client component renders\n}\n","tsx",[39,647,648,653,670,674,691,715,743],{"__ignoreMap":37},[42,649,650],{"class":44,"line":45},[42,651,652],{"class":135},"// app/page.tsx  —  a Server Component\n",[42,654,655,657,660,663,665,668],{"class":44,"line":56},[42,656,594],{"class":141},[42,658,659],{"class":141}," const",[42,661,662],{"class":168}," dynamic",[42,664,172],{"class":141},[42,666,667],{"class":66}," \"force-dynamic\"",[42,669,154],{"class":48},[42,671,672],{"class":44,"line":70},[42,673,160],{"emptyLinePlaceholder":159},[42,675,676,678,681,683,685,688],{"class":44,"line":81},[42,677,594],{"class":141},[42,679,680],{"class":141}," default",[42,682,597],{"class":141},[42,684,600],{"class":141},[42,686,687],{"class":59}," Page",[42,689,690],{"class":48},"() {\n",[42,692,693,696,698,700,702,704,706,708,710,712],{"class":44,"line":92},[42,694,695],{"class":141},"  const",[42,697,433],{"class":48},[42,699,436],{"class":168},[42,701,189],{"class":48},[42,703,441],{"class":168},[42,705,444],{"class":48},[42,707,63],{"class":141},[42,709,449],{"class":141},[42,711,603],{"class":59},[42,713,714],{"class":48},"();\n",[42,716,717,719,722,725,728,730,733,735,737,740],{"class":44,"line":103},[42,718,625],{"class":141},[42,720,721],{"class":48}," \u003C",[42,723,724],{"class":168},"Dashboard",[42,726,727],{"class":59}," columns",[42,729,63],{"class":141},[42,731,732],{"class":48},"{columns} ",[42,734,441],{"class":59},[42,736,63],{"class":141},[42,738,739],{"class":48},"{rows} />; ",[42,741,742],{"class":135},"// client component renders\n",[42,744,745],{"class":44,"line":209},[42,746,640],{"class":48},[17,748,749,750,753,754,757,758,761],{},"If you have ",[120,751,752],{},"no backend"," — a pure frontend — mint a short-TTL scoped token on a tiny route and construct a ",[39,755,756],{},"VibedasherEmbedClient({ token })"," in the browser instead. It exposes the identical ",[39,759,760],{},"query()",", so the rendering code doesn't care which door it came through.",[513,763,765],{"id":764},"bind-charts-by-column-type-not-by-guessing","Bind charts by column type, not by guessing",[17,767,768,769,772,773,776,777,779],{},"The result is typed. ",[39,770,771],{},"columns[].type"," is one of ",[39,774,775],{},"string | number | boolean | date | timestamp | json",", and ",[39,778,441],{}," are objects keyed by column name. Bind by type and the recreate stays faithful:",[781,782,783,799,805],"ul",{},[784,785,786,787,790,791,794,795,798],"li",{},"first ",[39,788,789],{},"string"," (or ",[39,792,793],{},"date","/",[39,796,797],{},"timestamp",") column → category / x-axis",[784,800,786,801,804],{},[39,802,803],{},"number"," column → measure / y-value",[784,806,807,808,794,810,812],{},"a ",[39,809,793],{},[39,811,797],{}," column → temporal axis, chronological order",[17,814,815,816,818,819,822],{},"That's the fidelity trick: your AI reads ",[39,817,771],{}," instead of sniffing ",[39,820,821],{},"typeof row[k]",", so the ejected charts render correctly on the first pass.",[24,824,826],{"id":825},"which-door-should-you-use","Which door should you use?",[781,828,829,835],{},[784,830,831,834],{},[120,832,833],{},"Ship analytics into an existing site in minutes, non-developers involved"," → iframe. It works today.",[784,836,837,840],{},[120,838,839],{},"You're a developer who wants the dashboard to live inside your app, in your code"," → eject (preview). You own the frontend; we run the ETL and query engine, pay-as-you-go.",[17,842,843],{},"Same engine, two front doors.",[24,845,847],{"id":846},"next-steps","Next steps",[17,849,850],{},"The iframe path is live now — publish a dashboard and paste the snippet. The eject path is a developer preview; a full quickstart is coming.",[17,852,853,854,861,862,867],{},"Read the docs at ",[855,856,860],"a",{"href":857,"rel":858},"https://docs.vibedasher.com",[859],"nofollow","docs.vibedasher.com",", or ",[855,863,866],{"href":864,"rel":865},"https://cloud.vibedasher.com/register",[859],"start building at cloud.vibedasher.com",".",[869,870,871],"style",{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":37,"searchDepth":56,"depth":56,"links":873},[874,875,879,880],{"id":26,"depth":56,"text":27},{"id":312,"depth":56,"text":313,"children":876},[877,878],{"id":515,"depth":70,"text":516},{"id":764,"depth":70,"text":765},{"id":825,"depth":56,"text":826},{"id":846,"depth":56,"text":847},"Engineering","2026-07-09","Two ways to ship a Vibedasher dashboard in a Next.js app: paste one iframe snippet today, or eject the dashboard into your own code and wire it to the SDK's client.query().","md",{},"/blog/embed-ai-dashboard-nextjs",{"title":5,"description":883},"blog/embed-ai-dashboard-nextjs","0APNNbhmTneUAGf5WENZQTx5Ste07f7v3kJ5yAuMZQg",1783694222028]