Minimal VPN subscription landing page built with TanStack Start, React, Vite, Tailwind CSS, and shadcn-style UI components.
The page is intended to be served as a branded subscription helper:
- show current subscription status and traffic from Remnawave;
- copy, import, and serve the subscription link for supported clients;
- provide platform-specific download links;
- keep Remnawave API credentials on the server side only.
The frontend renders the UI from src/routes/index.tsx.
For browser requests, subscription card data is loaded through a local server route:
Browser -> GET /api/subscription-info?shortUuid={shortUuid} -> Remnawave GET /api/sub/{shortUuid}/info
The Remnawave API token is read only by src/routes/api/subscription-info.ts. Do not expose it as a VITE_* variable.
For VPN client requests, short subscription URLs are proxied before the React page renders:
VPN client -> GET /{shortUuid} -> Remnawave GET /api/sub/{shortUuid}
VPN client -> GET /{shortUuid}/{clientType} -> Remnawave GET /api/sub/{shortUuid}/{clientType}
The server keeps Remnawave response headers such as subscription-userinfo,
profile-title, and routing, so clients like Happ can import the same
subscription URL that opens the web page in a browser.
For short subscription URLs such as /<shortUuid>, the shortUuid is read from
the route and sent to /api/subscription-info. On /, the server falls back to
the last path segment of SUBSCRIPTION_URL, falling back to
VITE_SUBSCRIPTION_URL. There is no separate
REMNAWAVE_SUBSCRIPTION_SHORT_UUID setting.
Required server-side variables:
| Variable | Purpose |
|---|---|
REMNAWAVE_PANEL_URL |
Base URL of the Remnawave panel, for example https://panel.example.com. |
REMNAWAVE_API_TOKEN |
Remnawave API token used by the server route. Never expose this to the browser. |
Public UI variables:
| Variable | Purpose |
|---|---|
VITE_SUBSCRIPTION_URL |
Build-time fallback public subscription URL/template shown, copied, and used for client import buttons on / when runtime SUBSCRIPTION_URL is unset. On /<shortUuid>, the visible route replaces the last path segment for copy/import buttons and for /api/subscription-info?shortUuid=.... If both values are empty, subscription copy/import buttons are hidden on /. |
VITE_PAGE_TITLE |
Header/title shown in the UI. Defaults to Subscription. |
VITE_SUPPORT_URL |
Build-time fallback support link shown as the Telegram/support button if runtime SUPPORT_URL is unset. |
VITE_SUBSCRIPTION_NOT_FOUND_REDIRECT_URL |
Build-time fallback browser redirect URL used when subscription info cannot be loaded and runtime SUBSCRIPTION_NOT_FOUND_REDIRECT_URL is unset. If both values are empty, the page stays open and shows the subscription card with the failed-load state. |
VITE_NOT_FOUND_REDIRECT_URL |
Build-time fallback browser redirect URL used for unmatched 404 routes when runtime NOT_FOUND_REDIRECT_URL is unset. If both values are empty, the built-in 404 page is shown. |
VITE_USE_MOCK_SUBSCRIPTION_INFO |
Optional static/demo mode. Set to true, 1, or yes to show bundled mock subscription data instead of calling /api/subscription-info. Used by the GitHub Pages workflow. |
Optional variables:
| Variable | Purpose |
|---|---|
SUPPORT_URL |
Runtime support link shown as the Telegram/support button. Preferred for Docker/Dokploy because it is read from the running container environment through /api/page-config. If both SUPPORT_URL and VITE_SUPPORT_URL are empty, the support button is hidden. |
SUBSCRIPTION_URL |
Runtime public subscription URL/template shown, copied, and used as the server fallback for /api/subscription-info on /. Preferred over VITE_SUBSCRIPTION_URL. |
SUBSCRIPTION_NOT_FOUND_REDIRECT_URL |
Runtime browser redirect URL used when subscription info cannot be loaded. Preferred over VITE_SUBSCRIPTION_NOT_FOUND_REDIRECT_URL. |
NOT_FOUND_REDIRECT_URL |
Runtime browser redirect URL used for unmatched 404 routes. Preferred over VITE_NOT_FOUND_REDIRECT_URL. |
CADDY_AUTH_API_TOKEN |
Optional X-Api-Key header for protected panel access. |
CLOUDFLARE_ZERO_TRUST_CLIENT_ID |
Optional Cloudflare Access client id. |
CLOUDFLARE_ZERO_TRUST_CLIENT_SECRET |
Optional Cloudflare Access client secret. |
EGAMES_COOKIE |
Optional cookie forwarded to the panel. |
Variables prefixed with VITE_ are build-time client configuration. They are
read by Vite during npm run build and baked into the browser bundle. Changing
them in a running Docker/Dokploy container does not affect the already-built UI;
rebuild and redeploy the image after changing a VITE_* value.
Server-side variables without the VITE_ prefix are read by the running server
process. In this app, SUPPORT_URL, SUBSCRIPTION_URL,
SUBSCRIPTION_NOT_FOUND_REDIRECT_URL, and NOT_FOUND_REDIRECT_URL are runtime
overrides: the browser fetches public UI values from /api/page-config, and
server routes read the same running process environment. They can be changed in
the container environment without rebuilding the image. Their VITE_*
counterparts remain build-time fallbacks and are useful for static builds or as
image defaults.
VITE_PAGE_TITLE and VITE_USE_MOCK_SUBSCRIPTION_INFO remain build-time
settings. To change those in production, set the value before building the image
and redeploy it.
Example:
REMNAWAVE_PANEL_URL=https://panel.example.com
REMNAWAVE_API_TOKEN=remnawave_api_token
SUBSCRIPTION_URL=https://vpn.example.com/subscription/user/abc123def456
VITE_PAGE_TITLE=Intezya VPN
SUPPORT_URL=https://t.me/support
SUBSCRIPTION_NOT_FOUND_REDIRECT_URL=https://example.com/support/subscription-not-found
NOT_FOUND_REDIRECT_URL=https://example.com/
VITE_USE_MOCK_SUBSCRIPTION_INFO=falseOn page load, the browser requests /api/subscription-info; on short routes it
uses /api/subscription-info?shortUuid=<shortUuid>. If the request fails because
the subscription cannot be fetched from Remnawave, the page checks
SUBSCRIPTION_NOT_FOUND_REDIRECT_URL, then falls back to
VITE_SUBSCRIPTION_NOT_FOUND_REDIRECT_URL.
- If the variable contains a URL, the browser navigates to that URL.
- If the variable is empty or unset, the user remains on the page and the subscription card shows the failed-load state.
Unmatched browser routes, including / when root rendering is disabled, use the
root 404 component. If NOT_FOUND_REDIRECT_URL contains a URL, the browser
navigates to that URL. If it is empty or unset, the page falls back to
VITE_NOT_FOUND_REDIRECT_URL; if both values are empty, the original 404 page is
shown.
The repository includes .github/workflows/pages.yml for a static GitHub Pages
deployment. The workflow runs on pushes to main and on manual dispatch, builds
the app with npm run build, and uploads dist/client through the official
GitHub Pages artifact/deploy actions.
TanStack Start normally builds a server-rendered app, so the Pages workflow also
runs node scripts/create-pages-index.mjs after the build. That script creates
static index.html, 404.html, and .nojekyll files in dist/client around
the generated client bundle.
GitHub Pages cannot run the TanStack Start server route
/api/subscription-info, so the workflow sets:
VITE_USE_MOCK_SUBSCRIPTION_INFO=trueIn that mode the page shows bundled demo subscription data and does not call the
server route. Normal non-Pages builds leave this variable unset and continue to
load real Remnawave data through /api/subscription-info.
For the repository site https://shadowgatevpn.github.io/subscription-page/, the
workflow also builds assets with the /subscription-page/ base path. Before the
first deployment, set the repository Pages source to GitHub Actions in
GitHub repository settings.
The repository includes .github/workflows/docker.yml for building and pushing a
Docker image to GitHub Container Registry:
ghcr.io/shadowgatevpn/subscription-page
The workflow runs on pushes to main, version tags matching v*, and manual
dispatch. It authenticates to ghcr.io with the built-in GITHUB_TOKEN, so no
registry secret is required. It publishes tags from the branch/tag name, a
sha-<commit> tag, and latest for the default branch.
The image is built from the root Dockerfile. The Docker build sets
DOCKER_BUILD=true, which makes vite.config.ts produce a Nitro node-server
output under .output. The runtime image serves that output on PORT, defaulting
to 3000.
Install dependencies:
npm installStart the dev server:
npm run devBuild for production:
npm run buildPreview a production build:
npm run previewRun lint:
npm run lintRun focused assertion tests:
node_modules/.bin/tsx src/lib/i18n.test.ts
node_modules/.bin/tsx src/lib/pages-build.test.ts
node_modules/.bin/tsx src/lib/subscription-info.test.ts
node_modules/.bin/tsx src/lib/not-found-redirect.test.ts
node_modules/.bin/tsx src/lib/subscription-redirect.test.ts
node_modules/.bin/tsx src/lib/subscription-passthrough.test.ts
node_modules/.bin/tsx src/lib/subscription-url.test.tsRun TypeScript checking:
node_modules/.bin/tsc --noEmit| Path | Responsibility |
|---|---|
Dockerfile |
Multi-stage Node image for the server-rendered subscription page. |
.dockerignore |
Keeps local build output and dependencies out of Docker build context. |
src/routes/index.tsx |
Main subscription page UI. |
src/routes/api/subscription-info.ts |
Server-only Remnawave subscription info proxy. |
src/lib/mock-subscription-info.ts |
Static demo subscription data used by GitHub Pages builds. |
src/lib/pages-build.ts |
GitHub Pages base path and env-flag helpers. |
src/lib/not-found-redirect.ts |
Decides whether unmatched 404 routes should redirect the browser. |
src/lib/subscription-passthrough.ts |
Proxies non-browser short subscription URL requests to Remnawave. |
src/lib/subscription-info.ts |
Normalizes Remnawave response into card data. |
src/lib/subscription-redirect.ts |
Decides whether a failed subscription load should redirect the browser. |
src/lib/subscription-url.ts |
Extracts shortUuid from the configured subscription URL. |
src/lib/i18n.ts |
UI translations. |
src/page-config.ts |
Public page configuration from VITE_* env values. |
scripts/create-pages-index.mjs |
Creates static Pages entry files around the TanStack Start client bundle. |
- Keep
REMNAWAVE_API_TOKENserver-side only. - Do not prefix secrets with
VITE_; Vite exposesVITE_*variables to browser code. - The browser calls only
/api/subscription-info; it never calls Remnawave with the API token. - The local server route returns normalized card data, not the full Remnawave response.