Skip to content

指南

基于 Analog v1.x / v2.x(@analogjs/platform / @analogjs/router / @analogjs/content)+ Angular v17/v18/v19/v20+ + Vite v5/v6/v8 + Nitro v2 编写。涵盖文件路由全集 / Layouts / Server Routes / Server-Side Data Fetching / Form Actions / Route Metadata / Middleware / Markdown Content / SSR / SSG / Nitro Adapter / 与 Angular 标准项目共存 / 常见踩坑。

文件路由全集

Analog 在 Angular Router 之上叠加「文件即路由」约定——src/app/pages/ 下所有 *.page.ts 文件自动收集成 Angular Routes[]。共 5 类主路由 + 2 类辅助。

总览:5 种主路由类型

类型写法示例URL
Index 路由(name).page.tsindex.page.ts(home).page.ts/
静态路由name.page.tsabout.page.ts/about
嵌套静态parent/child.page.tsparent.child.page.tsabout/team.page.ts/about/team
路由组(group)/page.ts(不影响 URL)(auth)/login.page.ts/login
动态路由[param].page.ts[productId].page.ts/:productId
Layout 路由parent 同名 + 子文件夹products.page.ts + products//products + /products/*
Pathless Layout(group).page.ts + (group)/(auth).page.ts + (auth)/共享 layout 但不加 URL
Catch-all[...name].page.ts[...not-found].page.ts** (404)

Index 路由

首页用 (home).page.tsindex.page.ts

ts
// src/app/pages/(home).page.ts → /
import { Component } from "@angular/core";

@Component({
  template: `<h2>Welcome Home</h2>`,
})
export default class HomePageComponent {}

两种写法等价(home) 括号语法 vs index 直接命名——团队约定二选一即可。(home) 可读性更好(一眼看出是首页),index 更对齐其他元框架的命名习惯(Next.js / Nuxt)。

静态路由

ts
// src/app/pages/about.page.ts → /about
import { Component } from "@angular/core";

@Component({
  template: `<h2>About</h2>`,
})
export default class AboutPageComponent {}

嵌套静态路由

两种语法等价:

treeview
# 方式 1:文件夹嵌套
src/app/pages/
├── about/
│   ├── (about-home).page.ts    → /about
│   ├── team.page.ts            → /about/team
│   └── contact.page.ts         → /about/contact

# 方式 2:点号
src/app/pages/
├── about.page.ts               → /about
├── about.team.page.ts          → /about/team
└── about.contact.page.ts       → /about/contact

