You open a fresh Next.js project, cd into src/components, and the directory is empty. Not “empty like a new sketchbook” empty — empty like a blinking cursor daring you to type npx create-next-app for the fifth project this month and then rebuild the same dashboard shell you’ve built a dozen times before. You’ve got OpenCode running against a local Qwen3.5 9B model because the client’s compliance team won’t let code anywhere near a hosted API, and you’re about to find out the hard way that “build me a dashboard” is not a prompt — it’s a dare.
A 9B model quantized to 4-bit GGUF fits in about 6.5GB of VRAM, which means it runs fine on an RTX 3060 or 4060 sitting under your desk. What it doesn’t do is hold an entire React application in its head while generating flawless syntax. Ask it for a whole app and you’ll get truncated JSX, imports for components that don’t exist, and a closing </div> that never shows up. Ask it for one component at a time, with clear boundaries, and it performs close to what you’d expect from a much larger model. The difference isn’t model quality — it’s how you split the work.
This is the pattern for getting a local agent to scaffold a real Next.js frontend without burning an afternoon debugging hallucinated imports: use standard CLI tooling for the parts that don’t need intelligence, then hand the agent tightly scoped, atomic prompts for the parts that do.
Setting Up the Next.js Canvas
Let the CLI Do the Boring Part
Don’t ask OpenCode to hand-write next.config.js, your tsconfig.json, or the Tailwind setup. That’s a solved problem — create-next-app does it correctly every time, and every token you spend having a 9B model reconstruct boilerplate from memory is a token it isn’t spending on your actual component logic. Have the agent run the standard scaffold command instead of typing out config files token by token:
npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
This gets you App Router, TypeScript, Tailwind, and a src/ layout in one shot, with zero risk of the model inventing a config option that doesn’t exist. Once the scaffold finishes, that’s when OpenCode’s job actually starts — filling in the components that make the shell useful.
Tell the Agent Which Next.js It’s In
Next.js has shipped two fundamentally different data-fetching and rendering models since 2023: the Pages Router your model trained on years of tutorials for, and the App Router you’re actually using. Local models default to what they saw most in training, which skews toward the older pattern. Left unguided, a 9B model will happily hand you getServerSideProps in a project that has no pages/ directory at all.
Put a standing rule in AGENTS.md at the project root so you’re not repeating yourself every session:
# AGENTS.md
- This project uses the Next.js App Router exclusively. Never use `getServerSideProps`, `getStaticProps`, or the `pages/` directory.
- Components are Server Components by default. Add `"use client"` as the first line only when the component uses state, effects, or browser APIs.
- Styling is Tailwind CSS utility classes only. No inline `style` props, no separate `.css` files.
- Save all components under `src/components/`.
That last rule matters more than it looks like it should — Tailwind’s default config only scans paths defined in tailwind.config.ts (which defaults to everything under src/). A component saved completely outside src/ can render with zero styling applied even though the JSX is correct. You’ll spend twenty minutes assuming your Tailwind install is broken before you notice the file landed in the wrong folder.
Generating Complex Components
Break the Dashboard Into Pieces the Model Can Hold
A dashboard isn’t one component, it’s three or four: a Sidebar, a Header, and whatever main content does the actual work — say, a DataTable. Prompting for all of it at once is the single most common way to burn a session. Local 9B models start losing instruction adherence and syntax integrity somewhere past 150-200 lines of generated React in a single response. Split the dashboard into files and generate each one in its own turn:
Sidebar.tsx— Client Component only if it needs state (a mobile collapse toggle); Server Component if it’s static navigation.Header.tsx— almost always a Server Component.DataTable.tsx— Client Component, since sorting and pagination need interactivity.
Each of these is a separate prompt, not a separate section of one giant prompt. The context window resets your advantage every time — a focused 40-line ask gets you working code; a 300-line ask gets you a component that imports lucide-react icons you never installed.
Component Generation Pattern Diagram
Visualizes the atomic component generation pattern. The Next.js shell is built by standard CLI tooling, while OpenCode handles individual components via tightly scoped, sequential prompts.

