Switch to Bascik
From WordPress
WordPress is a PHP content management system that renders pages from a database on each request; Bascik is a build tool for HTML components. Theme templates become HTML component files, The Loop becomes a Node.js build script, and your posts and pages become Markdown files or build-time fetches from the WordPress REST API.
Why Switch to Bascik
- Streamlined Architecture: Clean HTML, CSS, and scoped JavaScript without the weight, vulnerabilities, or maintenance overhead of a database-backed CMS.
- Flexible Content Workflows: Manage content via Markdown, headless CMS APIs, or build scripts, deploying to any host or serverless platform.
- Headless Option: You can keep WordPress as a headless CMS for authoring while letting Bascik render fast, modern front ends (see Keep WordPress as a Headless CMS).
Mental Model Comparison
| Concept | WordPress | Bascik |
|---|---|---|
| Theme template parts | header.php, footer.php, get_template_part() | src/components/site-header/site-header.html (<site-header>) |
| Page templates | page.php, single.php, archive.php | Files in src/pages/ plus slot-based layout components |
| The Loop | while ( have_posts() ) : the_post(); | <script data-bascik-build> with Node.js |
| Template tags | the_title(), the_permalink() | data-bascik-prop-* and data-bascik-attr-* |
| Posts and pages in the database | Markdown files or the REST API at build time | Dynamic Routes |
| Shortcodes and blocks | [callout]...[/callout] | Component tags with data-bascik-slot |
style.css and enqueued assets | wp_enqueue_style() | Paired .css files (auto-scoped and deduplicated) |
functions.php hooks | add_action() and add_filter() | Build scripts, Exec Scripts, and API Routes |
| Plugins | PHP plugins | npm packages, build scripts, or hosted services |
| Media Library | wp-content/uploads/ | Image files copied into dist/ by an Exec Script |
Permalinks (/%year%/%monthnum%/%day%/%postname%/) | Rewrite rules | One dynamic route template per URL shape, such as src/pages/[year]/[month]/[day]/[slug]/index.html |
A Low-Risk First Step
Move your theme's footer into a Bascik component before touching any content:
- Create a new Bascik project with
npm create bascik@latest. - Create
src/components/site-footer/site-footer.htmland paste the rendered footer markup from your live site. - Use
<site-footer />insidesrc/pages/index.html. - Run
npm run devto inspect the generated HTML.
Theme Files → HTML Component Files
WordPress themes are PHP files that mix markup and logic. In Bascik, every reusable piece of markup is a plain .html file under src/components/, identified by its hyphenated folder name. Start from the rendered HTML of your live site rather than the PHP source.
Before (WordPress theme) After (Bascik)
my-theme/ src/components/
header.php site-header/
footer.php site-header.html
sidebar.php site-footer/
index.php site-footer.html
single.php site-layout/
page.php site-layout.html
archive.php post-card/
template-parts/ post-card.html
content-post.php src/pages/
style.css index.html
functions.php about.html
blog/
[slug].html get_header() and get_footer() → Slot-based Layout Components
A WordPress template calls get_header() and get_footer() around the page content. In Bascik, a layout component wraps the repeated structure and uses data-bascik-slot for the page body.
<?php /* page.php (WordPress - before) */ ?>
<?php get_header(); ?>
<main>
<h1><?php the_title(); ?></h1>
<?php the_content(); ?>
</main>
<?php get_footer(); ?> <!-- src/components/site-layout/site-layout.html (Bascik - after) -->
<site-header />
<main>
<div data-bascik-slot></div>
</main>
<site-footer /> <!-- src/pages/about.html (Bascik - after) -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>About - Acme</title>
<meta name="description" content="Learn about Acme Corp." />
</head>
<body>
<site-layout>
<h1>About</h1>
<p>We build things.</p>
</site-layout>
</body>
</html> The <html>, <head>, and <body> live in the page file, so wp_head() and wp_footer() have no direct equivalent. Put shared head markup such as favicons, fonts, and Open Graph tags in a reusable component like <site-head>.
Template Tags → data-bascik-prop-* and data-bascik-attr-*
Template tags such as the_title() and the_permalink() print values from the current post. In Bascik, a component declares where values go with data-bascik-prop-* for text and data-bascik-attr-{attribute}="{propName}" for attributes such as href or src.
<?php /* template-parts/content-post.php (WordPress - before) */ ?>
<article class="post-card">
<h3><?php the_title(); ?></h3>
<p><?php echo esc_html( get_the_excerpt() ); ?></p>
<a href="<?php the_permalink(); ?>">Read more</a>
</article> <!-- src/components/post-card/post-card.html (Bascik - after) -->
<article class="post-card">
<h3 data-bascik-prop-title></h3>
<p data-bascik-prop-excerpt></p>
<a data-bascik-attr-href="url">Read more</a>
</article>
<!-- Usage -->
<post-card
data-bascik-prop-title="Hello World"
data-bascik-prop-excerpt="A short summary."
data-bascik-prop-url="/blog/hello-world"
></post-card> Text and attribute values: Bascik props carry plain text strings. For rich HTML content, such as a post body, use a slot instead of a prop.
The Loop → <script data-bascik-build>
The Loop iterates over posts from the database. The Bascik equivalent is a <script data-bascik-build> block that reads Markdown files with Node.js and returns HTML from its default export.
<?php /* archive.php (WordPress - before) */ ?>
<ul class="post-list">
<?php while ( have_posts() ) : the_post(); ?>
<li>
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
<time><?php echo get_the_date( 'Y-m-d' ); ?></time>
</li>
<?php endwhile; ?>
</ul> <!-- src/pages/blog/index.html (Bascik - after) -->
<ul class="post-list">
<script data-bascik-build>
import { readdir, readFile } from 'node:fs/promises';
import matter from 'gray-matter';
const escape = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
export default async function () {
const files = (await readdir('./content/posts')).filter(f => f.endsWith('.md'));
const posts = await Promise.all(files.map(async f => {
const { data } = matter(await readFile(`./content/posts/${f}`, 'utf8'));
return { slug: f.replace('.md', ''), title: data.title, date: new Date(data.date) };
}));
posts.sort((a, b) => b.date - a.date);
return posts.map(p =>
`<li><a href="/blog/${p.slug}/">${escape(p.title)}</a> <time datetime="${p.date.toISOString()}">${p.date.toISOString().slice(0, 10)}</time></li>`
).join('\n');
}
</script>
</ul> Install gray-matter (npm install gray-matter): the scaffold does not include it. A title is text, so escape it before returning. A page that reads files Bascik cannot see as imports (here readdir) must be listed in scripts.cache.exclude, or a rebuild reuses the old output; see Build Scripts.
Single Posts → Dynamic Routes
single.php renders one template for every post. In Bascik, a file named src/pages/blog/[slug].html with a <script data-bascik-routes> block expands into one static page per post. See Dynamic Routes for the full route object format.
<!-- src/pages/blog/[slug].html (Bascik - after) -->
<script data-bascik-routes>
import { readdir } from 'node:fs/promises';
export default async function () {
const files = await readdir('./content/posts');
return files
.filter(f => f.endsWith('.md'))
.map(f => ({
params: { slug: f.replace('.md', '') }
}));
}
</script> To keep existing permalinks, name files and folders to match your current URL structure. src/pages/blog/[slug].html writes /blog/my-post.html; for the trailing-slash URLs WordPress uses, put the template in a folder: src/pages/blog/[slug]/index.html gives /blog/my-post/. A route parameter cannot contain /, so a date permalink needs one bracket per segment:
WordPress permalink Bascik template
/2026/03/15/raised-beds/ src/pages/[year]/[month]/[day]/[slug]/index.html
/about/ (top-level page) src/pages/[pageslug]/index.html
/about/colophon/ (child page) src/pages/[parent]/[child]/index.html
/category/notes/ src/pages/category/[term]/index.html
/page/2/ src/pages/page/[page]/index.html Templates at different depths can sit side by side at the root. Check the result against your live sitemap before launch.
Shortcodes and Blocks → Components
Shortcodes such as [callout]text[/callout] and custom Gutenberg blocks are reusable fragments. In Bascik, they become component files. For fragments that wrap content, use data-bascik-slot.
<?php /* functions.php (WordPress - before) */
add_shortcode( 'callout', function ( $atts, $content = '' ) {
return '<div class="callout">' . do_shortcode( $content ) . '</div>';
} ); <!-- src/components/my-callout/my-callout.html (Bascik - after) -->
<div class="callout">
<div data-bascik-slot></div>
</div>
<!-- Used in a page with: -->
<my-callout><p>This is a tip.</p></my-callout> style.css → Paired CSS Files
WordPress themes usually ship one global stylesheet loaded with wp_enqueue_style(). In Bascik, write a .css file next to each component. Class names are scoped at build time, so you can split a large style.css per component and stop worrying about collisions with plugin styles. See Scoped Styles.
Plugins → Build Scripts, API Routes, and Services
Plugins have no direct equivalent because Bascik pages are static HTML. Map each plugin to the closest piece:
| Plugin purpose | Bascik approach |
|---|---|
| SEO (title, meta, sitemap) | Hardcode tags in pages, and use the built-in Sitemap and Robots generation |
| Contact forms | A handler in src/api/ (see API Routes) or a hosted form service |
| Search | Build a search index at build time with an Exec Script |
| Caching and performance | Not needed for static output; see Performance |
| Image galleries and sliders | Vanilla JavaScript in a component <script>, see Scoped JavaScript |
| Comments, memberships, e-commerce | Hosted services, or keep WordPress for those features |
// src/api/contact.ts (Bascik - replaces a contact form plugin)
export const POST = async (request: Request): Promise<Response> => {
const { name, email, message } = await request.json();
if (!email || !message) {
return Response.json({ error: 'email and message are required' }, { status: 400 });
}
// Send the message with your email provider here.
return Response.json({ ok: true }, { status: 201 });
}; Run bascik --server to serve API routes in production. Fully static hosting cannot run API routes, so pick a host that supports them or use a hosted form service.
Moving Your Content
You have two options, and they can be combined:
- Export to Markdown. Export your content from WordPress (Tools → Export produces a WXR XML file), convert posts to Markdown with a community converter such as
wordpress-export-to-markdown, and commit the.mdfiles undercontent/. Then read them with a build script as shown above. Run the converter while the old site is still online, because it downloads the images. Review the output: page builders and shortcodes rarely convert cleanly; check page hierarchy, category names, image alt text, and draft dates. - Fetch at build time. Read posts from the WordPress REST API in a build script. This keeps WordPress as your editor and Bascik as your renderer.
The converter saves each post's images next to its Markdown, as originals only. Copy them into dist/ with an Exec Script and point the Markdown at the copied path. The resized copies WordPress made (photo-300x200.jpg) are not in an export; if you want srcset, regenerate them or keep fetching through the REST API, which lists them.
Keep WordPress as a Headless CMS
A WordPress site exposes its published content at /wp-json/wp/v2/posts and /wp-json/wp/v2/pages. Drafts, private posts, and password-protected bodies are not public, so they never reach the build. Editors keep using the dashboard while visitors receive static HTML.
Fetch everything once, in a pre Exec Script, rather than in every page. The script follows the X-WP-TotalPages header (the API rejects per_page above 100 with a 400), sanitizes each body, downloads the media into dist/, and writes one JSON file that the pages read:
// scripts/sync-wordpress.ts (Bascik - a pre exec step; illustrative)
import { mkdir, writeFile } from 'node:fs/promises';
import sanitizeHtml from 'sanitize-html';
const origin = process.env.WORDPRESS_URL;
const posts = [];
for (let page = 1, total = 1; page <= total; page++) {
const res = await fetch(`${origin}/wp-json/wp/v2/posts?per_page=100&page=${page}&_embed=wp:term`);
if (!res.ok) throw new Error(`WordPress returned ${res.status}`);
total = Number(res.headers.get('x-wp-totalpages') ?? 1);
posts.push(...await res.json());
}
const snapshot = posts.map((post) => ({
slug: post.slug,
date: post.date,
title: sanitizeHtml(post.title.rendered, { allowedTags: [] }),
html: sanitizeHtml(post.content.rendered),
}));
await mkdir('node_modules/.cache/site', { recursive: true });
await writeFile('node_modules/.cache/site/posts.json', JSON.stringify(snapshot)); <!-- src/pages/[year]/[month]/[day]/[slug]/index.html (Bascik - after) -->
<script data-bascik-routes>
import { readFile } from 'node:fs/promises';
export default async function () {
const posts = JSON.parse(await readFile('node_modules/.cache/site/posts.json', 'utf8'));
return posts.map((post) => {
const [year, month, day] = post.date.slice(0, 10).split('-');
return { params: { year, month, day, slug: post.slug } };
});
}
</script> Sanitize what you print. content.rendered is HTML that an editor wrote, and administrators may save any markup, including <script>, onerror handlers, and javascript: links. Printed as is, it runs in every visitor's browser. Pass it through an allowlist such as sanitize-html before printing. Bascik also removes any <script data-bascik-build>, data-bascik-server, or data-bascik-routes tag found in a build script's output, with a warning. This is not a substitute for sanitizing CMS content.
Rebuild on publish: Static output only changes when you rebuild. Trigger a build from a WordPress webhook or your CI pipeline whenever content is published. List the pages that read the fetched data in scripts.cache.exclude: a build script's cache key does not include network responses, so without it a rebuild can reuse output from before the edit.
Large posts: Passing a whole post body as route data works without an environment-size limit. Reading the snapshot file from the page is another option.
Migration Checklist
- Crawl your live site (or export a sitemap) and list every URL you need to keep.
- Recreate the header, footer, and shared head as components.
- Build the layout component and one page per template type.
- Move content into Markdown or wire up the REST API.
- Replace each plugin with a build script, API route, or service from the table above.
- Compare the built output against your old sitemap, and configure redirects on your host for any URLs that change. WordPress serves its feed at
/feed/; a static feed is usually a file such as/feed.xml, so redirect the old address. - Choose your approach for dynamic features such as comments, search, and archives using Bascik API routes, server scripts, or dedicated services.
AI-Assisted Migration: If you use LLMs or AI coding assistants to convert theme templates, see the Agent Skills documentation for guidelines on providing context to AI tools.