选择策略

  • 需要共享 layout → 文件夹嵌套(配合 parent about.page.ts
  • 只是路径分组、无共享布局 → 点号写法更扁平
  • 混合使用合法:可以在同一项目中按需选择

路由组(不影响 URL)

(group)/ 括号文件夹是「逻辑分组」——文件夹存在但不出现在 URL 中:

treeview
src/
└── app/
    └── pages/
        └── (auth)/
            ├── login.page.ts      → /login
            ├── signup.page.ts     → /signup
            └── forgot.page.ts     → /forgot

用途

  • 按业务域组织文件((auth) / (marketing) / (dashboard)
  • 与同名 Pathless Layout (auth).page.ts 配合共享布局
  • 不修改 URL 路径——访问 /login 而非 /auth/login

动态路由

[xxx] 方括号语法:

ts
// src/app/pages/products/[productId].page.ts → /products/:productId
import { Component, inject } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { AsyncPipe } from "@angular/common";
import { map } from "rxjs";

@Component({
  imports: [AsyncPipe],
  template: `
    <h2>Product</h2>
    <p>ID: {{ id$ | async }}</p>
  `,
})
export default class ProductPage {
  private route = inject(ActivatedRoute);
  id$ = this.route.paramMap.pipe(map((p) => p.get("productId")));
}

Angular 模板的插值(如 {{ id$ | async }})在 VitePress 内联反引号中需要用 <span v-pre> 包裹,避免被 Vue 渲染。代码块内安全无需处理。

Component Input Bindings(推荐写法)

启用 withComponentInputBinding() 后,参数变成 Input:

ts
// src/app/app.config.ts
import { provideFileRouter } from "@analogjs/router";
import { withComponentInputBinding } from "@angular/router";

providers: [provideFileRouter(withComponentInputBinding())];
ts
// src/app/pages/products/[productId].page.ts
import { Component, Input } from "@angular/core";

@Component({
  template: `<p>ID: {{ productId }}</p>`,
})
export default class ProductPage {
  @Input() productId!: string;
}

优势

  • 无需注入 ActivatedRoute + 订阅 paramMap
  • 类型推断(多个参数都自动成为 Input)
  • 配合 Server Load 时,load 也是一个 Input(详见后文)

多参数 / 复合参数

treeview
src/app/pages/users/[userId]/posts/[postId].page.ts → /users/:userId/posts/:postId
ts
@Component({...})
export default class PostPage {
  @Input() userId!: string;
  @Input() postId!: string;
}

Layout 路由

核心模式:parent 同名文件 + 同名子文件夹。

treeview
src/
└── app/
    └── pages/
        ├── products/
        │   ├── (products-list).page.ts   → /products
        │   └── [productId].page.ts       → /products/:productId
        └── products.page.ts              ← parent layout(必须有 router-outlet)

parent products.page.ts(layout):

ts
// src/app/pages/products.page.ts
import { Component } from "@angular/core";
import { RouterOutlet } from "@angular/router";

@Component({
  imports: [RouterOutlet],
  template: `
    <h2>Products</h2>
    <nav><a routerLink="/products">All</a></nav>

    <router-outlet />
  `,
})
export default class ProductsLayout {}

子页面 products/(products-list).page.ts

ts
@Component({
  template: `<h3>Products List</h3>`,
})
export default class ProductsListPage {}

关键点

  • parent 名字必须与子文件夹同名products.page.tsproducts/
  • parent 必须 imports: [RouterOutlet] + 模板包含 <router-outlet />
  • 子文件夹下任意 *.page.ts 都继承这个 layout
  • 嵌套 layout 可继续叠加(products/[productId]/edit.page.ts 也可以再有 products/[productId].page.ts 作为子 layout)

Pathless Layout

(group).page.ts + (group)/ —— 共享 layout 但不增加 URL 路径段:

treeview
src/
└── app/
    └── pages/
        ├── (auth)/
        │   ├── login.page.ts      → /login (layout: (auth).page.ts)
        │   └── signup.page.ts     → /signup (layout: (auth).page.ts)
        └── (auth).page.ts         ← 共享布局

(auth).page.ts

ts
@Component({
  imports: [RouterOutlet],
  template: `
    <div class="auth-shell">
      <header><h1>Auth</h1></header>
      <main><router-outlet /></main>
    </div>
  `,
})
export default class AuthLayout {}

含义/login/signup 共享 AuthLayout 包裹,但路径不包含 auth

Catch-all 路由

[...xxx] 三个点匹配任意剩余路径(对应 Angular Router 的 **):

ts
// src/app/pages/[...not-found].page.ts → 通配符 **
import { Component } from "@angular/core";
import { RouterLink } from "@angular/router";
import { injectResponse } from "@analogjs/router/tokens";
import type { RouteMeta } from "@analogjs/router";

export const routeMeta: RouteMeta = {
  title: "Page Not Found",
  canActivate: [
    () => {
      const response = injectResponse();
      if (import.meta.env.SSR && response) {
        // 在 SSR 时设置 HTTP 404 状态码
        response.statusCode = 404;
        response.end();
      }
      return true;
    },
  ],
};

@Component({
  imports: [RouterLink],
  template: `
    <h2>404 - Page Not Found</h2>
    <a routerLink="/">Go Home</a>
  `,
})
export default class NotFoundPage {}

关键点

  • 必须返回 HTTP 404 状态码(否则爬虫认为页面成功)
  • 嵌套 catch-all 也合法:/products/[...invalid].page.ts 处理 /products/foo/bar/baz
  • 通常配合 Markdown:src/app/pages/[...not-found].md 也可以

完整路由表示例

对于下列文件结构:

treeview
src/
└── app/
    └── pages/
        ├── (auth)/
        │   ├── login.page.ts
        │   └── signup.page.ts
        ├── (marketing)/
        │   ├── about.md
        │   └── contact.md
        ├── products/
        │   ├── (product-list).page.ts
        │   ├── [productId].edit.page.ts
        │   └── [productId].page.ts
        ├── (auth).page.ts
        ├── (home).page.ts
        ├── [...not-found].md
        └── products.page.ts

Analog 生成的路由表:

路径页面共享 Layout
/(home).page.ts
/about(marketing)/about.md
/contact(marketing)/contact.md
/login(auth)/login.page.ts(auth).page.ts
/signup(auth)/signup.page.ts(auth).page.ts
/productsproducts/(product-list).page.tsproducts.page.ts
/products/1products/[productId].page.tsproducts.page.ts
/products/1/editproducts/[productId].edit.page.tsproducts.page.ts
/unknown[...not-found].md

withExtraRoutes 手动追加路由

需要混入手动定义的路由(如来自老代码或第三方库):

ts
// src/app/app.config.ts
import { ApplicationConfig } from "@angular/core";
import { Routes } from "@angular/router";
import { provideFileRouter, withExtraRoutes } from "@analogjs/router";

const customRoutes: Routes = [
  {
    path: "custom",
    loadComponent: () =>
      import("./custom-component").then((m) => m.CustomComponent),
  },
];

export const appConfig: ApplicationConfig = {
  providers: [provideFileRouter(withExtraRoutes(customRoutes))],
};

withExtraRoutes 的路由会前置合并到自动发现的路由数组——优先级更高。

withDebugRoutes 可视化

ts
import { provideFileRouter, withDebugRoutes } from "@analogjs/router";

providers: [provideFileRouter(withDebugRoutes())];

访问 /__analog/routes 看完整路由表(含每条路由对应的文件、layout、是否动态等)。

不要在生产打包时启用 withDebugRoutes()——可以通过 mode === 'development' 条件加载:

ts
import.meta.env.DEV ? withDebugRoutes() : (() => () => {})()

Server Routes(API + Nitro)

Analog 的服务端能力由 Nitro + h3 提供。所有 server 代码放在 src/server/,文件即路由。

目录结构

src/
└── server/
    ├── routes/
    │   └── api/                       ← 所有文件 → /api/* 路径
    │       ├── hello.ts               → GET/POST/... /api/hello
    │       ├── users/
    │       │   └── [id].get.ts        → GET /api/users/:id
    │       └── [...].ts               → 兜底所有 /api/* 未匹配请求
    └── middleware/                    ← 中间件
        └── 1.auth.ts                  → 数字前缀控制顺序

基础 API Route

ts
// src/server/routes/api/hello.ts → /api/hello
import { defineEventHandler } from "h3";

export default defineEventHandler(() => ({
  message: "Hello World",
  timestamp: Date.now(),
}));

启动 dev server 后,访问 http://localhost:5173/api/hello

json
{ "message": "Hello World", "timestamp": 1747800000000 }

动态 API 参数

[name] 方括号语法,getRouterParamevent.context.params 读取:

ts
// src/server/routes/api/v1/hello/[name].ts → /api/v1/hello/:name
import { defineEventHandler, getRouterParam } from "h3";

export default defineEventHandler((event) => {
  const name = getRouterParam(event, "name");
  return `Hello, ${name}!`;
});

或:

ts
export default defineEventHandler((event) => {
  return `Hello, ${event.context.params?.["name"]}!`;
});

HTTP 方法后缀

.get.ts / .post.ts / .put.ts / .patch.ts / .delete.ts 限定方法:

ts
// src/server/routes/api/v1/users/[id].get.ts
import { defineEventHandler, getRouterParam } from "h3";

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, "id");
  // TODO: fetch user by id from DB
  return { id, name: "Alice" };
});
ts
// src/server/routes/api/v1/users.post.ts
import { defineEventHandler, readBody } from "h3";

export default defineEventHandler(async (event) => {
  const body = await readBody<{ name: string; email: string }>(event);
  // TODO: insert into DB
  return { id: Date.now(), ...body };
});

关键点

  • .get.ts / .post.ts 等只匹配对应方法——访问错误方法返回 405
  • 没有后缀的文件:默认处理所有方法(GET / POST / PUT / DELETE)
  • 同一资源 GET + POST:同一目录下两个文件 users.get.ts + users.post.ts

查询参数 getQuery

ts
// src/server/routes/api/v1/search.ts → /api/v1/search?q=...&page=...
import { defineEventHandler, getQuery } from "h3";

export default defineEventHandler((event) => {
  const { q, page = "1" } = getQuery<{ q: string; page?: string }>(event);
  return {
    query: q,
    page: parseInt(page),
    results: [],
  };
});

Catch-all API

[...].ts 兜底所有未匹配请求:

ts
// src/server/routes/api/[...].ts
import { defineEventHandler } from "h3";

export default defineEventHandler((event) => {
  return { error: "Not Found", path: event.path };
});

错误处理 createError

未捕获错误返回 500,要返回特定状态码用 createError

ts
// src/server/routes/api/v1/products/[id].ts
import { defineEventHandler, getRouterParam, createError } from "h3";

export default defineEventHandler((event) => {
  const param = getRouterParam(event, "id");
  const id = parseInt(param ?? "");

  if (!Number.isInteger(id)) {
    throw createError({
      statusCode: 400,
      statusMessage: "ID must be an integer",
    });
  }

  if (id > 100) {
    throw createError({
      statusCode: 404,
      statusMessage: "Product not found",
    });
  }

  return { id, name: `Product ${id}` };
});

XML / RSS 输出

设置 content-typetext/xml

ts
// src/server/routes/api/rss.xml.ts
import { defineEventHandler, setHeader } from "h3";

export default defineEventHandler((event) => {
  const feed = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>My Blog</title>
    <link>https://example.com</link>
    <description>My blog posts</description>
  </channel>
</rss>`;

  setHeader(event, "content-type", "text/xml");
  return feed;
});

配合 SSG:要让 /api/rss.xml 在构建时预渲染为静态文件,将其加入 vite.config.tsprerender.routes

ts
analog({
  prerender: {
    routes: async () => ["/", "/api/rss.xml"],
  },
});

构建后产物在 dist/analog/public/api/rss.xml

ts
// 设置 cookie
import { setCookie } from "h3";

export default defineEventHandler((event) => {
  setCookie(event, "sessionId", "abc123", {
    httpOnly: true,
    secure: true,
    maxAge: 60 * 60 * 24, // 1 day
    sameSite: "lax",
  });
  return { ok: true };
});
ts
// 读取 cookies
import { parseCookies } from "h3";

export default defineEventHandler((event) => {
  const cookies = parseCookies(event);
  const sessionId = cookies["sessionId"];
  return { sessionId };
});

WebSocket(实验性)

需在 vite.config.ts 中启用:

ts
// vite.config.ts
analog({
  nitro: {
    experimental: {
      websocket: true,
    },
  },
});

注意 HMR 端口冲突:Vite dev server 的 HMR WebSocket 默认与主服务器同端口。要避免冲突,移除 angular.json / project.json 中的 port 配置,让 vite.config.ts 中的配置生效:

ts
server: {
  port: 3000,
  hmr: {
    port: 3002,
    path: "vite-hmr",
  },
}

定义 WebSocket handler:

ts
// src/server/routes/api/ws/chat.ts → ws://example.com/api/ws/chat
import { defineWebSocketHandler } from "h3";

export default defineWebSocketHandler({
  open(peer) {
    peer.send({ user: "server", message: `Welcome ${peer}!` });
    peer.publish("chat", { user: "server", message: `${peer} joined!` });
    peer.subscribe("chat");
  },
  message(peer, message) {
    if (message.text().includes("ping")) {
      peer.send({ user: "server", message: "pong" });
    } else {
      const msg = { user: peer.toString(), message: message.toString() };
      peer.send(msg);
      peer.publish("chat", msg);
    }
  },
  close(peer) {
    peer.publish("chat", { user: "server", message: `${peer} left!` });
  },
});

Server-Sent Events(SSE)

ts
// src/server/routes/api/sse.ts
import { defineEventHandler, createEventStream } from "h3";

export default defineEventHandler(async (event) => {
  const eventStream = createEventStream(event);

  const interval = setInterval(async () => {
    await eventStream.push(`Message @ ${new Date().toLocaleTimeString()}`);
  }, 1000);

  eventStream.onClosed(async () => {
    clearInterval(interval);
    await eventStream.close();
  });

  return eventStream.send();
});

客户端用浏览器原生 EventSource

ts
const sse = new EventSource("/api/sse");
sse.onmessage = (e) => console.log("received:", e.data);
sse.onerror = () => sse.close();

Server-Side Data Fetching

页面进入前的服务端数据加载——.server.ts 文件中导出 async load 函数。

基础写法

ts
// src/app/pages/blog/index.server.ts
import type { PageServerLoad } from "@analogjs/router";

export const load = async ({
  params, // 路由参数
  req, // H3 Request
  res, // H3 Response
  fetch, // 内置 fetch
  event, // 完整 H3 event
}: PageServerLoad) => {
  const posts = await fetch<Post[]>("/api/v1/posts");
  return { posts };
};

interface Post {
  id: number;
  title: string;
  excerpt: string;
}

组件中通过 injectLoad 接收:

ts
// src/app/pages/blog/index.page.ts
import { Component } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { injectLoad } from "@analogjs/router";

import type { load } from "./index.server"; // 仅类型导入

@Component({
  template: `
    <h1>Blog</h1>
    @if (data(); as d) {
      @for (post of d.posts; track post.id) {
        <article>
          <h3>{{ post.title }}</h3>
          <p>{{ post.excerpt }}</p>
        </article>
      }
    }
  `,
})
export default class BlogIndexPage {
  data = toSignal(injectLoad<typeof load>(), { requireSync: true });
}

核心约束

  • *.server.ts 不会进入 client bundle——里面可以用 Node API、数据库客户端、敏感 keys
  • 必须 import type 导入 load——确保是纯类型引用
  • injectLoad<typeof load>() 返回 Observable<LoadResult<typeof load>>,配合 toSignal 转为 Signal
  • { requireSync: true }:路由 resolver 在组件实例化前已完成,可同步访问,避免初始 undefined
  • 内置 fetch 直接调用内部 API(在 SSR 时绕过 HTTP,直接 Nitro 内部路由)

用 Component Input Bindings 接收

启用 withComponentInputBinding() 后:

ts
import { Component, Input } from "@angular/core";
import type { LoadResult } from "@analogjs/router";
import type { load } from "./index.server";

@Component({
  template: `
    @for (post of data?.posts ?? []; track post.id) {
      <article>{{ post.title }}</article>
    }
  `,
})
export default class BlogIndexPage {
  @Input() set load(value: LoadResult<typeof load>) {
    this.data = value;
  }
  data?: LoadResult<typeof load>;
}

getLoadResolver:在 RouteMeta resolver 中访问 load

如果你已经有 RouteMeta.resolve resolver,想复用 load 的数据:

ts
import type { RouteMeta } from "@analogjs/router";
import { getLoadResolver } from "@analogjs/router";

export const routeMeta: RouteMeta = {
  resolve: {
    extra: async (route) => {
      // 获取本路由的 load 数据
      const loadData = await getLoadResolver(route);
      return {
        ...loadData,
        timestamp: Date.now(),
      };
    },
  },
};

Server Context 注入

在 server 端访问 Request / Response / Base URL:

ts
// src/app/some.service.ts(在 SSR + client 都可注入)
import { Injectable } from "@angular/core";
import {
  injectRequest,
  injectResponse,
  injectBaseURL,
} from "@analogjs/router/tokens";

@Injectable({ providedIn: "root" })
export class MyService {
  request = injectRequest(); // H3 Request 对象(仅 SSR 时有值)
  response = injectResponse(); // H3 Response 对象(仅 SSR 时有值)
  baseUrl = injectBaseURL(); // server base URL,如 "https://example.com"
}

在 client 端,三者均为 null ——服务端独占数据应在 *.server.ts 中处理。

requestContextInterceptor:相对 URL 自动绝对化

HttpClient 用相对路径 /api/foo 时,SSR 阶段没有 host header 会失败。requestContextInterceptor 自动加 host:

ts
// src/app/app.config.ts
import {
  provideHttpClient,
  withFetch,
  withInterceptors,
} from "@angular/common/http";
import { requestContextInterceptor } from "@analogjs/router";

providers: [
  provideHttpClient(
    withFetch(),
    withInterceptors([
      // 其他拦截器(如 auth、logging)...
      requestContextInterceptor, // ⚠️ 必须放最后
    ])
  ),
];

requestContextInterceptor 必须放数组最后——它在最末层加 host,其他拦截器(如 auth)在前面修改请求。

TransferState 自动缓存

HttpClient 在 SSR 阶段的所有请求会通过 Angular TransferState 序列化到 HTML,client hydration 后直接读缓存——不重复请求。无需手动配置

覆盖默认 base URL

如部署在子路径或不同域名下,设置环境变量:

bash
# .env
VITE_ANALOG_PUBLIC_BASE_URL="http://localhost:5173"
bash
# 生产
VITE_ANALOG_PUBLIC_BASE_URL="https://api.example.com"

Form Server Actions

仿 Remix / SvelteKit / SolidStart 的表单提交模式——.server.ts 中导出 async action,配合 FormAction 指令。

基础写法

页面:

ts
// src/app/pages/newsletter.page.ts
import { Component, signal } from "@angular/core";
import { FormAction } from "@analogjs/router";

type FormErrors = { email?: string } | undefined;

@Component({
  imports: [FormAction],
  template: `
    @if (!signedUp()) {
      <form
        method="post"
        (onSuccess)="onSuccess()"
        (onError)="onError($any($event))"
        (onStateChange)="errors.set(undefined)"
      >
        <input type="email" name="email" />

        @if (errors()?.email) {
          <p class="error">{{ errors()?.email }}</p>
        }

        <button type="submit">Subscribe</button>
      </form>
    } @else {
      <p>Thanks for signing up!</p>
    }
  `,
})
export default class NewsletterPage {
  signedUp = signal(false);
  errors = signal<FormErrors>(undefined);

  onSuccess() {
    this.signedUp.set(true);
  }

  onError(result?: FormErrors) {
    this.errors.set(result);
  }
}

服务端 action:

ts
// src/app/pages/newsletter.server.ts
import {
  type PageServerAction,
  redirect,
  json,
  fail,
} from "@analogjs/router/server/actions";
import { readFormData } from "h3";

export async function action({ event }: PageServerAction) {
  const body = await readFormData(event);
  const email = body.get("email") as string;

  // 校验
  if (!email) {
    return fail(422, { email: "Email is required" });
  }
  if (!email.includes("@")) {
    return fail(422, { email: "Invalid email format" });
  }

  // 业务逻辑(DB / 邮件 / etc.)
  console.log("Subscribed:", email);

  return json({ type: "success" });
}

核心三件套

函数用途返回值
json(data)返回成功响应标记为 success,组件 (onSuccess) 触发
redirect(path)重定向浏览器导航到 path(必须是绝对路径)
fail(status, data)校验错误标记为 error,组件 (onError) 触发,data 透传

FormAction 指令的三个事件

事件触发时机
(onSuccess)server 返回 json(...)
(onError)server 返回 fail(status, data) 或异常时
(onStateChange)表单 submit 开始时(用于清空旧错误)

Progressive Enhancement(JS 禁用也能用)

FormAction 是 progressive enhancement:

  • JS 启用:fetch 提交 + 事件回调,不刷新页面
  • JS 禁用:原生 <form method="post"> 提交,浏览器整页刷新——依然能完成

服务端代码完全相同,UX 自动降级。

多表单同页

<input type="hidden" name="action" value="..."> 区分:

html
<form method="post">
  <input type="email" name="email" />
  <input type="hidden" name="action" value="subscribe" />
  <button type="submit">Subscribe</button>
</form>

<form method="post">
  <input type="text" name="search" />
  <input type="hidden" name="action" value="search" />
  <button type="submit">Search</button>
</form>
ts
export async function action({ event }: PageServerAction) {
  const body = await readFormData(event);
  const action = body.get("action") as string;

  switch (action) {
    case "subscribe": {
      const email = body.get("email") as string;
      // ...
      return json({ ok: true });
    }
    case "search": {
      const q = body.get("search") as string;
      return redirect(`/search?q=${encodeURIComponent(q)}`);
    }
    default:
      return fail(400, { error: "Unknown action" });
  }
}

GET 表单(搜索)

GET 表单 + query 参数 + Server Load 一起用:

ts
// src/app/pages/search.page.ts
import { Component, computed } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { injectLoad, FormAction } from "@analogjs/router";

import type { load } from "./search.server";

@Component({
  imports: [FormAction],
  template: `
    <form method="get">
      <input type="text" name="search" [value]="searchTerm()" />
      <button type="submit">Search</button>
    </form>

    @if (searchTerm()) {
      <p>Search Term: {{ searchTerm() }}</p>
    }
  `,
})
export default class SearchPage {
  loader = toSignal(injectLoad<typeof load>(), { requireSync: true });
  searchTerm = computed(() => this.loader().searchTerm);
}
ts
// src/app/pages/search.server.ts
import type { PageServerLoad } from "@analogjs/router";
import { getQuery } from "h3";

export async function load({ event }: PageServerLoad) {
  const query = getQuery(event);
  const search = (query["search"] as string) ?? "";

  return {
    loaded: true,
    searchTerm: search,
  };
}

GET 表单不需要 action() server 函数——浏览器把表单数据作为 query string 拼到当前 URL,load() 通过 getQuery(event) 读取。

Route Metadata(RouteMeta

每个 *.page.ts 文件可同时导出 routeMeta,传给 Angular Router:

ts
import type { RouteMeta } from "@analogjs/router";
import { inject } from "@angular/core";

import { AboutService } from "./about.service";

export const routeMeta: RouteMeta = {
  title: "About Analog",
  canActivate: [() => true],
  providers: [AboutService],
};

@Component({...})
export default class AboutPage {
  private service = inject(AboutService);
}

RouteMeta 主要字段

字段类型用途
titlestring | ResolveFn<string>页面 <title>
metaMetaTag[] | ResolveFn<MetaTag[]>meta 标签
canActivateCanActivateFn[]路由守卫
canDeactivateCanDeactivateFn[]离开守卫
resolveRecord<string, ResolveFn>Angular Router resolver
providersProvider[]该路由专属 providers
redirectTostring重定向目标
pathMatch'full' | 'prefix'重定向匹配模式

重定向路由

只重定向不渲染:

ts
// src/app/pages/index.page.ts
import type { RouteMeta } from "@analogjs/router";

export const routeMeta: RouteMeta = {
  redirectTo: "/home",
  pathMatch: "full",
};

// ⚠️ 不要 export default class —— 纯重定向文件不需要组件

嵌套重定向:

ts
// src/app/pages/cities/index.page.ts
export const routeMeta: RouteMeta = {
  redirectTo: "/cities/new-york",
  pathMatch: "full",
};

嵌套重定向 redirectTo 必须是绝对路径/cities/new-york 而非 new-york)—— Angular Router 限制。

Meta 标签

ts
export const routeMeta: RouteMeta = {
  title: "Refresh every 30s",
  meta: [
    {
      httpEquiv: "refresh",
      content: "30",
    },
    {
      name: "description",
      content: "This page refreshes every 30 seconds",
    },
  ],
};

输出到 <head>

html
<meta http-equiv="refresh" content="30" />
<meta name="description" content="This page refreshes every 30 seconds" />

Open Graph + SEO

ts
export const routeMeta: RouteMeta = {
  title: "My Blog Post",
  meta: [
    { name: "description", content: "Article description" },
    { name: "author", content: "Analog Team" },
    { property: "og:title", content: "My Blog Post" },
    { property: "og:description", content: "Article description" },
    { property: "og:image", content: "https://example.com/cover.png" },
    { property: "og:url", content: "https://example.com/blog/post-1" },
    { property: "og:type", content: "article" },
    { name: "twitter:card", content: "summary_large_image" },
  ],
};

动态 meta(基于数据)

ts
// src/app/pages/blog/posts.[slug].page.ts
import type { RouteMeta } from "@analogjs/router";
import type { ResolveFn } from "@angular/router";
import { inject } from "@angular/core";

import { injectActivePostAttributes } from "@analogjs/content";

const postTitleResolver: ResolveFn<string> = (route) => {
  const attrs = injectActivePostAttributes(route);
  return attrs.title;
};

const postMetaResolver: ResolveFn<MetaTag[]> = (route) => {
  const attrs = injectActivePostAttributes(route);
  return [
    { name: "description", content: attrs.description },
    { property: "og:title", content: attrs.title },
    { property: "og:description", content: attrs.description },
    { property: "og:image", content: attrs.coverImage },
  ];
};

export const routeMeta: RouteMeta = {
  title: postTitleResolver,
  meta: postMetaResolver,
};

Middleware(服务端中间件)

服务端请求级中间件——src/server/middleware/*.ts

ts
// src/server/middleware/1.auth.ts
import {
  defineEventHandler,
  getRequestURL,
  parseCookies,
  sendRedirect,
} from "h3";

export default defineEventHandler(async (event) => {
  const pathname = getRequestURL(event).pathname;

  // 仅对 /admin/* 路径生效
  if (pathname.startsWith("/admin")) {
    const cookies = parseCookies(event);
    const token = cookies["authToken"];

    if (!token) {
      return sendRedirect(event, "/login", 401);
    }
  }
});

关键约束

  • 中间件只能修改请求 / 返回 redirect,不能正常 return 数据(其他 handler 会接管)
  • 文件名顺序执行(数字前缀 1. 2. 99. 控制顺序)
  • 必须将中间件目录加入 tsconfig.app.jsoninclude
    json
    {
      "include": [
        "src/**/*.d.ts",
        "src/app/pages/**/*.page.ts",
        "src/server/middleware/**/*.ts"
      ]
    }