Visual Notes:
- The CLI generates the initial canvas to avoid wasting context window tokens.
- Each component represents an isolated, atomic prompt cycle to maintain context integrity.
- Component responses feed back into the Dashboard Shell sequentially.
Say the Tailwind Classes Out Loud
“Make it look modern” is not a specification, it’s a Rorschach test, and the model will fail it. Give it literal class names:
Use
bg-slate-900,text-slate-100, andborder-slate-800for the container. Rows gethover:bg-slate-800on hover.
That single sentence removes an entire category of back-and-forth where you’re describing a color scheme in adjectives and the model is guessing at hex values.
Practical Example: Building a Dynamic Data Table
Here’s the actual sequence for the DataTable component, run as three separate prompts against OpenCode.
Step 1 — the interface. Prompt:
Create a React functional component
DataTableinsrc/components/DataTable.tsx. It must use Tailwind CSS for styling. Include a"use client"directive at the very top. The component accepts adataprop (an array of objects). Implement basic ascending/descending sorting logic on column headers.
Step 2 — styling pass. Once the logic comes back clean, ask specifically for the visual layer using the explicit class names from the section above, rather than trusting the first pass to have nailed hover states and borders.
Step 3 — refine the sort. The first sorting implementation from a 9B model is usually functional but naive — often string-comparing numeric columns. Call that out directly and ask for a numeric-aware comparator.
The component that comes out the other side:
"use client";
import { useState, useMemo } from "react";
type SortDirection = "asc" | "desc" | null;
interface DataTableProps {
data: (Record<string, string | number | null> & { id?: string | number })[];
}
export default function DataTable({ data }: DataTableProps) {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
const columns = data.length > 0 ? Object.keys(data[0]).filter(k => k !== "id") : [];
const sortedData = useMemo(() => {
if (!sortKey || !sortDirection) return data;
return [...data].sort((a, b) => {
const valA = a[sortKey];
const valB = b[sortKey];
if (valA === null || valA === undefined) return 1;
if (valB === null || valB === undefined) return -1;
if (typeof valA === "number" && typeof valB === "number") {
return sortDirection === "asc" ? valA - valB : valB - valA;
}
return sortDirection === "asc"
? String(valA).localeCompare(String(valB))
: String(valB).localeCompare(String(valA));
});
}, [data, sortKey, sortDirection]);
function handleSort(column: string) {
if (sortKey !== column) {
setSortKey(column);
setSortDirection("asc");
return;
}
setSortDirection(sortDirection === "asc" ? "desc" : sortDirection === "desc" ? null : "asc");
}
return (
<table className="w-full border border-slate-800 bg-slate-900 text-slate-100">
<thead>
<tr>
{columns.map((col) => (
<th
key={col}
onClick={() => handleSort(col)}
className="cursor-pointer border-b border-slate-800 px-4 py-2 text-left hover:bg-slate-800"
>
{col} {sortKey === col ? (sortDirection === "asc" ? "▲" : sortDirection === "desc" ? "▼" : "") : ""}
</th>
))}
</tr>
</thead>
<tbody>
{sortedData.map((row, i) => (
<tr key={row.id || i} className="hover:bg-slate-800">
{columns.map((col) => (
<td key={col} className="border-b border-slate-800 px-4 py-2">
{row[col] !== null && row[col] !== undefined ? String(row[col]) : ""}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
That’s a working, typed, sortable table ready to drop into a dashboard page and wire up to real data. Three prompts, no rewritten layout.tsx, no phantom imports.
Best Practices and Tips
Do:
- State “App Router” explicitly in every non-trivial prompt — don’t assume the AGENTS.md rule carries enough weight on its own for complex requests.
- Read the imports before you accept generated code. A model hallucinating a package you never installed is the single most common failure mode.
- Hand over literal Tailwind classes or hex values when the visual result matters.
Don’t:
- Ask for more than one component per prompt.
- Trust generated accessibility markup without checking it. Local models frequently skip
aria-label, keyboard handlers, and focus states unless you ask by name.
Performance tip: keep each prompt scoped to the single file being touched, plus its direct dependencies. Feeding the agent your entire node_modules tree or a sprawling page.tsx “for context” is the fastest way to blow a 262k-token effective budget before the model has written a line of your actual component.
Troubleshooting Common Issues
The agent generates class components or getServerSideProps.
Local models trained on years of React tutorials default to whatever pattern showed up most often, which usually predates the App Router. Fix it by putting “Write modern React using functional components and hooks only, App Router conventions” at the top of the prompt itself — don’t rely on AGENTS.md alone for anything you actually care about getting right.
Tailwind classes aren’t rendering.
Check where the file landed first. If the agent saved the component completely outside src/, it’s outside Tailwind’s default content scan paths in tailwind.config.ts, and the classes exist in the JSX but never make it into the compiled CSS.
The session runs out of context or the response truncates mid-component.
You’re passing too much. Trim the prompt down to the one file being generated and its immediate dependencies — not the whole src/ tree.
Conclusion
A local 9B model can scaffold a genuinely usable Next.js frontend, but only if you stop treating it like a one-shot code generator and start treating it like a very fast, very literal junior developer: give it one task, be specific about the stack, and check its work before you move to the next component. Atomic generation isn’t a workaround for a weaker model — it’s a better way to prompt any model, local or not.
Next steps: try scaffolding a Sidebar component on your own setup using the atomic pattern above, then move on to building the API layer this frontend will eventually talk to.
Related Articles in This Series:
- The Architect’s Guide to Building Web Apps with Local AI: OpenCode on an 8GB GPU
- Building Secure Backend APIs using Qwen3.5 9B
