Switch to Bascik
From Next.js
Next.js and Bascik share intuitive file-based routing, but they serve different architectural goals. Next.js is a full-stack React framework offering server-side rendering, React Server Components (RSC), client-side hydration, and virtual DOM reconciliation. Bascik is a build-time HTML assembler and server that outputs standard vanilla HTML, CSS, and JavaScript with zero client runtime.
Switching to Bascik replaces framework abstractions with standard web platform primitives: JSX components become vanilla HTML files, data-fetching functions become build scripts or server scripts, and specialized Next.js components become standard HTML tags.
Mental Model Comparison
| Concept | Next.js | Bascik |
|---|---|---|
| Page | pages/about.js or app/about/page.tsx | src/pages/about.html |
| Shared layout | layout.tsx with children | <site-layout> with data-bascik-slot |
| Build-time data | Server Components, getStaticProps | <script data-bascik-build> |
| Per-request data | getServerSideProps, dynamic rendering | <script data-bascik-server> |
| Client interactivity | "use client" components and hooks | A <script> in the component, no hydration |
| Links, images, fonts | next/link, next/image, next/font | <a>, <img>, @font-face |
| API endpoints | pages/api/ or route handlers | src/api/ with Request and Response |
A Low-Risk First Step
To evaluate Bascik on an existing Next.js codebase, migrate a single static marketing page (like an /about page) and a shared header component before touching complex application routes:
- Create a project with
npm create bascik. - Create
src/components/site-nav/site-nav.htmlfrom your Next.js navigation component. - Create
src/pages/about.htmlcontaining your page structure and<site-nav />. - Run
npm run devto view your rendered page.
To migrate a whole production site with coding agents, matching it at every breakpoint while reaching 100 in all five Lighthouse categories, follow Migrating with Coding Agents.
Pages Router vs Bascik Routing
The Next.js Pages Router maps cleanly to Bascik's src/pages/ directory. Each .js / .tsx page file becomes a plain .html file at the same relative path:
Before (Next.js Pages Router) After (Bascik)
pages/ src/pages/
index.js index.html
about.js about.html
blog/ blog/
index.js index.html
[slug].js [slug].html Dynamic Routes: [slug].html Templates
In Next.js, pages/blog/[slug].js uses getStaticPaths (Pages Router) and app/blog/[slug]/page.tsx uses generateStaticParams (App Router) to define dynamic routes. In Bascik, you create a dynamic route template file like src/pages/blog/[slug].html with a <script data-bascik-routes> block that returns the array of routes to generate at build time (see Dynamic Routes).
A template is a full page, like every file in src/pages/: it needs <html>, <head>, and a non-empty <body>. The routes script can sit in the <head>. The template below writes dist/blog/<slug>.html, served at /blog/<slug>; use src/pages/blog/[slug]/index.html instead for /blog/<slug>/ URLs.
<!-- src/pages/blog/[slug].html -->
<!doctype html>
<html lang="en">
<head>
<script data-bascik-routes>
import { readdir } from "node:fs/promises";
export default async function () {
const files = await readdir("./content/posts");
return files
.filter((file) => file.endsWith(".md"))
.map((file) => ({ params: { slug: file.replace(/\.md$/, "") } }));
}
</script>
<title>Blog</title>
</head>
<body>
<article>
<script data-bascik-build>
import { readFile } from "node:fs/promises";
import { marked } from "marked";
export default async function () {
const { params } = JSON.parse(process.env.BASCIK_ROUTE);
const md = await readFile(
`./content/posts/${params.slug}.md`,
"utf8",
);
return marked(md);
}
</script>
</article>
</body>
</html> marked is not part of a new project: install it with npm install marked. A script that reads content/ cannot be cached by its imports, so add the template to scripts.cache.exclude and content/ to pipeline.watchPaths (see Build Scripts). An unknown slug gets the site's 404 page.
App Router Layouts → Shared Layout Components
Next.js App Router uses layout.tsx files to wrap nested pages in shared UI. In Bascik, the <html>, <head>, and <body> tags live in each page file, and repeated layout structure lives in components with slots.
// app/layout.tsx (Next.js - before)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SiteNav />
<main>{children}</main>
<SiteFooter />
</body>
</html>
);
} <!-- src/pages/about.html (Bascik - after) -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>About - Acme</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<site-nav />
<main>
<h1>About</h1>
<p>We build things.</p>
</main>
<site-footer />
</body>
</html> If many pages share the same outer wrapper, extract it into a layout component that accepts a default slot for the page content:
<!-- src/components/site-layout/site-layout.html -->
<site-nav />
<main class="content">
<div data-bascik-slot></div>
</main>
<site-footer /> <!-- src/pages/about.html (using the layout component) -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>About - Acme</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<site-layout>
<h1>About</h1>
<p>We build things.</p>
</site-layout>
</body>
</html> Server Components and getStaticProps → Build Scripts
getStaticProps (Pages Router) and an async server component (App Router) fetch data at build time. In Bascik, use a <script data-bascik-build> block. The script runs as a Node.js ESM module at build time, exporting a callable default function whose returned HTML replaces the tag in the page. Top-level import and top-level await are natively supported at module scope.
A build script returns HTML, not JSX. The html tagged template from @bascik/bascik/html escapes each interpolated value, as JSX did; see Escaping.
// pages/products.js (Next.js - before)
export async function getStaticProps() {
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return { props: { products } };
}
export default function Products({ products }) {
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} - ${p.price}
</li>
))}
</ul>
);
} <!-- src/pages/products.html (Bascik - after) -->
<ul>
<script data-bascik-build>
import { html } from "@bascik/bascik/html";
export default async function () {
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return html`${products.map((p) => html`<li>${p.name} - $${p.price}</li>`)}`;
}
</script>
</ul> Passing Props to Client Scripts (Hydration)
In Next.js Pages Router, props returned from getStaticProps or getServerSideProps are serialized by the framework into an internal <script id="__NEXT_DATA__" type="application/json"> tag so client-side React components can hydrate.
In Bascik, you do not need a runtime framework or synthetic data payloads. When client JavaScript needs data for browser filtering or interactivity, return a <script type="application/json"> tag directly:
- Static build data (
getStaticPropsequivalent): Return the<script type="application/json">block from<script data-bascik-build>. - Per-request server data (
getServerSidePropsequivalent): Return the<script type="application/json">block from<script data-bascik-server>.
<!-- src/pages/products.html -->
<script data-bascik-build>
import { html, json } from "@bascik/bascik/html";
export default async function () {
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return html`<script type="application/json" id="products-data">
${json(products)}
<\/script>`;
}
</script>
<script>
// Browser JavaScript reads the payload directly:
const products = JSON.parse(
document.getElementById("products-data").textContent,
);
</script> See Passing Data to Client Scripts for safety details and scoping rules.
Escaping and Shared Helpers
Move the code that turns data into markup into .ts helpers that return html values. A helper or data module that one component or page uses sits beside it, in that component's folder or next to the page, and is imported with ./; only a helper several components or pages share goes in src/lib/, imported with the @/ alias. See Component Best Practices. Two rules differ from Next.js:
@/works only in the script tag. Bascik resolves@/lib/x.tsinside<script data-bascik-build>. A helper file is run by Node as-is, so tsconfigpathsdo not apply there: a helper that imports another helper uses a relative path with its extension (./site.ts), and a type-only import must beimport type.- Build markup with
html. JSX escapes text for you; a plain template string does not. Thehtmltagged template from@bascik/bascik/htmlescapes every interpolated value for where it lands, text or attribute, and blocksjavascript:URLs. A helper that returns it declaresSafeHtml. See HTML Helpers.
// src/lib/render.ts (illustrative)
import { html, type SafeHtml } from "@bascik/bascik/html";
import type { Post } from "../interfaces/post.ts";
export function renderPreview(post: Post): SafeHtml {
return html`<h3><a href="/posts/${post.slug}">${post.title}</a></h3>`;
} Metadata → Head Build Scripts
export const metadata and generateMetadata become a build script in the page <head> that returns <title> and <meta> tags. For absolute Open Graph URLs, read BASCIK_SITE_URL (set with --site-url, the environment, or .env). Fail the build when it is missing rather than falling back: Next.js falls back to http://localhost:3000 with a warning when metadataBase is unset, which ships social URLs that point at a developer machine.
<head>
<site-head />
<script data-bascik-build>
import { renderHead } from "@/lib/site.ts";
export default () => renderHead({ title: "About - Acme" });
</script>
</head> A dynamic route reads the same BASCIK_ROUTE in its head script as in its body.
next/image → Standard img
Replace <Image> from next/image with a standard <img> tag. Add width, height, and loading="lazy" attributes where appropriate. Files under src/pages/ that are not pages or source code (images, fonts, .webmanifest, and similar) are copied to dist/ at the same path, so src/pages/assets/hero.jpg is served at /assets/hero.jpg. Bascik has no public/ directory, and it does not resize images or generate srcset.
// Before (Next.js)
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority /> <!-- After (Bascik) -->
<img src="/img/hero.jpg" alt="Hero" width="1200" height="600" /> next/link → Standard a
Replace <Link href="..."> with a standard <a href="...">. Because Bascik sites do not require a heavy client router, navigation uses standard browser page requests.
next/font → @font-face
next/font downloads a font, self-hosts it, and adds a preload. Do the same by hand: put the .woff2 file and its license under src/pages/assets/fonts/, declare it with @font-face in your global stylesheet, and add <link rel="preload" as="font" type="font/woff2" crossorigin> to the head. Bascik does not generate fallback-metric adjustments.
Client Components → Component Scripts
A "use client" component becomes a Bascik component with a plain <script>. There is no hydration: the HTML is final, and the script attaches behavior. useState becomes a variable or a DOM attribute, and useEffect becomes code that runs once when the script runs. Use getElementById for the component's own elements, because Bascik rewrites those ids per instance.
A script that must run before the page paints (for example, applying a saved color scheme) goes in <head>. Production HTML minification moves scripts in the body to the end of the body, but leaves head scripts where they are.
next/head → Inline head Tags
Replace the <Head> component from next/head with regular <title> and <meta> tags in each page's <head> element:
// pages/about.js (Next.js - before)
import Head from "next/head";
export default function About() {
return (
<>
<Head>
<title>About - Acme</title>
<meta name="description" content="About Acme Corp." />
</Head>
<h1>About</h1>
</>
);
} <!-- src/pages/about.html (Bascik - after) -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>About - Acme</title>
<meta name="description" content="About Acme Corp." />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<site-nav />
<h1>About</h1>
<site-footer />
</body>
</html> API Routes → src/api/
Next.js App Router route handlers (app/api/.../route.ts) map directly to Bascik API routes in src/api/. Both frameworks use standard WHATWG Request and Response interfaces:
Before (Next.js App Router) After (Bascik)
app/api/contact/route.ts src/api/contact.ts
app/api/users/[id]/route.ts src/api/users/[id].ts // src/api/contact.ts (Bascik)
export const POST = async (request: Request): Promise<Response> => {
const data = await request.json();
return Response.json({ received: data }, { status: 201 });
}; The second argument differs. In Next.js 15 and later, params is a promise you await; in Bascik it is a plain object, context.params.id. Code that awaits it still works, because awaiting a plain object returns the object.
Handlers run in-process on the production server (bascik --server) or during local dev (bascik). A static build (bascik --build) cannot serve them and prints a warning; see Static Builds vs Production Server and Deployment for the supported serverless targets.
CSS Modules & Tailwind CSS
- CSS Modules: Create a plain
.cssfile alongside the component HTML (e.g.src/components/card/card.css). ReplaceclassName={styles.foo}withclass="foo". Bascik scopes class names at build time without requiring PostCSS or Webpack configuration. A rule such as.markdown h2matches onlyh2elements written in the component's template; to style HTML passed through a slot, such as rendered Markdown, write.markdown :is(h2). - Tailwind CSS: Keep your Tailwind classes and run Tailwind as a
pipeline.execscript withphase: 'pre'that writes the stylesheet intoBASCIK_OUT_DIR, then link it from the head. Point Tailwind'scontentatsrc/**/*.{html,ts}, which covers component files and the markup helpers beside them as well as shared ones insrc/lib/. Bascik leaves a class unscoped when the component's own stylesheet does not define it, so Tailwind utilities and adarkclass toggled on<html>keep matching withoutscoping.attributes.class: false.@applyin a component's.cssfile is not processed, because Bascik does not run Tailwind on component stylesheets; write the CSS out or keep@applyin the Tailwind input file. See Libraries for the CDN option.
TypeScript in Bascik
Bascik component files are vanilla HTML. If you have TypeScript helper functions, data fetchers, or API routes, you can keep them in .ts files and import them directly into build scripts, server scripts, or API routes. The scripts themselves can be TypeScript too: mark any inline script, browser or data-bascik-*, with type="text/typescript", or point its src= at a .ts file, and Bascik strips the types before it runs (see scripts.typescript).