设置 headers

ts
import { defineEventHandler, setHeaders } from "h3";

export default defineEventHandler((event) => {
  setHeaders(event, {
    "x-powered-by": "Analog",
    "x-frame-options": "DENY",
  });
});

访问环境变量

process.env 在中间件中可用:

ts
import { defineEventHandler } from "h3";

export default defineEventHandler((event) => {
  console.log("Path:", event.path);
  console.log("Server-only var:", process.env["SERVER_SECRET"]);
  console.log("Public var:", process.env["VITE_PUBLIC_API_KEY"]);
});

环境变量约定VITE_* 前缀对 client 可见,无前缀仅 server 可访问。详见 Vite 文档

Markdown Content Routes

Analog 内置 @analogjs/content 包,支持把 Markdown 作为路由 / 集合 / 内嵌内容。

启用

ts
// src/app/app.config.ts
import { provideContent, withMarkdownRenderer } from "@analogjs/content";

providers: [provideContent(withMarkdownRenderer())];
ts
// vite.config.ts
analog({
  content: {
    highlighter: "prism", // 或 "shiki"
  },
});

Markdown 作为路由

md
<!-- src/app/pages/about.md → /about -->
---
title: About
meta:
  - name: description
    content: About this site
---

## About

Welcome to Analog.

[Back Home](./)

