A full-featured TypeScript boilerplate using Bun, Vue 3 (SSR + SPA), Drizzle ORM, ElysiaJS, and a built-in WebSocket server. Designed without Express or Node.js, this project utilizes Bun's native HTTP server, Vite, and Elysia for modern high-performance development.
Ideal for building isomorphic applications with authentication, real-time features, SSR/SSG, and scalable backend architecture.
- โ Bun-native backend using Elysia.js
- โ Fullstack TypeScript (Bun + Vue 3)
- โ SSR + SPA hybrid with Vite
- โ Static Generation (SSG) support
- โ Pinia + SSR hydration
- โ Drizzle ORM (SQLite, MySQL, PostgreSQL)
- โ Session + API key authentication
- โ Native WebSocket server (with Elysia)
- โ Clean architecture: MVC + Router + Middleware
- โ Unified configuration and composables
- โ CSRF protection, CORS, CSP
- โ
i18n with dynamic translation loading
- ๐ Auto-detect locale from route
- ๐ฆ Load only necessary translation namespace per page
- ๐ง SSR/SSG compatible
- ๐งฉ Backend integration via
i18next-fs-backend
and Elysia route
data/ # SQLite DB file (default: mydb.sqlite)
dist/ # Production build (client + server bundles)
scripts/ # Generation, build, and utility scripts
tests/ # Unit, integration, and load tests
public/ # Static public assets (e.g., logo, icons)
.env # Environment variable definitions
index.html # HTML template for SSR rendering
vite.config.ts # Vite build configuration
config/ # Configuration layer
โโโ ssg.config.ts # Static routes for pre-rendering
โโโ ws.config.ts # WebSocket ping/pong settings
โโโ security.config.ts # CORS, CSP, cookie settings
โโโ i18n.config.ts # i18n config
src/
โโโ client/ # Vue 3 SPA (SSR + hydration)
โ โโโ pages/ # File-based routing (SPA/SSR)
โ โโโ composables/ # Vue composables
โ โโโ store/ # Pinia stores
โ โโโ App.vue # Main layout
โ โโโ main.ts # App factory
โ โโโ router.ts # Vue Router config
โ โโโ entry-client.ts # Client SPA bootstrap
โ โโโ entry-server.ts # SSR rendering entry
โ โโโ entry-static-client.ts# Static client hydration
โ โโโ env.d.ts # Type declarations
โ โโโ vite-env.d.ts # Vite typings
โ
โโโ server/ # Bun HTTP + WebSocket + Elysia
โ โโโ db/ # Drizzle ORM DB init
โ โโโ models/ # Drizzle schemas
โ โโโ middleware/ # CSRF, auth, validation
โ โโโ controllers/ # Route handlers (business logic)
โ โโโ routes/ # API + SSR + WS routes
โ โโโ utils/ # preload, static walker
โ โโโ index.ts # Server entrypoint (Bun + Elysia)
โ
โโโ shared/ # Cross-layer utils
โ โโโ axios.ts # Axios with SSR support
โ โโโ env.ts # PUBLIC_ environment reader
โ โโโ globalCookieJar.ts # Server cookie holder
Create .env
:
PORT=8888
HOST=localhost
DB_FILE_NAME=data/mydb.sqlite
PUBLIC_API_URL=http://localhost:8888
PUBLIC_WS_URL=ws://localhost:8888/ws
Start in dev:
bun run dev
Build and serve:
bun run build
bun run start
- Server renders HTML via
entry-server.ts
- State is hydrated client-side using
window.__pinia
- Preload links injected into
<head>
for performance
- Session:
Set-Cookie: sessionId
- Alternative:
Authorization: Bearer <apiKey>
- Example routes:
POST /api/guest/login
POST /api/guest/register
GET /api/profile
POST /api/logout
- Via global axios client
@/shared/axios.ts
- SSR requests auto-include cookies from
globalCookieJar
baseURL
isPUBLIC_API_URL
- Connect via
ws://localhost:8888/ws
- Use
useWebSocket()
composable on the client - Broadcast server messages with
broadcast()
Create a .vue
file in src/client/pages/
and register it in router.ts
:
{ path: '/dashboard', component: () => import('./pages/Dashboard.vue') }
Add it to src/server/models
, then export from schema.ts
:
export const posts = sqliteTable('posts', { ... });
Create a handler in controllers/
:
export async function dashboardController({ body, request, set }: Context<{ body: PostBody }>) {
return { ok: true };
}
Add to routes/guest.ts
or routes/protected.ts
:
routes['/api/dashboard'] = { GET: dashboardController };
export const protectedRoutes = new Elysia({ prefix: "/api" })
//...
.post("/api/dashboard", dashboardController)
Use CLI to scaffold code:
bun run scripts/gen.ts controller MyController
bun run scripts/gen.ts route MyRoute guest
bun run scripts/gen.ts route MyRoute protected
bun run scripts/gen.ts model MyModel
bun run scripts/gen.ts middleware MyMiddleware
bun run dev # Dev mode with Vite
bun run build # Build frontend and SSR bundle
bun run generate # Generate frontend and bundle
bun run start # Start production server
- Pinia (
useUserStore
, etc.) - Auto-hydration in
entry-client.ts
- Server sets
window.__pinia
on render
- Powered by Drizzle ORM
- SQLite by default (
.env: DB_FILE_NAME
) - Tables:
users
: email, passwordHash, apiKeysessions
: id, userId, expiresAt
controllers/
: business logicmiddleware/
: validation/authroutes/
: route mappingmodels/
: Drizzle schemashared/
: universal utils (axios, env, cookies)
The template includes built-in CSRF protection:
- A secure CSRF token is generated on each SSR HTML response and stored in a cookie.
- The client automatically attaches the token via the
x-csrf-token
header on requests. - Server middleware validates the token by comparing it to the cookie.
- Only affects
POST
,PUT
,PATCH
, andDELETE
methods. - The logic is implemented in
src/server/middleware/csrf.ts
and used in the main router.
This helps prevent cross-site request forgery attacks in authenticated requests.
This template uses Drizzle ORM, which supports multiple SQL dialects including:
- SQLite (default, easy to start with)
- MySQL
- PostgreSQL
- MariaDB
To switch databases:
- Update
.env
:
# For MySQL:
DB_URL=mysql://user:pass@host:port/dbname
- Change the Drizzle adapter in
src/server/db/init.ts
:
Replace:
import { drizzle } from 'drizzle-orm/bun-sqlite';
import { Database } from 'bun:sqlite';
With (example for MySQL):
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
And update initDb()
:
const connection = await mysql.createConnection(process.env.DB_URL!);
db = drizzle(connection, { schema });
- Adapt models in
src/server/models/
using the corresponding dialectโs schema utilities:
drizzle-orm/sqlite-core
โdrizzle-orm/mysql-core
orpg-core
- Install required dependencies:
bun add mysql2 drizzle-orm
Once this is done, the rest of the application logic remains the same โ only the schema and adapter change depending on your database engine.
This template supports dynamic <title>
, <meta name="description">
, and Open Graph tags both on the server (SSR) and client using @vueuse/head
.
Import and call useHead()
inside your <script setup>
block:
<!-- src/client/pages/HomePage.vue -->
<script setup lang="ts">
import { useHead } from "@vueuse/head";
useHead({
title: "Home Page - My App",
meta: [
{
name: "description",
content: "Welcome to the home page of our app.",
},
{
property: "og:title",
content: "My App Home",
},
],
});
</script>
During server-side rendering, the generated tags are serialized and injected into the <head>
of the HTML response. This ensures search engines and social media platforms can read correct metadata on first load.
No extra configuration is needed โ SSR automatically includes the result of useHead()
in the response.
Localization is powered by vue-i18n
, integrated with SSR + SPA support and automatic translation loading based on the current route.
- Translations are organized by locale and namespace, e.g.
lang/en/home.json
,lang/ru/profile.json
- The namespace is automatically derived from the route:
/en/profile
โ namespace:profile
/auth/login
โ namespace:auth
/
โ namespace:home
(default)
๐ On the client, translations are automatically loaded based on the current page name. You don't need to manually specify a namespace โ it's inferred from the URL.
import { useI18n } from "vue-i18n";
const { t } = useI18n();
t("home.title"); // => "Home" (en), "ะะปะฐะฒะฝะฐั" (ru)
- All translation keys use the format
namespace.key
- Translations are fetched dynamically based on the current route
The <LocaleSwitcher />
component changes the current language via redirect:
<LocaleSwitcher />
- The default locale (
en
) has no prefix - Other locales use a prefixed path (
/ru/...
)
When creating a new translation file (e.g. lang/en/dashboard.json
, lang/ru/dashboard.json
):
Make sure to add the new namespace in
config/i18n.config.ts
:
namespaces: ["common", "auth", "profile", "meta", "home", "dashboard"],
When adding a new locale (e.g. de
):
Update the i18n config to include it:
preload: ["en", "ru", "de"],
supportedLngs: ["en", "ru", "de"],
Ensure the corresponding translation files exist:
lang/de/home.json
lang/de/common.json
...
To use a translation inside a backend controller or handler, use the t()
function provided in the context. You must reference translations using the namespace:key
format.
export function healthController({ t }: { t: TFunction }) {
return {
message: t("meta:health"),
};
}
This will return a translated string from
lang/{locale}/meta.json
under the key"health"
.
Let me know if you'd like to add an example of pluralization or interpolation too.
- Use
t("namespace.key")
for all translation calls - Namespace is resolved automatically from the route
- Translations are loaded dynamically as needed
- Update
config/i18n.config.ts
when adding new locales or namespaces
This section summarizes the performance of critical endpoints under load.
This document presents performance metrics and session analysis based on synthetic load testing using artillery
and bun
.
- Connections: 50
- Test Duration: 10 seconds
- Total Requests: 191
- 2xx Responses: 141
- Average Latency: 2910 ms
- p99 Latency: 4249 ms
- Max Latency: 6944 ms
- Throughput: 3.27 kB/s
Percentile | Latency (ms) |
---|---|
2.5% | 291 |
50% | 3462 |
97.5% | 4179 |
99% | 4249 |
Metric | Value |
---|---|
Min | 96.7 |
Max | 5487.8 |
Mean | 2714.2 |
Median | 2780 |
p95 | 4770.6 |
p99 | 5378.9 |
- Load Profile: 50 โ 500 RPS (ramp-up over 30 seconds)
- Test Duration: 30 seconds
- Total Requests: 8250
- 2xx Responses: 4501
- Timeout Errors (ETIMEDOUT): 3749
- Average Latency: 1 ms
- p99 Latency: 4 ms
- Max Latency: 7 ms
- Throughput: ~7.5 MB in 30s
Percentile | Latency (ms) |
---|---|
2.5% | 1 |
50% | 1 |
97.5% | 2 |
99% | 4 |
Metric | Value |
---|---|
Min | 1.3 |
Max | 219.1 |
Mean | 5.7 |
Median | 2.3 |
p95 | 5.4 |
p99 | 127.8 |
- Connections: 50
- Total Requests: 600
- 2xx Responses: 600
- Average Latency: 903 ms
- p99 Latency: 3605 ms
Metric | Value |
---|---|
Min | 96.7 |
Max | 5487.8 |
Mean | 2714.2 |
Median | 2780 |
p95 | 4770.6 |
p99 | 5378.9 |
- All tests were performed on
http://localhost:8888
. - Bun server memory usage peaked at 327 MB, with negligible CPU load.
Extracted from artillery
and custom loadtest logs:
Metric | Value (seconds) |
---|---|
Average | 2714.2 |
Median | 2780 |
95th Percentile | 4770.6 |
99th Percentile | 5378.9 |
Max | 5487.8 |
Most user sessions range between 2.5โ5 minutes. A few sessions exceed 20 minutes, indicating background usage or idle tabs.
To isolate backend performance, we executed a high-load benchmark targeting the /meta/info
endpoint without involving frontend rendering, WebSocket communication, or additional system load (e.g., SSR or SQLite queries).
- Command:
bun run scripts/metatest.ts
- Connections: 1000
- Duration: 10 seconds
- Workers: 1
- Average Latency:
72.28 ms
- p99 Latency:
142 ms
- Max Latency:
269 ms
- Avg Throughput:
~49,686 req/sec
,~19.3 MB/s
- Total Processed:
~501,000 requests in 10 seconds
Given the SQLite backend and SSR integration, the Bun server delivers:
- High performance for static and API requests.
- Consistent latency within a few hundred milliseconds for chained operations (e.g., CSRF โ Login โ Profile).
- Excellent RPS handling in raw conditions without frontend or I/O overhead.
These results confirm that the backend powered by Bun is highly capable under load, especially when isolated from rendering or database operations.
This template supports static pre-rendering (SSG) for selected routes.
-
Define routes to generate in
ssg.config.ts
:export const staticRoutes = ["/", "/about", "/auth/login"];
-
Run the generation script:
bun run generate
This will:
- Build the project using
vite.config.ts
- Render all configured routes to HTML
- Save files to
dist/static
- Serve the app:
bun run start
- When a user requests a page:
- If a pre-rendered HTML file exists in
dist/static
, it is served instantly. - Otherwise, the page is rendered via SSR on demand.
- If a pre-rendered HTML file exists in
- This ensures fast load for common pages, while keeping SSR flexibility.
- You can mix SSG and SSR freely.
- Useful for marketing, landing, auth, and public pages.
- Static files are generated once and served without re-computation.
Last updated: April 18, 2025
- Bun โ fast all-in-one JS runtime
- Vue 3 โ reactive UI framework
- Elysia.js โ ultra-fast server framework
- Drizzle ORM โ typesafe SQL
- Pinia โ Vue store with SSR support
- Vite โ dev/build tool
MIT ยฉ s00d
Pull requests and contributions welcome.