访问 /about 直接渲染——支持 Frontmatter / 代码高亮 / 链接 / 图片 / 表格等。

嵌入 Markdown 到组件(injectContent

适合博客详情页这种「读单个 markdown 文件 + Angular 组件 + 自定义模板」场景:

treeview
src/
├── app/
│   └── pages/
│       └── blog/
│           └── posts.[slug].page.ts
└── content/
    ├── 2026-01-01-hello.md
    └── 2026-02-15-world.md
md
<!-- src/content/2026-01-01-hello.md -->
---
title: Hello World
slug: 2026-01-01-hello
description: My first post
coverImage: https://images.unsplash.com/photo-xxx
---

This is my first post.

\`\`\`ts
const greet = (name: string) => `Hello, \${name}!`;
\`\`\`

页面:

ts
// src/app/pages/blog/posts.[slug].page.ts
import { Component } from "@angular/core";
import { AsyncPipe } from "@angular/common";
import { injectContent, MarkdownComponent } from "@analogjs/content";

export interface PostAttributes {
  title: string;
  slug: string;
  description: string;
  coverImage: string;
}

@Component({
  imports: [MarkdownComponent, AsyncPipe],
  template: `
    @if (post$ | async; as post) {
      <article>
        <h1>{{ post.attributes.title }}</h1>
        <img [src]="post.attributes.coverImage" alt="" />
        <analog-markdown [content]="post.content" />
      </article>
    }
  `,
})
export default class BlogPostPage {
  post$ = injectContent<PostAttributes>(); // 默认按 `slug` 路由参数读
}

列出多篇 Markdown(injectContentFiles

ts
// src/app/pages/blog/(blog-list).page.ts
import { Component } from "@angular/core";
import { RouterLink } from "@angular/router";
import { injectContentFiles } from "@analogjs/content";

import type { PostAttributes } from "./posts.[slug].page";

@Component({
  imports: [RouterLink],
  template: `
    <h1>Blog</h1>
    <ul>
      @for (post of posts; track post.slug) {
        <li>
          <a [routerLink]="['/blog', 'posts', post.attributes.slug]">
            {{ post.attributes.title }}
          </a>
        </li>
      } @empty {
        <li>No posts yet.</li>
      }
    </ul>
  `,
})
export default class BlogListPage {
  posts = injectContentFiles<PostAttributes>((file) =>
    file.filename.includes("/src/content/")
  );
}

injectContentFiles 仅返回 metadatafilename / slug / attributes)—— 不加载 body 内容。如要读 body,对每个文件单独调用 injectContent 或在详情页中加载。

子目录组织

treeview
src/
└── content/
    ├── posts/
    │   ├── hello.md
    │   └── world.md
    └── projects/
        ├── project-a.md
        └── project-b.md
ts
readonly post$ = injectContent<PostAttributes>({
  param: "slug",
  subdirectory: "posts",
});

readonly project$ = injectContent<ProjectAttributes>({
  param: "slug",
  subdirectory: "projects",
});

递归子目录 + Catch-all 路由

复杂文档站需要任意层级的 Markdown 路由:

treeview
src/
├── app/pages/docs/[...slug].page.ts
└── content/docs/
    ├── getting-started/
    │   ├── welcome.md
    │   └── first-upload.md
    └── assets/
        └── upload.md
ts
// src/app/pages/docs/[...slug].page.ts
@Component({...})
export default class DocPage {
  doc$ = injectContent({
    param: "slug",
    subdirectory: "docs",
  });
}

请求 /docs/getting-started/welcome 自动解析到 src/content/docs/getting-started/welcome.md

Prism 高亮

ts
// app.config.ts
import { withPrismHighlighter } from "@analogjs/content/prism-highlighter";

provideContent(withMarkdownRenderer(), withPrismHighlighter());
ts
// vite.config.ts
analog({
  content: {
    highlighter: "prism",
    prismOptions: {
      additionalLangs: ["prism-diff", "prism-bash", "prism-yaml"],
    },
  },
});

样式:

css
/* src/styles.css */
@import "prismjs/themes/prism.css";
@import "prismjs/plugins/diff-highlight/prism-diff-highlight.css";

Shiki 高亮(推荐生产)

更现代的语法高亮,VSCode 同款:

ts
// app.config.ts
import { withShikiHighlighter } from "@analogjs/content/shiki-highlighter";

provideContent(withMarkdownRenderer(), withShikiHighlighter());
ts
// vite.config.ts
analog({
  content: {
    highlighter: "shiki",
    shikiOptions: {
      highlight: {
        theme: "github-dark",
      },
      highlighter: {
        additionalLangs: ["diff", "yaml"],
        themes: ["github-dark", "github-light"],
      },
    },
  },
});

Mermaid 图表

ts
// app.config.ts
provideContent(
  withMarkdownRenderer({
    loadMermaid: () => import("mermaid"),
  })
);

启用后,markdown 中:

md
```mermaid
graph TD
    A[Browser] -->|HTTP| B(Nitro)
    B -->|Render| C{Analog}
```

会自动渲染为 SVG。

SSR:服务端渲染

Analog 默认启用 SSR——provideClientHydration() 已在 app.config.ts 中配置。

不兼容 SSR 的依赖

某些 Angular 第三方库(如 Apollo / Material 部分组件 / Spartan)在 SSR 环境下报错。添加到 ssr.noExternal

ts
// vite.config.ts
export default defineConfig({
  ssr: {
    noExternal: [
      "apollo-angular",
      "apollo-angular/**",
      "@spartan-ng/**",
      // glob 也支持
    ],
  },
  plugins: [analog()],
});

详见 Vite SSR Externals 文档

Hybrid Rendering:部分路由仅 client

通过 routeRules 配置某些路由只在 client 渲染:

ts
// vite.config.ts
analog({
  prerender: {
    routes: ["/", "/404.html"],
  },
  nitro: {
    routeRules: {
      // /admin/* 都不 SSR
      "/admin/**": { ssr: false },
      // 404 页面是 fallback
      "/404.html": { ssr: false },
    },
  },
});

应用场景

  • 后台管理页面(用户已登录后才访问,无需 SSR)
  • 高度交互的页面(地图 / 富文本编辑器)
  • 包含浏览器专属 API 的页面(WebGL / WebAudio)

完全关闭 SSR

ts
// vite.config.ts
analog({
  ssr: false,
  prerender: {
    routes: [],
  },
});

此时项目是纯 client SPA——构建产物只有 dist/analog/public,无 dist/analog/server

SSG:静态站点生成

构建时预渲染静态 HTML——同一份代码 SSR + SSG 切换。

显式列出路由

ts
// vite.config.ts
analog({
  prerender: {
    routes: async () => [
      "/",
      "/about",
      "/blog",
      "/blog/posts/2026-01-01-hello",
    ],
  },
});

从 Content 目录批量预渲染

针对博客 / 文档站,遍历整个内容目录:

ts
import analog, { type PrerenderContentFile } from "@analogjs/platform";

analog({
  prerender: {
    routes: async () => [
      "/",
      "/blog",
      {
        contentDir: "src/content/blog",
        transform: (file: PrerenderContentFile) => {
          // 跳过 draft
          if (file.attributes.draft) {
            return false;
          }
          const slug = file.attributes.slug || file.name;
          return `/blog/${slug}`;
        },
      },
    ],
  },
});

transform 返回值含义:

  • string → 该路径加入预渲染列表
  • false → 跳过该文件

递归子目录(recursive: true

默认只匹配顶层文件。要遍历嵌套目录:

ts
analog({
  prerender: {
    routes: async () => [
      "/",
      {
        contentDir: "src/content/docs",
        recursive: true,
        transform: (file: PrerenderContentFile) => {
          const slug = file.attributes.slug || file.name;
          // file.relativePath 是文件相对 contentDir 的目录路径
          return file.relativePath
            ? `/docs/${file.relativePath}/${slug}`
            : `/docs/${slug}`;
        },
      },
    ],
  },
});

纯静态构建(无 server)

ts
analog({
  static: true,
  prerender: {
    routes: async () => [
      "/",
      "/about",
      "/blog",
      // SPA fallback
      "/404.html",
    ],
  },
  nitro: {
    routeRules: {
      "/404.html": { ssr: false },
    },
  },
});

ssr 必须仍为 true(默认)——static: true 表示只输出预渲染产物,不打包 server runtime。

构建产物全在 dist/analog/public/,可直接部署到任何静态托管(GitHub Pages / S3 / Netlify / CDN)。

Prerender Server-Side Data

*.server.tsload() 返回值一起被预渲染(client 侧不需要再发请求):

ts
analog({
  static: true,
  prerender: {
    routes: async () => [
      "/",
      {
        route: "/shipping",
        staticData: true, // ⚠️ 预渲染 server load 数据
      },
    ],
  },
});

Sitemap 自动生成

ts
analog({
  prerender: {
    routes: async () => ["/", "/blog"],
    sitemap: {
      host: "https://example.com",
    },
  },
});

构建后 dist/analog/public/sitemap.xml 自动生成。

精细控制 lastmod / changefreq / priority

ts
analog({
  prerender: {
    sitemap: { host: "https://example.com" },
    routes: async () => [
      "/",
      "/blog",
      {
        route: "/blog/post-1",
        sitemap: { lastmod: "2026-01-01" },
      },
      {
        contentDir: "/src/content/archived",
        transform: (file) => `/archived/${file.attributes.slug}`,
        sitemap: (file) => ({
          lastmod: file.attributes.updatedAt,
          changefreq: "never",
        }),
      },
    ],
  },
});

Post-rendering Hooks

构建时对每个 HTML 文件做后处理:

ts
import type { PrerenderRoute } from "nitropack";

analog({
  static: true,
  prerender: {
    routes: async () => ["/", "/aboutus"],
    postRenderingHooks: [
      async (route: PrerenderRoute) => {
        if (route.route === "/aboutus") {
          const gTag = `<script>...</script>`;
          route.contents = route.contents?.concat(gTag);
        }
      },
    ],
  },
});

用途

  • 内联 critical CSS
  • 注入 GA / Plausible / 第三方统计
  • 删除特定 script 标签
  • 修改 meta tags

Nitro Adapter:多端部署

同一份代码部署到 17+ 个平台——通过 Nitro preset 选择。

默认 Node.js

bash
npm run build
node dist/analog/server/index.mjs
# Listening on http://localhost:3000

可配置环境变量:

  • NITRO_PORTPORT(默认 3000)
  • NITRO_HOSTHOST

常用 Preset 列表

Preset平台配置方式
node-serverNode.js standalone(默认)默认
vercelVercel Serverless FunctionsBUILD_PRESET=vercel
vercel-edgeVercel Edge FunctionsBUILD_PRESET=vercel-edge
netlifyNetlify FunctionsBUILD_PRESET=netlify
netlify-edgeNetlify Edge FunctionsBUILD_PRESET=netlify-edge
cloudflare-pagesCloudflare PagesBUILD_PRESET=cloudflare-pages
cloudflareCloudflare WorkersBUILD_PRESET=cloudflare
firebaseFirebase Cloud FunctionsBUILD_PRESET=firebase
aws-lambdaAWS LambdaBUILD_PRESET=aws-lambda
azureAzure FunctionsBUILD_PRESET=azure
deno-serverDeno DeployBUILD_PRESET=deno-server
bunBun runtimeBUILD_PRESET=bun
static纯静态站点static: true
github-pagesGitHub Pages(SSG)BUILD_PRESET=github_pages + 配 .nojekyll
render-comRender.comBUILD_PRESET=render-com
digital-oceanDigitalOcean App PlatformBUILD_PRESET=digital-ocean
edgioEdgioedgio init --connector=@edgio/analogjs
zeropsZerops(官方合作)zerops.yml 文件

切换 Preset 的两种方式

方式 1:环境变量(推荐 CI/CD)

bash
BUILD_PRESET=cloudflare-pages npm run build

方式 2:vite.config.ts

ts
analog({
  nitro: {
    preset: "vercel",
  },
});

Vercel

零配置——push 仓库到 Vercel 即可部署。Vercel 自动检测 Analog 项目。

如未自动识别,手动设置:

ts
// vite.config.ts
analog({
  nitro: { preset: "vercel" },
});

或环境变量 BUILD_PRESET=vercel

Cloudflare Pages

  1. Cloudflare Dashboard → Workers & Pages → Connect to Git
  2. Build Commandnpm run build
  3. Build Output Directorydist/analog/public
  4. 环境变量:(可选)BUILD_PRESET=cloudflare-pages

本地预览:

bash
BUILD_PRESET=cloudflare-pages npm run build
npx wrangler pages dev ./dist/analog/public

Netlify

CLI 模式:

bash
npx netlify init
npx netlify deploy

手动配置:

  • Publish directorydist/analog/public
  • Functions directorynetlify/functions

或在仓库根放 netlify.toml

toml
[build]
  command = "npm run build"
  publish = "dist/analog/public"
  functions = "netlify/functions"

Firebase Hosting + Functions

需要 Blaze 计划(云函数收费)。

bash
firebase init   # 选 Hosting + Functions
firebase deploy

firebase.json

json
{
  "functions": {
    "source": "dist/analog/server"
  },
  "hosting": [
    {
      "public": "dist/analog/public",
      "cleanUrls": true,
      "rewrites": [
        {
          "source": "**",
          "function": "server"
        }
      ]
    }
  ]
}

vite.config.ts

ts
analog({
  nitro: {
    preset: "firebase",
    firebase: {
      nodeVersion: "20",
      gen: 2,
      httpsOptions: {
        region: "us-east1",
        maxInstances: 100,
      },
    },
  },
});

GitHub Pages(SSG)

完全静态。在 gh-pages 分支根放 .nojekyll 文件(避免 Jekyll 处理)。

推荐 GitHub Actions:

yaml
# .github/workflows/deploy.yml
name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20.x"
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-pages-artifact@v3
        with:
          path: dist/analog/public
      - uses: actions/deploy-pages@v4

自定义 URL 前缀(basehref)

部署到 example.com/basehref/ 之类子路径:

  1. vite.config.ts
ts
export default defineConfig(({ mode }) => ({
  base: "/basehref",
  plugins: [
    analog({
      ...(mode === "production"
        ? { apiPrefix: "basehref" }
        : { apiPrefix: "basehref/api" }),
      prerender: { routes: async () => [] },
    }),
  ],
}));
  1. app.config.ts 提供 APP_BASE_HREF
ts
import { APP_BASE_HREF } from "@angular/common";

providers: [
  { provide: APP_BASE_HREF, useValue: import.meta.env.BASE_URL || "/" },
];
  1. HttpClient 用 injectAPIPrefix()
ts
import { injectAPIPrefix } from "@analogjs/router/tokens";

const apiPrefix = injectAPIPrefix();
this.http.get(`${apiPrefix}/v1/hello`);
  1. 生产环境设环境变量:
bash
NITRO_APP_BASE_URL=/basehref/ node dist/analog/server/index.mjs

Astro Angular(Island 架构)

Analog 还提供 @analogjs/astro-angular——让 Angular 组件能作为 Astro Island 使用:

bash
npx astro add @analogjs/astro-angular
astro
---
// src/pages/index.astro
import Counter from "../components/Counter.ts";
---

<html>
  <body>
    <h1>Static Astro</h1>
    <!-- Angular 组件作为 Island,仅可见时激活 -->
    <Counter client:visible />
  </body>
</html>

Counter 是普通 Angular Standalone Component——Astro 在 HTML 中嵌入,仅当用户滚动到该组件可见区域时才下载 Angular runtime + 组件代码并 hydrate(部分 hydration)。

这是 Analog 与众不同的能力——其他 Angular 元框架都做不到「Angular 组件作为 Island」。详见 Astro Angular 集成文档

与 Angular 标准项目共存策略

Analog 不替代 Angular CLI——两者可在同一项目共存:

保留 Angular CLI 命令

bash
ng generate component my-component  # 生成 standalone 组件(Angular 19+ 默认)
ng generate service my-service       # 生成 service
ng update                            # 升级 Angular

angular.json 中所有 builder 已经切到 @analogjs/platform:vite——ng build / ng serve 实际运行的是 Vite。

混入 NgModule(不推荐)

Analog 设计上假设 standalone API——但如果你必须用 NgModule(如使用某老旧第三方库),可以在 (home).page.ts 中导入:

ts
import { Component, NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { LegacyModule } from "legacy-lib";

@Component({
  standalone: true,
  imports: [CommonModule, LegacyModule],
  template: `...`,
})
export default class HomePage {}

建议:尽可能升级到 standalone 版本。NgModule 支持在 Angular 中长期来看会逐步弱化。

@analogjs/platform:vite builder(库项目)

对于 Angular 库项目,可以用 Analog 的 Vite builder:

json
// projects/my-lib/project.json
{
  "architect": {
    "build": {
      "builder": "@analogjs/platform:vite",
      "options": {
        "configFile": "projects/my-lib/vite.config.ts",
        "outputPath": "dist/projects/my-lib"
      }
    }
  }
}
ts
// projects/my-lib/vite.config.ts
import { defineConfig } from "vite";
import angular from "@analogjs/vite-plugin-angular";

export default defineConfig({
  plugins: [angular()],
  build: {
    target: ["esnext"],
    lib: {
      entry: "src/public-api.ts",
      fileName: "fesm2022/my-lib",
      formats: ["es"],
    },
    rollupOptions: {
      external: [/^@angular\/.*/, "rxjs", "rxjs/operators"],
    },
  },
});

详见 构建库指南

Nx workspace

Analog 与 Nx 紧密集成——create-analog 支持生成 Nx 项目:

bash
npm create analog@latest
# 选择 Nx workspace 选项

Nx 中 Analog 项目结构:

apps/
└── my-app/
    ├── src/
    │   ├── app/pages/
    │   └── server/
    ├── vite.config.ts
    └── project.json

构建:

bash
nx build my-app
nx serve my-app
nx test my-app

常见踩坑

1. *.server.ts 中的代码出现在 client bundle

症状:客户端打包后报错 "Module 'fs' not found" 或泄漏 secrets。

原因

  • *.server.ts 文件被 import 而非 import type
  • 在非 *.server.ts 文件中直接 import server-only 代码

正确做法

ts
// ❌ 错误:值导入
import { load } from "./[id].server";

// ✅ 正确:类型导入
import type { load } from "./[id].server";

2. SSR 时 HttpClient 请求失败 "no host"

症状localhost:5173/api/foo 在 SSR 阶段返回 ECONNREFUSED 或 "Invalid URL"。

原因:HttpClient 用相对路径时 SSR 阶段没有 host header。

解决:在 app.config.ts 中注册 requestContextInterceptor(必须在拦截器数组最后):

ts
provideHttpClient(
  withFetch(),
  withInterceptors([
    /* 其他拦截器 */
    requestContextInterceptor, // 最后
  ])
);

3. Angular 模板插值在 VitePress 中被错误解析

症状:在 VitePress 内联反引号中写 Angular 模板示例时,{{ data() }} 被当成 Vue 表达式编译失败。

解决:用 <span v-pre> 包裹反引号本身:

md
模板中 <span v-pre>`{{ data() | json }}`</span> 渲染数据。

代码块(```ts)内的 {{ }} 不会被解析,无需特殊处理。

4. 第三方 Angular 库在 SSR 报错

症状:dev 模式控制台 [vite] Error: ... is not a function,引用 @apollo/client / @spartan-ng/* / @angular/material 部分组件。

解决:加入 ssr.noExternal

ts
// vite.config.ts
export default defineConfig({
  ssr: {
    noExternal: ["apollo-angular", "apollo-angular/**", "@spartan-ng/**"],
  },
});

5. Zone.js + Zoneless 混淆

症状:升级 Angular 后 SSR build 报 "Zone is not defined"。

原因:Angular 18+ 引入 zoneless,但 SSR 入口仍可能需要 zone.js polyfill。

解决:在 main.server.ts 头部确保 @angular/platform-server/init 引入:

ts
import "@angular/platform-server/init"; // ⚠️ 必须在最前
import { render } from "@analogjs/router/server";
// ...

Angular 20+ 后 zoneless 默认稳定,可移除 zone.js:

ts
// app.config.ts
import { provideZonelessChangeDetection } from "@angular/core";

providers: [provideZonelessChangeDetection()];

6. 路由文件没生效

症状:新增 src/app/pages/foo.page.ts/foo 404。

原因

  • 没有 export default class(路由必须是 default export)
  • dev server 缓存了旧的路由列表
  • 文件名拼写错误(如 .pages.ts 而非 .page.ts

解决

  • 确认 default export + 文件后缀
  • 重启 dev server(Ctrl+C 重新 npm run start
  • 访问 /__analog/routes 查看 Analog 实际发现的路由

7. Nitro 调试

症状:API route 返回 500 但日志不详细。

解决

  • 在 handler 中加 console.log(event) / console.log(error)——Nitro 日志直接打印到 dev server 终端
  • 启用 verbose 日志:在 vite.config.ts 添加 nitro: { logLevel: 'debug' }
  • 查看产物:dist/analog/server/index.mjs 是完整的 Nitro 输出,可手动 node dist/analog/server/index.mjs 单独运行

8. prerender 路径列表与 content 目录不同步

症状:博客新增 markdown 文件后,构建 SSG 时该文件没有被预渲染。

原因prerender.routes 是手动列表,没用 contentDir 动态扫描。

解决:用 contentDir + transform

ts
analog({
  prerender: {
    routes: async () => [
      "/",
      "/blog",
      {
        contentDir: "src/content/blog",
        transform: (file) => `/blog/${file.attributes.slug || file.name}`,
      },
    ],
  },
});

9. WebSocket HMR 与项目 WebSocket 冲突

症状:启用 Nitro websocket 后,dev server 报 "port already in use" 或 HMR 失效。

原因:Vite HMR WebSocket 与项目自定义 WS 同端口。

解决:分离端口:

ts
// vite.config.ts
server: {
  port: 3000,        // 主服务
  hmr: {
    port: 3002,      // HMR 独立端口
    path: "vite-hmr",
  },
}

同时删除 angular.json / project.json 中的 serve.options.port(避免覆盖)。

10. Form Action 提交后页面不响应

症状:表单提交后服务端 action 已执行,但 client (onSuccess) 事件没触发。

原因

  • <form> 没引入 FormAction 指令(必须在组件 imports 中加)
  • (onSuccess) 拼写错误(不是 (success)(onsuccess)
  • server action 没 return json({...}) 而是直接 return data

解决

ts
@Component({
  imports: [FormAction],  // ⚠️ 必须导入
  template: `
    <form method="post" (onSuccess)="handle($event)">  <!-- ⚠️ 注意大小写 -->
      ...
    </form>
  `,
})
ts
// action 必须用 json/fail/redirect 三个辅助
return json({ success: true }); // ✅
return { success: true }; // ❌

接下来读什么

  • 参考:API 速查 / 文件约定 / vite.config.ts 全部 analog() 选项 / Nitro preset 列表 / 命名约定 / 常用集成包